net-connector 0.7.3 → 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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/CONTRIBUTING.md +12 -0
  4. data/README.md +27 -424
  5. data/README.zh-CN.md +64 -0
  6. data/{exe/net-connector-backup → bin/net-backup} +1 -0
  7. data/docs/VERIFICATION.md +2 -2
  8. data/docs/architecture.md +27 -5
  9. data/docs/netdisco.md +176 -0
  10. data/docs/netdisco.zh-CN.md +247 -0
  11. data/docs/usage.md +124 -0
  12. data/docs/usage.zh-CN.md +190 -0
  13. data/examples/backup.yml +5 -4
  14. data/examples/inventory_sql.yml +5 -1
  15. data/lib/net/connector/device/base.rb +12 -5
  16. data/lib/net/connector/device/local_backup.rb +3 -0
  17. data/lib/net/connector/device/running_config/strategy.rb +2 -1
  18. data/lib/net/connector/device/running_config.rb +2 -0
  19. data/lib/net/connector/device/tftp/receipt.rb +1 -0
  20. data/lib/net/connector/device/tftp/strategy.rb +28 -9
  21. data/lib/net/connector/device/tftp.rb +23 -16
  22. data/lib/net/connector/device/topology/immediate_strategy.rb +2 -1
  23. data/lib/net/connector/engine/authentication.rb +1 -0
  24. data/lib/net/connector/engine/core.rb +1 -0
  25. data/lib/net/connector/engine/dialogue.rb +5 -134
  26. data/lib/net/connector/engine/errors.rb +0 -92
  27. data/lib/net/connector/engine/known_hosts.rb +20 -4
  28. data/lib/net/connector/engine/log.rb +27 -6
  29. data/lib/net/connector/engine/recovery.rb +1 -1
  30. data/lib/net/connector/engine/redactor.rb +98 -0
  31. data/lib/net/connector/engine/response_reader.rb +139 -0
  32. data/lib/net/connector/engine/session.rb +20 -7
  33. data/lib/net/connector/engine/transport.rb +21 -10
  34. data/lib/net/connector/netdisco/backup_run.rb +35 -8
  35. data/lib/net/connector/netdisco/cli/options.rb +20 -8
  36. data/lib/net/connector/netdisco/cli.rb +14 -11
  37. data/lib/net/connector/netdisco/config_file.rb +19 -14
  38. data/lib/net/connector/netdisco/connection.rb +0 -1
  39. data/lib/net/connector/netdisco/database_client.rb +44 -26
  40. data/lib/net/connector/netdisco/device.rb +5 -0
  41. data/lib/net/connector/netdisco/diagnostic.rb +8 -0
  42. data/lib/net/connector/netdisco/fleet.rb +12 -3
  43. data/lib/net/connector/netdisco/plan.rb +16 -3
  44. data/lib/net/connector/netdisco/planner.rb +2 -1
  45. data/lib/net/connector/netdisco/progress.rb +31 -7
  46. data/lib/net/connector/netdisco/report/files.rb +4 -2
  47. data/lib/net/connector/netdisco/result_store.rb +6 -7
  48. data/lib/net/connector/netdisco/rules.rb +8 -5
  49. data/lib/net/connector/netdisco/settings.rb +83 -19
  50. data/lib/net/connector/netdisco/tftp_archive.rb +6 -3
  51. data/lib/net/connector/netdisco/tftp_verification.rb +1 -0
  52. data/lib/net/connector/netdisco/worker.rb +3 -1
  53. data/lib/net/connector/netdisco.rb +1 -21
  54. data/lib/net/connector/storage/private_file.rb +6 -0
  55. data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
  56. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +2 -0
  57. data/lib/net/connector/vendor/cisco_ios/topology.rb +2 -0
  58. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +2 -0
  59. data/lib/net/connector/vendor/h3c/tftp_backup.rb +1 -0
  60. data/lib/net/connector/vendor/h3c/topology.rb +2 -0
  61. data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
  62. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +2 -0
  63. data/lib/net/connector/vendor/hillstone/topology.rb +2 -0
  64. data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -1
  65. data/lib/net/connector/vendor/palo_alto/running_config.rb +27 -6
  66. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +2 -0
  67. data/lib/net/connector/vendor/palo_alto/topology.rb +16 -16
  68. data/lib/net/connector/vendor/radware/running_config.rb +1 -0
  69. data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -0
  70. data/lib/net/connector/vendor/radware/topology.rb +1 -0
  71. data/lib/net/connector/version.rb +2 -1
  72. data/lib/net/connector.rb +1 -0
  73. data/lib/net-connector.rb +1 -0
  74. metadata +12 -20
  75. data/lib/net/connector/netdisco/tftp_history.rb +0 -16
@@ -8,21 +8,20 @@ require_relative "../storage/private_file"
8
8
  module Net
9
9
  module Connector
10
10
  module Netdisco
11
+ # 调用方选择结果持久化方式;所有写入器接收同一份 Report,不再次执行设备任务。
11
12
  module ResultStore
12
13
  # 每批保存一份私有 JSON 报告,通过重命名防止半写文件。
13
14
  class Json
14
15
  # 以临时文件和重命名原子写入私有 JSON 报告。
15
- def write(batch, directory:)
16
+ def write(report, directory:)
16
17
  destination = File.expand_path(directory)
17
18
  FileUtils.mkdir_p(destination, mode: 0o700)
18
- filename = "netdisco-#{batch.mode}-#{batch.started_at.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(4)}.json"
19
+ filename = "netdisco-#{report.mode}-#{report.started_at.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(4)}.json"
19
20
  path = File.join(destination, filename)
20
- Storage::PrivateFile.write(path, JSON.pretty_generate(batch.summary)).path
21
+ Storage::PrivateFile.write(path, JSON.pretty_generate(report.summary)).path
21
22
  end
22
23
  end
23
24
 
24
- Text = Json # Compatibility with the former name of the JSON writer.
25
-
26
25
  # 数据库仓储由调用方提供,可使用 Active Record 或其他对象。
27
26
  # 仓储须实现 create!,本项目不负责数据库表结构。
28
27
  class Database
@@ -34,8 +33,8 @@ module Net
34
33
  end
35
34
 
36
35
  # 把批量摘要写入调用方仓储并返回记录位置。
37
- def write(batch, **_options)
38
- record = @repository.create!(batch.summary)
36
+ def write(report, **_options)
37
+ record = @repository.create!(report.summary)
39
38
  record.respond_to?(:id) ? "database:#{record.id}" : "database"
40
39
  end
41
40
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ipaddr"
4
+ require_relative "../../connector"
4
5
 
5
6
  module Net
6
7
  module Connector
@@ -93,11 +94,13 @@ module Net
93
94
 
94
95
  # 根据地址、显式规则和厂商信息识别连接器。
95
96
  def resolve(row)
96
- begin
97
- address = IPAddr.new(row["ip"]).to_s
98
- return @host_overrides.fetch(address) if @host_overrides.key?(address)
99
- rescue IPAddr::Error, TypeError
100
- # 无效的管理地址交由 Device.from_row 标记。
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
101
104
  end
102
105
  label = normalize(row["vendor"])
103
106
  os = normalize(row["os"])
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "../../connector"
5
+ require_relative "client"
6
+ require_relative "database_client"
7
+ require_relative "config_file"
8
+ require_relative "rules"
9
+ require_relative "planner"
4
10
 
5
11
  module Net
6
12
  module Connector
@@ -10,21 +16,24 @@ module Net
10
16
  DEFAULT_BACKUP_DIRECTORY = "backups"
11
17
  DEFAULT_CONCURRENCY = 4
12
18
  MAX_CONCURRENCY = 50
13
- ENVIRONMENT_KEYS = %w[NETDISCO_URL NC_BACKUP_DIRECTORY NC_CONCURRENCY NC_SAMPLE_PER_VENDOR
14
- NC_PROTOCOL NC_KNOWN_HOSTS NC_HOST_KEY_POLICY NC_LOG_DIRECTORY NC_LOG_LEVEL TFTP_HOST].freeze
19
+ # 这里只列可从环境读取的公开策略;凭据单独动态读取,其他内部键只供 YAML/CLI 使用。
20
+ ENVIRONMENT_KEYS = %w[ND_URL].freeze
15
21
  DEFAULT_PAGE_SIZE = Client::DEFAULTS.fetch(:page_size)
16
22
  UNSET = Object.new.freeze
17
23
  private_constant :UNSET
18
24
 
25
+ # 加载无凭据 YAML 作为默认策略,保留传入环境作为动态凭据来源。
19
26
  def self.from_file(path, env: ENV)
20
27
  new(env: env, defaults: ConfigFile.load(path))
21
28
  end
22
29
 
30
+ # 只有 NC_CONFIG 显式指定时才读取文件,不隐式加载 .env。
23
31
  def self.from_env(env: ENV)
24
32
  path = env["NC_CONFIG"]
25
33
  path && !path.empty? ? from_file(path, env: env) : new(env: env)
26
34
  end
27
35
 
36
+ # 分开保存默认策略、显式覆盖与凭据来源;fixed 实例只承载已复制的公开快照。
28
37
  def initialize(env: ENV, defaults: {}, overrides: {}, policy: nil, fixed: false)
29
38
  @env, @defaults, @overrides = env, defaults, overrides
30
39
  @policy, @fixed = policy, fixed
@@ -32,7 +41,14 @@ module Net
32
41
 
33
42
  # 调用入口补充运行目录等非敏感覆盖,凭据仍从同一来源读取。
34
43
  def with_overrides(values)
35
- self.class.new(env: @env, defaults: @defaults, overrides: @overrides.merge(values))
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)
36
52
  end
37
53
 
38
54
  # 快照自身不保留 ENV、凭据 resolver 或原 Settings 的引用,不能携带秘密进入摘要。
@@ -55,6 +71,7 @@ module Net
55
71
  self.class.new(env: @env, defaults: @defaults, overrides: @overrides, policy: snapshot(mode: mode))
56
72
  end
57
73
 
74
+ # 按业务模式预检所有策略;离线导出仅验证目录,不读取清单或设备凭据。
58
75
  def validate!(mode:)
59
76
  unless %i[backup tftp inventory show_config export].include?(mode)
60
77
  raise ArgumentError, "unknown settings validation mode"
@@ -67,7 +84,9 @@ module Net
67
84
  concurrency
68
85
  limit_per_vendor
69
86
  log_directory
87
+ tftp_root
70
88
  connection_options
89
+ database_options
71
90
  Net::Connector.vendors.each { |vendor| connection_options(vendor) }
72
91
  tftp_source_files.each_value { |source| TftpTarget.validate_source_file!(source) }
73
92
  tftp_vrfs
@@ -75,6 +94,7 @@ module Net
75
94
  self
76
95
  end
77
96
 
97
+ # 用固定策略创建清单客户端,但每次读取最新身份凭据;显式认证覆盖默认认证来源。
78
98
  def client(policy: snapshot(mode: :inventory), credentials: nil)
79
99
  if credentials && (!credentials.is_a?(Hash) || credentials.keys.sort != %i[password username] ||
80
100
  !credentials.values.all? { |value| present?(value) })
@@ -82,28 +102,29 @@ module Net
82
102
  end
83
103
  options = policy.client_options
84
104
  if policy.inventory_source == :postgres
85
- connection = DatabaseClient::CONNECTION_ENV.each_with_object({}) do |(key, name), values|
105
+ connection = DatabaseClient::CONNECTION_ENV.each_with_object(policy.database_options.dup) do |(key, name), values|
86
106
  item = secret_value(name)
87
107
  values[key] = item if item
88
108
  end
89
109
  connection.merge!(user: credentials[:username], password: credentials[:password]) if credentials
90
110
  return DatabaseClient.new(connection_options: connection, **options)
91
111
  end
92
- raise ArgumentError, "NETDISCO_URL is required" unless options.fetch(:url)
112
+ raise ArgumentError, "ND_URL is required" unless options.fetch(:url)
93
113
 
94
114
  authentication = if credentials
95
115
  credentials
96
- elsif (token = secret_value("NETDISCO_API_KEY"))
116
+ elsif (token = secret_value("ND_API_KEY"))
97
117
  { api_key: token }
98
118
  else
99
- { username: required_secret("NETDISCO_USERNAME"), password: required_secret("NETDISCO_PASSWORD") }
119
+ { username: required_secret("ND_USERNAME"), password: required_secret("ND_PASSWORD") }
100
120
  end
101
121
  Client.new(**options, **authentication)
102
122
  end
103
123
 
124
+ # 清单来源来自 YAML/CLI,HTTP 与 PostgreSQL 共用后续计划和执行接口。
104
125
  def inventory_source
105
- source = value("NETDISCO_SOURCE") || "http"
106
- raise ArgumentError, "NETDISCO_SOURCE must be http or postgres" unless %w[http postgres].include?(source)
126
+ source = value("ND_SOURCE") || "http"
127
+ raise ArgumentError, "ND_SOURCE must be http or postgres" unless %w[http postgres].include?(source)
107
128
 
108
129
  source.to_sym
109
130
  end
@@ -115,6 +136,7 @@ module Net
115
136
  inventory_source == :postgres ? postgres_options(options) : http_options(options)
116
137
  end
117
138
 
139
+ # 将筛选和映射策略交给 Rules 校验,未识别设备由清单结果单独标记。
118
140
  def rules
119
141
  Rules.new(include_hosts: list("NC_INCLUDE_HOSTS"),
120
142
  exclude_hosts: list("NC_EXCLUDE_HOSTS"),
@@ -124,6 +146,7 @@ module Net
124
146
  mappings: json("NC_DEVICE_RULES", []))
125
147
  end
126
148
 
149
+ # 将备份目录解析为绝对路径;这里只校验配置,不创建目录。
127
150
  def backup_directory
128
151
  path = raw("NC_BACKUP_DIRECTORY", DEFAULT_BACKUP_DIRECTORY)
129
152
  raise ArgumentError, "NC_BACKUP_DIRECTORY must be nonempty" unless present?(path)
@@ -131,6 +154,7 @@ module Net
131
154
  File.expand_path(path)
132
155
  end
133
156
 
157
+ # 与 Worker 共用并发上限,非法值在任务与目录创建前失败。
134
158
  def concurrency
135
159
  count = integer("NC_CONCURRENCY", DEFAULT_CONCURRENCY)
136
160
  raise ArgumentError, "NC_CONCURRENCY must be at most #{MAX_CONCURRENCY}" if count > MAX_CONCURRENCY
@@ -138,6 +162,7 @@ module Net
138
162
  count
139
163
  end
140
164
 
165
+ # 显式 nil 表示全量;tftp: true 且未设置时采用五台上限,示例入口另外选择全量。
141
166
  def limit_per_vendor(tftp: false)
142
167
  count = if configured?("NC_SAMPLE_PER_VENDOR")
143
168
  integer("NC_SAMPLE_PER_VENDOR", 5) unless raw("NC_SAMPLE_PER_VENDOR").nil?
@@ -148,6 +173,7 @@ module Net
148
173
  count
149
174
  end
150
175
 
176
+ # 华为批次默认上传 startup.cfg;H3C 省略源文件时由设备策略探测。
151
177
  def tftp_source_files
152
178
  files = { huawei: raw("NC_HUAWEI_TFTP_SOURCE_FILE", "flash:/startup.cfg") }
153
179
  %i[h3c h3c_wireless].each do |vendor|
@@ -157,6 +183,7 @@ module Net
157
183
  files
158
184
  end
159
185
 
186
+ # 按厂商读取 VRF 映射,不能把不支持 VRF 的厂商静默忽略。
160
187
  def tftp_vrfs
161
188
  mapping = json("NC_TFTP_VRFS", {})
162
189
  raise ArgumentError, "NC_TFTP_VRFS must be a JSON object" unless mapping.is_a?(Hash)
@@ -164,6 +191,7 @@ module Net
164
191
  self.class.validate_vrfs!(mapping.transform_keys(&:to_sym))
165
192
  end
166
193
 
194
+ # 只允许 NX-OS、山石的安全单一 VRF 名称,禁止命令拼接字符。
167
195
  def self.validate_vrfs!(vrfs)
168
196
  unless vrfs.is_a?(Hash) && vrfs.all? { |vendor, name|
169
197
  %i[cisco_nxos hillstone].include?(vendor) && name.is_a?(String) &&
@@ -174,8 +202,32 @@ module Net
174
202
  vrfs
175
203
  end
176
204
 
177
- def tftp_server = value("TFTP_HOST")
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)
178
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 复用连接校验;厂商协议覆盖通用协议,不包含设备凭据。
179
231
  def connection_options(vendor = nil)
180
232
  configuration = Configuration.new(
181
233
  protocol: vendor_protocols[vendor.to_s] || value("NC_PROTOCOL") || "ssh",
@@ -188,6 +240,7 @@ module Net
188
240
  options.freeze
189
241
  end
190
242
 
243
+ # 厂商协议必须使用已登记的厂商键和 ssh/telnet,不接受未知映射。
191
244
  def vendor_protocols
192
245
  values = json("NC_VENDOR_PROTOCOLS", {})
193
246
  unless values.is_a?(Hash) && values.all? { |vendor, protocol| Net::Connector.vendors.map(&:to_s).include?(vendor) && %w[ssh telnet].include?(protocol) }
@@ -196,6 +249,7 @@ module Net
196
249
  values
197
250
  end
198
251
 
252
+ # 每台任务读取最新厂商凭据,缺失字段回退到通用凭据;无用户名时跳过设备。
199
253
  def device_credentials_for(device)
200
254
  prefix = "NC_#{device.vendor.to_s.upcase}_"
201
255
  username = secret_value(prefix + "USERNAME") || secret_value("NC_DEVICE_USERNAME")
@@ -206,11 +260,13 @@ module Net
206
260
  enable_password: secret_value(prefix + "ENABLE_PASSWORD") || secret_value("NC_ENABLE_PASSWORD") }
207
261
  end
208
262
 
263
+ # 会话日志目录属于公开策略;调用方和批次入口负责实际创建。
209
264
  def log_directory
210
265
  path = value("NC_LOG_DIRECTORY")
211
266
  File.expand_path(path) if path
212
267
  end
213
268
 
269
+ # 生成已验证的无凭据配置摘要,不展开原环境或 resolver。
214
270
  def public_config
215
271
  snapshot(mode: :show_config).config_hash
216
272
  end
@@ -219,9 +275,10 @@ module Net
219
275
 
220
276
  protected
221
277
 
278
+ # 只输出公开策略;数据库身份和设备凭据不得通过配置摘要进入报告。
222
279
  def config_hash
223
280
  {
224
- netdisco: client_options.merge(source: inventory_source),
281
+ netdisco: client_options.merge(source: inventory_source, database: database_options),
225
282
  backup: { directory: backup_directory, concurrency: concurrency, limit_per_vendor: limit_per_vendor },
226
283
  inventory: { include_hosts: list("NC_INCLUDE_HOSTS"),
227
284
  exclude_hosts: list("NC_EXCLUDE_HOSTS"),
@@ -233,10 +290,11 @@ module Net
233
290
  log_directory: log_directory,
234
291
  vendor_protocols: vendor_protocols
235
292
  ),
236
- 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 }
237
294
  }
238
295
  end
239
296
 
297
+ # 优先读取固定策略或显式覆盖;内部键的名称不表示该键可由进程环境配置。
240
298
  def raw(key, default = nil)
241
299
  return @policy.raw(key, default) if @policy
242
300
  return @overrides.fetch(key) if @overrides.key?(key)
@@ -254,7 +312,7 @@ module Net
254
312
 
255
313
  def client_budget_options(defaults)
256
314
  defaults.to_h do |name, default|
257
- key = "NETDISCO_#{name.to_s.upcase}"
315
+ key = "ND_#{name.to_s.upcase}"
258
316
  parsed = case name
259
317
  when :inventory_timeout then number(key, default)
260
318
  when :allow_insecure_http then boolean(key, default)
@@ -265,16 +323,16 @@ module Net
265
323
  end
266
324
 
267
325
  def postgres_options(options)
268
- query = value("NETDISCO_QUERY") || (raise ArgumentError, "NETDISCO_QUERY is required for postgres")
269
- DatabaseClient.options(query: query, query_params: json("NETDISCO_QUERY_PARAMS", []), **options)
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)
270
328
  end
271
329
 
272
330
  def http_options(options)
273
- if value("NETDISCO_QUERY") || value("NETDISCO_QUERY_PARAMS")
274
- raise ArgumentError, "SQL query settings require NETDISCO_SOURCE=postgres"
331
+ if value("ND_QUERY") || value("ND_QUERY_PARAMS")
332
+ raise ArgumentError, "SQL query settings require ND_SOURCE=postgres"
275
333
  end
276
334
  options = Client.options(**options)
277
- url = value("NETDISCO_URL")
335
+ url = value("ND_URL")
278
336
  Client.validate_url!(url, allow_insecure_http: options.fetch(:allow_insecure_http)) if url
279
337
  options.merge(url: url).freeze
280
338
  end
@@ -283,11 +341,16 @@ module Net
283
341
  ConfigFile::FIELDS.values.flat_map { |section| section.values.map(&:first) }
284
342
  end
285
343
 
344
+ # 旧前缀和已移除策略明确报错;错误只包含变量名,不泄露变量值。
286
345
  def validate_environment!
287
346
  legacy = @env.keys.find { |key| key.start_with?("NET_CONNECTOR_") }
288
- raise ArgumentError, "#{legacy} has been renamed to #{legacy.sub("NET_CONNECTOR_", "NC_")}" if 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
289
351
 
290
352
  removed = non_secret_keys - ENVIRONMENT_KEYS
353
+ removed << "NC_PROGRESS"
291
354
  removed += Net::Connector.vendors.map { |vendor| "NC_#{vendor.to_s.upcase}_PROTOCOL" }
292
355
  key = removed.find { |name| @env.key?(name) }
293
356
  raise ArgumentError, "#{key} is no longer an environment setting; use YAML via NC_CONFIG or CLI options" if key
@@ -343,6 +406,7 @@ module Net
343
406
  result if present?(result)
344
407
  end
345
408
 
409
+ # 凭据只从活的环境映射读取并复制,不允许 defaults/overrides 充当秘密来源。
346
410
  def secret_value(key)
347
411
  result = @env[key]
348
412
  raise ArgumentError, "#{key} must be a String" unless result.nil? || result.is_a?(String)
@@ -2,10 +2,12 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "tempfile"
5
+ require "digest"
5
6
  require_relative "../storage/backup_lock"
6
7
  require_relative "../storage/private_file"
7
8
  require_relative "../storage/safe_file"
8
9
  require_relative "tftp_verification"
10
+ require_relative "diagnostic"
9
11
 
10
12
  module Net
11
13
  module Connector
@@ -15,6 +17,7 @@ module Net
15
17
  BATCH_DIRECTORY_NAME = /\A\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:_\d{2})?\z/
16
18
  private_constant :BATCH_DIRECTORY_NAME
17
19
 
20
+ # 固定名上传无法保留历史时,在设备上传之前拒绝执行。
18
21
  class Unavailable < Net::Connector::Error
19
22
  def initialize(host: nil)
20
23
  super("a local TFTP root is required to retain a fixed-name upload", code: :tftp_history_unavailable,
@@ -22,6 +25,7 @@ module Net
22
25
  end
23
26
  end
24
27
 
28
+ # 设备可能已经上传,归档失败不能撤销或重新执行设备命令。
25
29
  class ArchiveFailed < Net::Connector::Error
26
30
  def initialize(host: nil)
27
31
  super("the uploaded file could not be retained in the batch", code: :tftp_archive_failed,
@@ -38,6 +42,7 @@ module Net
38
42
  @verifier = TftpVerification.new(root: @server_root)
39
43
  end
40
44
 
45
+ # 有本机根目录时保留原名并归档;远端服务器使用批次后缀避免重复覆盖。
41
46
  def upload_filename(device)
42
47
  name = device.tftp_filename
43
48
  return name if @server_root || device.vendor == :palo_alto
@@ -48,6 +53,7 @@ module Net
48
53
  TftpTarget.validate_path!("#{stem.byteslice(0, TftpTarget::MAX_PATH_BYTES - suffix.bytesize)}#{suffix}")
49
54
  end
50
55
 
56
+ # 上传与归档在同一目标锁内完成,后续失败仍保留可确认的上传回执和提交路径。
51
57
  def upload_and_archive(device, started_at:)
52
58
  raise Unavailable.new(host: device.host) if device.vendor == :palo_alto && !@server_root
53
59
 
@@ -69,9 +75,6 @@ module Net
69
75
  end
70
76
  end
71
77
 
72
- alias path_for upload_filename
73
- alias capture upload_and_archive
74
-
75
78
  private
76
79
 
77
80
  # 文件系统的 mtime 可能落后于 Time.now;用同一服务器目录的时间建立上传边界。
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../storage/safe_file"
4
+ require_relative "../device/tftp/receipt"
4
5
 
5
6
  module Net
6
7
  module Connector
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "settings"
4
+
3
5
  module Net
4
6
  module Connector
5
7
  module Netdisco
@@ -58,7 +60,7 @@ module Net
58
60
  threads.each do |thread|
59
61
  begin
60
62
  thread.join
61
- rescue Exception # rubocop:disable Lint/RescueException -- Preserve the original interruption after all workers stop.
63
+ rescue Exception # rubocop:disable Lint/RescueException -- 等待全部工作线程停止,不让收尾异常覆盖原中断。
62
64
  nil
63
65
  end
64
66
  end
@@ -1,25 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../connector"
4
- require_relative "netdisco/client"
5
- require_relative "netdisco/database_client"
6
- require_relative "netdisco/rules"
7
- require_relative "netdisco/device"
8
- require_relative "netdisco/config_file"
9
- require_relative "netdisco/settings"
10
- require_relative "netdisco/worker"
11
- require_relative "netdisco/result_store"
12
- require_relative "netdisco/batch"
13
- require_relative "netdisco/planner"
14
- require_relative "netdisco/fleet"
15
- require_relative "netdisco/progress"
3
+ # 公共入口只组合应用流程;每个模块自行声明依赖,不要求调用方维护加载顺序。
16
4
  require_relative "netdisco/cli"
17
- require_relative "storage/batch_directory"
18
- require_relative "netdisco/report/text"
19
- require_relative "netdisco/connection"
20
- require_relative "netdisco/cli/options"
21
- require_relative "netdisco/report/files"
22
- require_relative "netdisco/tftp_verification"
23
- require_relative "netdisco/tftp_archive"
24
- require_relative "netdisco/tftp_history"
25
5
  require_relative "netdisco/backup_run"
@@ -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/tftp/file_upload"
5
5
  module Net
6
6
  module Connector
7
7
  module H3c
8
+ # 未指定源文件时探测 H3C 启动配置,显式文件不推断配置种类。
8
9
  class TftpBackup < Tftp::FileUpload
9
10
  # 自动探测选择的是启动配置;显式文件仍不能推断为运行或启动配置。
10
11
  def receipt_metadata(target, source_file:, explicit_source:)
@@ -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
  # 用数据行和设备报告的总数交叉核对,避免遗漏邻居。
@@ -5,6 +5,7 @@ require_relative "../../device/tftp/file_upload"
5
5
  module Net
6
6
  module Connector
7
7
  module Huawei
8
+ # 上传调用方明确指定的华为保存文件,连接前拒绝缺少源文件的请求。
8
9
  class TftpBackup < Tftp::FileUpload
9
10
  # 缺少源文件也必须在首次设备 I/O 之前拒绝。
10
11
  def validate_options!(target, source_file:, vrf: nil)
@@ -12,7 +13,7 @@ module Net
12
13
  raise ArgumentError, "Huawei TFTP backup requires source_file" if source_file.nil?
13
14
  end
14
15
 
15
- # 要求调用方提供合法的华为启动配置文件路径。
16
+ # 读取调用方明确指定的合法保存文件路径,不根据文件名推断为启动配置。
16
17
  def resolve_source_file(value)
17
18
  raise ArgumentError, "Huawei TFTP backup requires source_file" unless value
18
19