net-connector 0.7.2 → 0.8.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -1
  3. data/CONTRIBUTING.md +1 -1
  4. data/README.md +16 -11
  5. data/docs/RELEASING.md +2 -2
  6. data/docs/architecture.md +22 -1
  7. data/lib/net/connector/device/base.rb +7 -1
  8. data/lib/net/connector/device/tftp.rb +14 -8
  9. data/lib/net/connector/engine/authentication.rb +1 -0
  10. data/lib/net/connector/engine/dialogue.rb +0 -132
  11. data/lib/net/connector/engine/error_metadata.rb +5 -3
  12. data/lib/net/connector/engine/errors.rb +0 -92
  13. data/lib/net/connector/engine/known_hosts.rb +14 -1
  14. data/lib/net/connector/engine/log.rb +17 -1
  15. data/lib/net/connector/engine/redactor.rb +98 -0
  16. data/lib/net/connector/engine/response_reader.rb +139 -0
  17. data/lib/net/connector/engine/session.rb +6 -2
  18. data/lib/net/connector/netdisco/backup_run.rb +22 -3
  19. data/lib/net/connector/netdisco/cli/options.rb +3 -0
  20. data/lib/net/connector/netdisco/cli.rb +11 -8
  21. data/lib/net/connector/netdisco/connection.rb +0 -1
  22. data/lib/net/connector/netdisco/database_client.rb +4 -2
  23. data/lib/net/connector/netdisco/device.rb +2 -0
  24. data/lib/net/connector/netdisco/diagnostic.rb +4 -0
  25. data/lib/net/connector/netdisco/fleet.rb +10 -1
  26. data/lib/net/connector/netdisco/plan.rb +13 -0
  27. data/lib/net/connector/netdisco/planner.rb +2 -1
  28. data/lib/net/connector/netdisco/progress.rb +25 -8
  29. data/lib/net/connector/netdisco/report/files.rb +11 -6
  30. data/lib/net/connector/netdisco/report/text.rb +1 -1
  31. data/lib/net/connector/netdisco/report.rb +19 -20
  32. data/lib/net/connector/netdisco/result_store.rb +5 -7
  33. data/lib/net/connector/netdisco/rules.rb +1 -0
  34. data/lib/net/connector/netdisco/settings.rb +6 -0
  35. data/lib/net/connector/netdisco/tftp_archive.rb +28 -13
  36. data/lib/net/connector/netdisco/tftp_verification.rb +1 -0
  37. data/lib/net/connector/netdisco/worker.rb +2 -0
  38. data/lib/net/connector/netdisco.rb +1 -21
  39. data/lib/net/connector/vendor/palo_alto/running_config.rb +26 -6
  40. data/lib/net/connector/version.rb +1 -1
  41. metadata +7 -6
  42. data/lib/net/connector/netdisco/tftp_history.rb +0 -16
  43. /data/{exe/net-connector-backup → bin/net-backup} +0 -0
@@ -61,15 +61,14 @@ module Net
61
61
  policy: policy, duration_ms: duration_ms, report_diagnostic: diagnostic, log_directory: log_directory)
62
62
  end
63
63
 
64
- # 手工构造的批次同样经过诊断白名单,不序列化任意异常内容。
65
- def summary
64
+ # 统计不创建逐设备明细;文本与进度输出复用此入口,不缓存调用方持有的批次。
65
+ def statistics
66
66
  batch_data = batch_summary
67
67
  batch_data.merge(
68
68
  schema_version: 2, policy: policy, policy_success: policy_success?, duration_ms: duration_ms,
69
69
  coverage: { complete: !outcomes.empty? && outcomes.all? { |outcome| ATTEMPTED_STATUSES.include?(outcome.status) },
70
70
  attempted: outcomes.count { |outcome| ATTEMPTED_STATUSES.include?(outcome.status) },
71
71
  skipped: outcomes.count { |outcome| !ATTEMPTED_STATUSES.include?(outcome.status) } },
72
- devices: device_summaries(batch_data.fetch(:devices)),
73
72
  callback_errors: callback_errors.map { |entry| { host: entry[:host], error_type: ErrorMetadata.type(entry[:error_type]) } },
74
73
  report_location: report_location, report_error: ErrorMetadata.type(report_error),
75
74
  verification: mode == :tftp ? { verified: outcomes.count { |outcome| server_verified?(outcome) },
@@ -78,6 +77,9 @@ module Net
78
77
  )
79
78
  end
80
79
 
80
+ # 手工构造的批次同样经过诊断白名单,不序列化任意异常内容。
81
+ def summary = statistics.merge(devices: device_summaries)
82
+
81
83
  def inspect = "#<#{self.class} policy=#{policy} status=#{status} policy_success=#{policy_success?}>"
82
84
 
83
85
  private
@@ -92,19 +94,7 @@ module Net
92
94
  failed: outcomes.count { |outcome| outcome.status == :failed },
93
95
  skipped: outcomes.count(&:skipped?),
94
96
  counts: counts,
95
- callback_errors: callback_errors,
96
- devices: outcomes.map do |outcome|
97
- { host: outcome.device.host || outcome.device.source_ip, name: outcome.device.name,
98
- vendor: outcome.device.vendor,
99
- status: outcome.status, path: outcome.backup&.path,
100
- bytes: outcome.backup.is_a?(Backup) ? outcome.backup.bytes : nil,
101
- sha256: outcome.backup.is_a?(Backup) ? outcome.backup.sha256 : nil,
102
- change: outcome.backup.is_a?(Backup) ? outcome.backup.change : nil,
103
- previous_sha256: outcome.backup.is_a?(Backup) ? outcome.backup.previous_sha256 : nil,
104
- started_at: outcome.started_at&.iso8601, finished_at: outcome.finished_at&.iso8601,
105
- duration_ms: outcome.duration_ms,
106
- error_code: outcome.error_code, error_type: outcome.error_type }
107
- end
97
+ callback_errors: callback_errors
108
98
  }
109
99
  end
110
100
 
@@ -122,13 +112,22 @@ module Net
122
112
  bytes: receipt&.server_bytes, sha256: receipt&.server_sha256 }
123
113
  end
124
114
 
125
- def device_summaries(entries)
126
- entries.zip(outcomes).map do |entry, outcome|
115
+ def device_summaries
116
+ outcomes.map do |outcome|
127
117
  diagnostic = outcome.diagnostic || Diagnostic.new(error_code: outcome.error_code, error_type: outcome.error_type)
118
+ entry = { host: outcome.device.host || outcome.device.source_ip, name: outcome.device.name,
119
+ vendor: outcome.device.vendor, status: outcome.status, path: outcome.backup&.path,
120
+ bytes: outcome.backup.is_a?(Backup) ? outcome.backup.bytes : nil,
121
+ sha256: outcome.backup.is_a?(Backup) ? outcome.backup.sha256 : nil,
122
+ change: outcome.backup.is_a?(Backup) ? outcome.backup.change : nil,
123
+ previous_sha256: outcome.backup.is_a?(Backup) ? outcome.backup.previous_sha256 : nil,
124
+ started_at: outcome.started_at&.iso8601, finished_at: outcome.finished_at&.iso8601,
125
+ duration_ms: outcome.duration_ms,
126
+ error_code: diagnostic.error_code, error_type: diagnostic.error_type,
127
+ diagnostic: diagnostic.to_h.except(:error_code, :error_type) }
128
128
  entry = entry.merge(tftp_summary(outcome)) if mode == :tftp
129
129
  entry[:session_log] = File.join(log_directory, "#{outcome.device.host.tr(":", "_")}.log") if log_directory && outcome.device.host && outcome.started_at
130
- entry.merge(error_code: diagnostic.error_code, error_type: diagnostic.error_type,
131
- diagnostic: diagnostic.to_h.except(:error_code, :error_type))
130
+ entry
132
131
  end
133
132
  end
134
133
  end
@@ -12,17 +12,15 @@ module Net
12
12
  # 每批保存一份私有 JSON 报告,通过重命名防止半写文件。
13
13
  class Json
14
14
  # 以临时文件和重命名原子写入私有 JSON 报告。
15
- def write(batch, directory:)
15
+ def write(report, directory:)
16
16
  destination = File.expand_path(directory)
17
17
  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"
18
+ filename = "netdisco-#{report.mode}-#{report.started_at.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(4)}.json"
19
19
  path = File.join(destination, filename)
20
- Storage::PrivateFile.write(path, JSON.pretty_generate(batch.summary)).path
20
+ Storage::PrivateFile.write(path, JSON.pretty_generate(report.summary)).path
21
21
  end
22
22
  end
23
23
 
24
- Text = Json # Compatibility with the former name of the JSON writer.
25
-
26
24
  # 数据库仓储由调用方提供,可使用 Active Record 或其他对象。
27
25
  # 仓储须实现 create!,本项目不负责数据库表结构。
28
26
  class Database
@@ -34,8 +32,8 @@ module Net
34
32
  end
35
33
 
36
34
  # 把批量摘要写入调用方仓储并返回记录位置。
37
- def write(batch, **_options)
38
- record = @repository.create!(batch.summary)
35
+ def write(report, **_options)
36
+ record = @repository.create!(report.summary)
39
37
  record.respond_to?(:id) ? "database:#{record.id}" : "database"
40
38
  end
41
39
  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
@@ -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
@@ -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
@@ -32,6 +34,7 @@ module Net
32
34
  def initialize(directory:, root: nil)
33
35
  @report_directory = File.expand_path(directory)
34
36
  @server_root = root && File.realpath(root)
37
+ FileUtils.mkdir_p(@report_directory, mode: 0o700)
35
38
  @server_archive_directory = create_server_archive_directory if @server_root
36
39
  @remote_suffix = reserve_remote_suffix unless @server_root
37
40
  @verifier = TftpVerification.new(root: @server_root)
@@ -68,9 +71,6 @@ module Net
68
71
  end
69
72
  end
70
73
 
71
- alias path_for upload_filename
72
- alias capture upload_and_archive
73
-
74
74
  private
75
75
 
76
76
  # 文件系统的 mtime 可能落后于 Time.now;用同一服务器目录的时间建立上传边界。
@@ -137,7 +137,7 @@ module Net
137
137
 
138
138
  def archive_uploaded_file(outcome)
139
139
  receipt = outcome.backup
140
- saved_batch_path = nil
140
+ saved_paths = { local_path: nil, archive_path: nil }
141
141
  batch_directory = File.join(@report_directory, "tftp")
142
142
  FileUtils.mkdir_p(batch_directory, mode: 0o700)
143
143
  extension = File.extname(receipt.path)
@@ -152,9 +152,8 @@ module Net
152
152
  end
153
153
  raise ArchiveFailed.new(host: outcome.device.host) unless Digest::SHA256.hexdigest(bytes) == receipt.server_sha256
154
154
 
155
- Storage::PrivateFile.write(batch_archive_path, bytes)
156
- saved_batch_path = batch_archive_path
157
- Storage::PrivateFile.write(server_archive_path, bytes)
155
+ persist_archive(saved_paths, :local_path, batch_archive_path, bytes)
156
+ persist_archive(saved_paths, :archive_path, server_archive_path, bytes)
158
157
  if outcome.success?
159
158
  uploaded_fingerprint = Storage::SafeFile.fingerprint(uploaded_path)
160
159
  raise ArchiveFailed.new(host: outcome.device.host) unless uploaded_fingerprint.sha256 == receipt.server_sha256
@@ -162,16 +161,32 @@ module Net
162
161
 
163
162
  File.unlink(uploaded_path)
164
163
  end
165
- outcome.with(backup: receipt.with(local_path: batch_archive_path, archive_path: server_archive_path))
166
- rescue StandardError
167
- retained = outcome.with(backup: receipt.with(local_path: saved_batch_path))
168
- outcome.success? ? archive_failure(retained) : retained
164
+ outcome.with(backup: receipt.with(**saved_paths))
165
+ rescue StandardError => error
166
+ retained = outcome.with(backup: receipt.with(**saved_paths))
167
+ outcome.success? ? archive_failure(retained, persistence_error: error) : retained
168
+ end
169
+
170
+ # 路径仅由本次写入器的可信回执确认;同步或清理失败不能抹掉已替换的文件。
171
+ def persist_archive(saved_paths, field, path, bytes)
172
+ Storage::PrivateFile.write(path, bytes)
173
+ saved_paths[field] = path
174
+ rescue StandardError => error
175
+ if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed? && error.receipt.path == path
176
+ saved_paths[field] = path
177
+ end
178
+ raise
169
179
  end
170
180
 
171
- def archive_failure(outcome)
181
+ def archive_failure(outcome, persistence_error: nil)
172
182
  error = ArchiveFailed.new(host: outcome.device.host)
183
+ diagnostic = Diagnostic.from(error, backup: outcome.backup)
184
+ if Storage::PrivateFile.receipt_error?(persistence_error)
185
+ diagnostic = diagnostic.with(artifact_state: persistence_error.receipt.state, artifact_phase: persistence_error.receipt.phase,
186
+ underlying_type: persistence_error.underlying_type)
187
+ end
173
188
  outcome.with(status: :reported_with_error, error_code: error.code, error_type: error.class.name,
174
- diagnostic: Diagnostic.from(error, backup: outcome.backup))
189
+ diagnostic: diagnostic)
175
190
  end
176
191
 
177
192
  def archive_filename(device, extension)
@@ -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
@@ -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"
@@ -48,19 +48,39 @@ module Net
48
48
  return false if text.empty?
49
49
 
50
50
  pending = +""
51
+ quote = nil
51
52
  text.each_line do |line|
52
53
  next if pending.empty? && line.strip.empty?
53
54
  return false if pending.empty? && !line.start_with?("set ")
54
55
 
55
56
  pending << line
56
- begin
57
- Shellwords.split(pending)
58
- pending.clear
59
- rescue ArgumentError
60
- # 未闭合引号继续读取;末尾仍未闭合则拒绝整个采集结果。
61
- end
57
+ quote = quote_after(line, quote)
58
+ next if quote
59
+
60
+ Shellwords.split(pending)
61
+ pending.clear
62
62
  end
63
63
  pending.empty?
64
+ rescue ArgumentError
65
+ false
66
+ end
67
+
68
+ # 每行只扫描新增字节,整条逻辑命令闭合后才交给 Shellwords 校验。
69
+ # 单引号中的反斜杠是普通字符;其他位置的转义只影响紧随的一个字节。
70
+ def quote_after(line, quote)
71
+ escaped = false
72
+ line.each_byte do |byte|
73
+ if escaped
74
+ escaped = false
75
+ elsif byte == 92 && quote != 39
76
+ escaped = true
77
+ elsif byte == quote
78
+ quote = nil
79
+ elsif quote.nil? && (byte == 34 || byte == 39)
80
+ quote = byte
81
+ end
82
+ end
83
+ quote
64
84
  end
65
85
 
66
86
  # 去掉命令回显和提示符,保留真正需要校验的设备响应。
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Net
4
4
  module Connector
5
- VERSION = "0.7.2"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-connector
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - net-connector contributors
8
- bindir: exe
8
+ bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-06 00:00:00.000000000 Z
10
+ date: 2026-10-09 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: expect-pty
@@ -333,7 +333,7 @@ dependencies:
333
333
  version: '0.2'
334
334
  description: 通过 SSH 或 Telnet 登录网络设备,采集配置、执行命令脚本、处理交互提示,并提供脱敏日志与基于 Netdisco 清单的批量备份。
335
335
  executables:
336
- - net-connector-backup
336
+ - net-backup
337
337
  extensions: []
338
338
  extra_rdoc_files: []
339
339
  files:
@@ -342,12 +342,12 @@ files:
342
342
  - LICENSE
343
343
  - README.md
344
344
  - SECURITY.md
345
+ - bin/net-backup
345
346
  - docs/RELEASING.md
346
347
  - docs/VERIFICATION.md
347
348
  - docs/architecture.md
348
349
  - examples/backup.yml
349
350
  - examples/inventory_sql.yml
350
- - exe/net-connector-backup
351
351
  - lib/net-connector.rb
352
352
  - lib/net/connector.rb
353
353
  - lib/net/connector/device/base.rb
@@ -382,6 +382,8 @@ files:
382
382
  - lib/net/connector/engine/log/messages.rb
383
383
  - lib/net/connector/engine/log/stream.rb
384
384
  - lib/net/connector/engine/recovery.rb
385
+ - lib/net/connector/engine/redactor.rb
386
+ - lib/net/connector/engine/response_reader.rb
385
387
  - lib/net/connector/engine/result.rb
386
388
  - lib/net/connector/engine/session.rb
387
389
  - lib/net/connector/engine/terminal_renderer.rb
@@ -410,7 +412,6 @@ files:
410
412
  - lib/net/connector/netdisco/rules.rb
411
413
  - lib/net/connector/netdisco/settings.rb
412
414
  - lib/net/connector/netdisco/tftp_archive.rb
413
- - lib/net/connector/netdisco/tftp_history.rb
414
415
  - lib/net/connector/netdisco/tftp_verification.rb
415
416
  - lib/net/connector/netdisco/worker.rb
416
417
  - lib/net/connector/storage.rb
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "tftp_archive"
4
-
5
- module Net
6
- module Connector
7
- module Netdisco
8
- # Compatibility with the first batch archive entry point.
9
- class TftpHistory < TftpArchive
10
- def initialize(server:, **options) # rubocop:disable Lint/UnusedMethodArgument -- Accept the former constructor keyword.
11
- super(**options)
12
- end
13
- end
14
- end
15
- end
16
- end
File without changes