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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/CONTRIBUTING.md +12 -0
  4. data/README.md +26 -425
  5. data/README.zh-CN.md +64 -0
  6. data/bin/net-backup +1 -0
  7. data/docs/VERIFICATION.md +2 -2
  8. data/docs/architecture.md +9 -4
  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 +5 -4
  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 +9 -8
  22. data/lib/net/connector/device/topology/immediate_strategy.rb +2 -1
  23. data/lib/net/connector/engine/core.rb +1 -0
  24. data/lib/net/connector/engine/dialogue.rb +5 -2
  25. data/lib/net/connector/engine/known_hosts.rb +20 -4
  26. data/lib/net/connector/engine/log.rb +14 -9
  27. data/lib/net/connector/engine/recovery.rb +1 -1
  28. data/lib/net/connector/engine/session.rb +14 -5
  29. data/lib/net/connector/engine/transport.rb +21 -10
  30. data/lib/net/connector/netdisco/backup_run.rb +13 -5
  31. data/lib/net/connector/netdisco/cli/options.rb +17 -8
  32. data/lib/net/connector/netdisco/cli.rb +3 -3
  33. data/lib/net/connector/netdisco/config_file.rb +19 -14
  34. data/lib/net/connector/netdisco/database_client.rb +40 -24
  35. data/lib/net/connector/netdisco/device.rb +3 -0
  36. data/lib/net/connector/netdisco/diagnostic.rb +4 -0
  37. data/lib/net/connector/netdisco/fleet.rb +2 -2
  38. data/lib/net/connector/netdisco/plan.rb +12 -3
  39. data/lib/net/connector/netdisco/progress.rb +9 -0
  40. data/lib/net/connector/netdisco/report/files.rb +1 -0
  41. data/lib/net/connector/netdisco/result_store.rb +1 -0
  42. data/lib/net/connector/netdisco/rules.rb +7 -5
  43. data/lib/net/connector/netdisco/settings.rb +77 -19
  44. data/lib/net/connector/netdisco/tftp_archive.rb +4 -0
  45. data/lib/net/connector/netdisco/worker.rb +1 -1
  46. data/lib/net/connector/storage/private_file.rb +6 -0
  47. data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
  48. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +2 -0
  49. data/lib/net/connector/vendor/cisco_ios/topology.rb +2 -0
  50. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +2 -0
  51. data/lib/net/connector/vendor/h3c/tftp_backup.rb +1 -0
  52. data/lib/net/connector/vendor/h3c/topology.rb +2 -0
  53. data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
  54. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +2 -0
  55. data/lib/net/connector/vendor/hillstone/topology.rb +2 -0
  56. data/lib/net/connector/vendor/huawei/tftp_backup.rb +2 -1
  57. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -0
  58. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +2 -0
  59. data/lib/net/connector/vendor/palo_alto/topology.rb +16 -16
  60. data/lib/net/connector/vendor/radware/running_config.rb +1 -0
  61. data/lib/net/connector/vendor/radware/tftp_backup.rb +2 -0
  62. data/lib/net/connector/vendor/radware/topology.rb +1 -0
  63. data/lib/net/connector/version.rb +2 -1
  64. data/lib/net/connector.rb +1 -0
  65. data/lib/net-connector.rb +1 -0
  66. 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
- duplicates = devices.group_by(&:host).reject { |host, entries| host.nil? || entries.size == 1 }
133
- devices.map { |device| duplicates.key?(device.host) ? device.with(issue: :duplicate_host) : device }.freeze
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
- groups = inventory.group_by { |device| device.host && IPAddr.new(device.host).to_s }
76
- return unless groups.any? { |host, devices| host && devices.size > 1 && devices.any? { |device| device.issue != :duplicate_host } }
78
+ issues_by_host = {}
79
+ inventory.each do |device|
80
+ next unless device.host
77
81
 
78
- raise ArgumentError, "plan duplicate hosts must be marked as duplicate_host"
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)
@@ -8,6 +8,7 @@ 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
@@ -94,11 +94,13 @@ module Net
94
94
 
95
95
  # 根据地址、显式规则和厂商信息识别连接器。
96
96
  def resolve(row)
97
- begin
98
- address = IPAddr.new(row["ip"]).to_s
99
- return @host_overrides.fetch(address) if @host_overrides.key?(address)
100
- rescue IPAddr::Error, TypeError
101
- # 无效的管理地址交由 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
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
- ENVIRONMENT_KEYS = %w[NETDISCO_URL NC_BACKUP_DIRECTORY NC_CONCURRENCY NC_SAMPLE_PER_VENDOR
20
- 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
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
- 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)
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({}) do |(key, name), values|
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, "NETDISCO_URL is required" unless options.fetch(:url)
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("NETDISCO_API_KEY"))
116
+ elsif (token = secret_value("ND_API_KEY"))
103
117
  { api_key: token }
104
118
  else
105
- { username: required_secret("NETDISCO_USERNAME"), password: required_secret("NETDISCO_PASSWORD") }
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("NETDISCO_SOURCE") || "http"
112
- 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)
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
- 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)
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 = "NETDISCO_#{name.to_s.upcase}"
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("NETDISCO_QUERY") || (raise ArgumentError, "NETDISCO_QUERY is required for postgres")
275
- 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)
276
328
  end
277
329
 
278
330
  def http_options(options)
279
- if value("NETDISCO_QUERY") || value("NETDISCO_QUERY_PARAMS")
280
- 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"
281
333
  end
282
334
  options = Client.options(**options)
283
- url = value("NETDISCO_URL")
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} 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
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 -- Preserve the original interruption after all workers stop.
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/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
  # 用数据行和设备报告的总数交叉核对,避免遗漏邻居。