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
@@ -49,9 +49,14 @@ module Net
49
49
  finish_output
50
50
  @context = @context.merge(fields).freeze
51
51
  yield
52
+ rescue Exception # rubocop:disable Lint/RescueException -- Preserve only failures propagating from this scope, including interrupts.
53
+ failed = true
54
+ raise
52
55
  ensure
53
56
  begin
54
57
  finish_output
58
+ rescue Error
59
+ raise unless failed
55
60
  ensure
56
61
  @context = previous
57
62
  end
@@ -98,6 +103,9 @@ module Net
98
103
  code: ErrorMetadata.code(failure.code),
99
104
  phase: ErrorMetadata.phase(failure.phase) || ErrorMetadata.phase(phase),
100
105
  message: failure.message)
106
+ rescue Error
107
+ # 原始失败已由调用者保留;诊断通道故障不能替换登录、命令或业务错误。
108
+ nil
101
109
  end
102
110
 
103
111
  # 用户钩子可能把未登记的配置片段放进事件名、字段名或值;敏感范围统一隐藏。
@@ -163,8 +171,16 @@ module Net
163
171
  finish_output
164
172
  @transport.log_output = nil if @transport
165
173
  yield
174
+ rescue Exception # rubocop:disable Lint/RescueException -- Restore attachment without replacing this scope's failure or interruption.
175
+ failed = true
176
+ raise
166
177
  ensure
167
- @transport.log_output = self if @transport && @writer && @attached
178
+ begin
179
+ @transport.log_output = self if @transport && @writer && @attached
180
+ rescue StandardError
181
+ # 恢复接线仍须尝试,但不能盖掉设备失败或用户中断。
182
+ raise unless failed
183
+ end
168
184
  end
169
185
 
170
186
  # 断开回显记录并关闭日志资源。
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expect/redactor"
4
+
5
+ module Net
6
+ module Connector
7
+ # 管理连接器的秘密作用域和输出敏感性;字节过滤统一委托给 expect-pty。
8
+ class Redactor
9
+ MASK = "[REDACTED]".b.freeze
10
+
11
+ # 保存初始凭据,并建立第一版脱敏词表。
12
+ def initialize(*secrets)
13
+ unless Expect.constants(false).include?(:Redactor) && Expect::Redactor.respond_to?(:redact)
14
+ raise LoadError, "net-connector requires expect-pty with the public Expect::Redactor API"
15
+ end
16
+
17
+ @configured = secrets.compact.map(&:b).map(&:freeze).freeze
18
+ reset
19
+ end
20
+
21
+ # 重置动态词表,同时保留配置中的固定凭据。
22
+ def reset
23
+ @secrets = []
24
+ @patterns = nil
25
+ @scoped = false
26
+ @sensitive = false
27
+ @output_sensitive = false
28
+ @configured.each { |secret| remember(secret) }
29
+ end
30
+
31
+ # 临时命令响应在错误归一化期间保留,离开作用域后不进入长期会话。
32
+ def with_scope(reuse: false)
33
+ return yield if reuse && @scoped
34
+
35
+ previous = @secrets
36
+ previous_patterns = @patterns
37
+ previous_scoped = @scoped
38
+ previous_sensitive = @sensitive
39
+ previous_output_sensitive = @output_sensitive
40
+ @secrets = previous.dup
41
+ @scoped = true
42
+ begin
43
+ yield
44
+ ensure
45
+ @secrets = previous
46
+ @patterns = previous_patterns
47
+ @scoped = previous_scoped
48
+ @sensitive = previous_sensitive
49
+ @output_sensitive = previous_output_sensitive
50
+ end
51
+ end
52
+
53
+ # 敏感命令和交互的异常可能只引用秘密的一部分,不能只依赖完整词匹配。
54
+ def sensitive! = @sensitive = true
55
+
56
+ # 判断当前脱敏范围是否包含敏感交互。
57
+ def sensitive? = @sensitive
58
+
59
+ # 输出敏感时不把正文加入词表;只在当前范围内屏蔽日志和任意错误正文。
60
+ def output_sensitive!
61
+ @output_sensitive = true
62
+ sensitive!
63
+ end
64
+
65
+ # 后续查询继承输出边界,即使查询命令本身没有敏感标记。
66
+ def output_sensitive? = @output_sensitive
67
+
68
+ # 只维护当前作用域的注册词;匹配次序和重叠区间交给共享过滤器。
69
+ def remember(secret)
70
+ return if secret.nil? || secret.empty?
71
+
72
+ bytes = secret.b
73
+ return if @secrets.include?(bytes)
74
+
75
+ @secrets << bytes.freeze
76
+ @patterns = nil
77
+ end
78
+
79
+ # 一次性诊断不共享流状态;原标记作为不透明区间,避免再次展开它。
80
+ def call(text)
81
+ Expect::Redactor.redact(text.to_s.b, patterns, replacement: MASK)
82
+ end
83
+
84
+ # 每个日志目标独占过滤流,跨分片尾部和重叠掩码由依赖库维护。
85
+ def stream
86
+ Expect::Redactor.new(patterns, replacement: MASK)
87
+ end
88
+
89
+ # 临时范围恢复时,日志流可以同步恢复注册词表,不缓存配置正文。
90
+ def patterns
91
+ @patterns ||= (@secrets.empty? ? [] : (@secrets + [MASK]).uniq).freeze
92
+ end
93
+
94
+ # 返回脱敏器类型摘要。
95
+ def inspect = "#<#{self.class}>"
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expect/pty"
4
+ require_relative "dialogue"
5
+
6
+ module Net
7
+ module Connector
8
+ # 在传输层上执行有界对话;收到字节或分页标记不会重置截止时间。
9
+ class ResponseReader
10
+ # 保存会话引用,以便读取器使用传输、配置和脱敏上下文。
11
+ def initialize(session)
12
+ @session = session
13
+ end
14
+
15
+ # 读取到结束提示、失败模式或超时,并处理交互响应和输出上限。
16
+ def read(prompt:, interactions:, phase:, timeout: nil, deadline: nil, command: nil, failures: {})
17
+ deadline ||= Expect.monotonic + timeout
18
+ patterns = [*failures.keys, *interactions.map(&:pattern), prompt]
19
+ raw = []
20
+ output = []
21
+ counts = Hash.new(0)
22
+ bytes = 0
23
+ loop do
24
+ event = read_event(patterns, deadline)
25
+ raw << event.before unless event.before.empty?
26
+ output << event.before unless event.before.empty?
27
+ bytes += event.before.bytesize + event.match.bytesize
28
+ validate_event!(event, bytes, raw, phase, command)
29
+
30
+ if event.index == patterns.size
31
+ raw << event.match
32
+ output << event.match
33
+ elsif event.index == patterns.size - 1
34
+ return finish_response(event.match, raw, output, phase, command)
35
+ elsif event.index < failures.size
36
+ raise build_connection_error(failures.values.fetch(event.index), phase, command), cause: nil
37
+ else
38
+ reply = interactions.fetch(event.index - failures.size)
39
+ raw << event.match
40
+ output << event.match if reply.capture?
41
+ respond(reply, event.match, counts, deadline, phase, command)
42
+ end
43
+ next if Expect.monotonic < deadline
44
+
45
+ raise build_read_error(nil, phase, command, raw.join), cause: nil
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # 分页和流式输出只消耗剩余时间,不为下一次读取重新计时。
52
+ def read_event(patterns, deadline)
53
+ @session.transport.read(patterns, timeout: [deadline - Expect.monotonic, 0].max)
54
+ end
55
+
56
+ # 连接失败模式携带稳定错误码,供连接级恢复规则选择处理方式。
57
+ def build_connection_error(failure, phase, command)
58
+ klass, code = failure
59
+ @session.build_error(klass, "device connection failed (#{code})", phase: phase, code: code, command: command)
60
+ end
61
+
62
+ # 先限制原始字节数,再处理传输失败,避免错误输出绕过总量限制。
63
+ def validate_event!(event, bytes, raw, phase, command)
64
+ if bytes > @session.configuration.max_output_bytes
65
+ raise @session.build_error(OutputLimitExceeded, "device output exceeded max_output_bytes",
66
+ phase: phase, command: command, output: (raw + [event.match]).join), cause: nil
67
+ end
68
+ raise build_read_error(event, phase, command, raw.join), cause: nil unless event.matched?
69
+ end
70
+
71
+ # 完整提示符必须消费字节;交互标记可从业务输出排除,但原始响应始终保留。
72
+ def finish_response(prompt, raw, output, phase, command)
73
+ if prompt.empty?
74
+ raise @session.build_error(PromptError, "prompt pattern did not consume any output",
75
+ phase: phase, command: command, output: raw.join), cause: nil
76
+ end
77
+
78
+ raw << prompt
79
+ output << prompt
80
+ raw_text = raw.join
81
+ Response.new(raw: raw_text, output: (output == raw) ? raw_text : output.join, prompt: prompt)
82
+ end
83
+
84
+ # 重复提示和缺失应答必须在写入前失败;所有应答共用读取操作的截止时间。
85
+ def respond(reply, prompt, counts, deadline, phase, command)
86
+ counts[reply] += 1
87
+ if prompt.empty? || (reply.limit && counts[reply] > reply.limit)
88
+ raise build_interaction_error("response rejected or repeated", phase, command), cause: nil
89
+ end
90
+
91
+ value = interaction_response(reply, prompt, phase, command)
92
+ raise build_interaction_error("response unavailable", phase, command), cause: nil unless value.is_a?(String)
93
+
94
+ @session.redactor.remember(value.chomp) if reply.sensitive?
95
+ @session.write(value, deadline: deadline, phase: phase, command: command)
96
+ end
97
+
98
+ # 动态口令尚未返回时无法加入词表,须在敏感范围内先归一化回调异常。
99
+ # 成功后由调用方登记响应,临时敏感标记不会影响后续普通命令的诊断。
100
+ def interaction_response(reply, prompt, phase, command)
101
+ return reply.response(prompt) unless reply.sensitive?
102
+
103
+ @session.redactor.with_scope(reuse: true) do
104
+ @session.redactor.sensitive!
105
+ reply.response(prompt)
106
+ rescue => error
107
+ raise @session.normalize_error(error, phase: phase, command: command), cause: nil
108
+ end
109
+ end
110
+
111
+ # 将交互拒绝或响应缺失转换为认证或脚本错误。
112
+ def build_interaction_error(message, phase, command)
113
+ authenticating = %i[login enable].include?(phase)
114
+ klass = authenticating ? AuthenticationError : ScriptError
115
+ label = authenticating ? "authentication" : "command interaction"
116
+ @session.build_error(klass, "#{label} #{message}", phase: phase, command: command)
117
+ end
118
+
119
+ # 根据 EOF、提示符、阶段和底层异常选择具体读取错误类型。
120
+ def build_read_error(event, phase, command, output)
121
+ if event&.error.is_a?(Exception)
122
+ return @session.build_error(TransportError, "transport read failed", phase: phase, command: command,
123
+ underlying: event.error, output: output)
124
+ end
125
+ klass = if event&.error == :eof
126
+ ConnectionClosed
127
+ elsif command&.prompt
128
+ PromptError
129
+ elsif %i[login enable].include?(phase)
130
+ LoginTimeout
131
+ else
132
+ CommandTimeout
133
+ end
134
+ @session.build_error(klass, "device response #{event&.error || :timeout}", phase: phase,
135
+ command: command, output: output)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -3,6 +3,8 @@
3
3
  require "English"
4
4
  require_relative "authentication"
5
5
  require_relative "log"
6
+ require_relative "redactor"
7
+ require_relative "response_reader"
6
8
  require_relative "recovery"
7
9
  require_relative "transport"
8
10
 
@@ -45,12 +47,14 @@ module Net
45
47
  started = Expect.monotonic
46
48
  @log.log_event("operation_start", level: :debug, phase: :script)
47
49
  result = yield
48
- log_operation_completion(started, result.is_a?(Result) ? result.error : nil, steps: steps.size)
49
- result
50
50
  rescue => error
51
51
  failure = normalize_error(error, phase: :script)
52
52
  log_operation_completion(started, failure, steps: steps.size)
53
53
  raise failure, cause: nil
54
+ else
55
+ # 完成事件只发送一次;观察者失败由外层保留业务结果,不再次调用同一观察者。
56
+ log_operation_completion(started, result.is_a?(Result) ? result.error : nil, steps: steps.size)
57
+ result
54
58
  end
55
59
  end
56
60
 
@@ -2,6 +2,12 @@
2
2
 
3
3
  require "json"
4
4
  require "socket"
5
+ require_relative "cli/options"
6
+ require_relative "connection"
7
+ require_relative "fleet"
8
+ require_relative "progress"
9
+ require_relative "report/files"
10
+ require_relative "../storage/batch_directory"
5
11
 
6
12
  module Net
7
13
  module Connector
@@ -38,8 +44,9 @@ module Net
38
44
 
39
45
  Storage::PrivateFile.write(File.join(batch_directory, "plan.json"), JSON.pretty_generate(preview))
40
46
  report = execute(fleet, settings, plan, batch_directory, progress, success_policy, server_root)
47
+ report = record_progress_error(report, progress)
41
48
  report = Report::Files.write(report, directory: batch_directory, plan: plan, concurrency: settings.concurrency)
42
- finish(report, progress, options, batch_directory)
49
+ finish(report, progress, options, batch_directory, plan, settings.concurrency)
43
50
  rescue ArgumentError, Client::Error => exception
44
51
  @error.puts "备份参数或清单错误:#{exception.message}"
45
52
  2
@@ -95,10 +102,16 @@ module Net
95
102
  end
96
103
  end
97
104
 
98
- def finish(report, progress, options, batch_directory)
105
+ def finish(report, progress, options, batch_directory, plan, concurrency)
99
106
  progress.finish(report)
100
107
  progress.location(report.report_location) if report.report_location
101
- @error.puts "文本报告:#{File.join(batch_directory, "summary.txt")}" if @env.fetch("NC_PROGRESS", "1") != "0" && File.file?(File.join(batch_directory, "summary.txt"))
108
+ summary_path = File.join(batch_directory, "summary.txt")
109
+ progress.location(summary_path, label: "文本报告") if File.file?(summary_path)
110
+ updated = record_progress_error(report, progress)
111
+ unless updated.equal?(report)
112
+ # 末尾显示也可能首次失败,只补写诊断,不重复设备任务或事件。
113
+ report = Report::Files.write(updated, directory: batch_directory, plan: plan, concurrency: concurrency)
114
+ end
102
115
  if options[:json]
103
116
  @output.puts JSON.generate(directory: batch_directory, counts: report.counts,
104
117
  tasks_succeeded: report.policy_success?, success: report.policy_success?,
@@ -108,6 +121,12 @@ module Net
108
121
  report.policy_success? ? 0 : 1
109
122
  end
110
123
 
124
+ def record_progress_error(report, progress)
125
+ return report unless progress.output_error && !report.callback_errors.include?(progress.output_error)
126
+
127
+ report.with(callback_errors: [*report.callback_errors, progress.output_error].freeze)
128
+ end
129
+
111
130
  def tftp_root(settings, options)
112
131
  server = settings.tftp_server || (raise ArgumentError, "TFTP_HOST or YAML tftp.server is required")
113
132
  return options[:tftp_root] || @env["TFTP_ROOT"] if options[:tftp_root] || @env["TFTP_ROOT"]
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require_relative "../settings"
5
+ require_relative "../planner"
6
+ require_relative "../config_file"
4
7
 
5
8
  module Net
6
9
  module Connector
@@ -3,6 +3,9 @@
3
3
  require "json"
4
4
  require "ipaddr"
5
5
  require "optparse"
6
+ require_relative "../version"
7
+ require_relative "fleet"
8
+ require_relative "cli/options"
6
9
 
7
10
  module Net
8
11
  module Connector
@@ -46,23 +49,23 @@ module Net
46
49
  return 0
47
50
  end
48
51
 
49
- batch = backup(fleet, plan, settings, options)
50
- summary = batch.summary
52
+ report = backup(fleet, plan, settings, options)
53
+ summary = report.summary
51
54
  @output.puts JSON.pretty_generate(summary)
52
- batch.policy_success? ? 0 : 1
55
+ report.policy_success? ? 0 : 1
53
56
  rescue Storage::PrivateFile::WriteError => exception
54
57
  message = Storage::PrivateFile.receipt_error?(exception) ? exception.message : exception.class.name
55
- @error.puts "net-connector-backup: #{message}"
58
+ @error.puts "net-backup: #{message}"
56
59
  2
57
60
  rescue OptionParser::ParseError
58
- @error.puts "net-connector-backup: 参数无效;使用 --help 查看参数"
61
+ @error.puts "net-backup: 参数无效;使用 --help 查看参数"
59
62
  2
60
63
  rescue ArgumentError, KeyError, Errno::ENOENT,
61
64
  Psych::Exception, JSON::ParserError, Client::Error => exception
62
- @error.puts "net-connector-backup: #{exception.message}"
65
+ @error.puts "net-backup: #{exception.message}"
63
66
  2
64
67
  rescue StandardError => exception
65
- @error.puts "net-connector-backup 失败(#{exception.class})"
68
+ @error.puts "net-backup 失败(#{exception.class})"
66
69
  2
67
70
  end
68
71
 
@@ -113,7 +116,7 @@ module Net
113
116
  def parse_options
114
117
  options = { environment: {} }
115
118
  parser = OptionParser.new do |args|
116
- args.banner = "用法:net-connector-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
119
+ args.banner = "用法:net-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
117
120
  Options.add_settings(args, options)
118
121
  Options.add_execution(args, options)
119
122
  args.on("--source SOURCE", %w[http postgres], "清单来源:http(默认)或 postgres") { |value| options[:source] = value }
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require_relative "client"
5
4
 
6
5
  # 清单连接与设备凭据来源;业务入口负责规划和执行备份。
7
6
  module Net
@@ -48,8 +48,10 @@ module Net
48
48
  connected = false
49
49
  begin
50
50
  Timeout.timeout(budget.remaining, Client::InventoryTimeout) do
51
- # 从握手开始持有连接;PG.connect 尚未返回时被超时中断,调用方无法负责关闭它。
52
- connection = PG::Connection.connect_start(@connection_options)
51
+ # 先取得连接句柄再交付超时,确保异常到达时 ensure 能显式关闭连接。
52
+ Thread.handle_interrupt(Client::InventoryTimeout => :never) do
53
+ connection = PG::Connection.connect_start(@connection_options)
54
+ end
53
55
  # PostgreSQL NOTICE 可能包含 SQL 或数据,不交给 libpq 默认的 stderr 输出器。
54
56
  connection.set_notice_processor { |_notice| nil }
55
57
  wait_for_connection(connection, budget)
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ipaddr"
4
+ require_relative "../../connector"
4
5
  require_relative "../storage/saved_config"
6
+ require_relative "client"
5
7
 
6
8
  module Net
7
9
  module Connector
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../engine/error_metadata"
4
+ require_relative "../engine/errors"
5
+ require_relative "../device/local_backup"
6
+ require_relative "../device/tftp/receipt"
7
+ require_relative "../storage/private_file"
4
8
 
5
9
  module Net
6
10
  module Connector
@@ -1,7 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "digest"
4
+ require_relative "../storage"
5
+ require_relative "client"
6
+ require_relative "settings"
7
+ require_relative "device"
8
+ require_relative "worker"
9
+ require_relative "result_store"
10
+ require_relative "planner"
11
+ require_relative "report"
12
+ require_relative "tftp_verification"
13
+ require_relative "tftp_archive"
5
14
 
6
15
  module Net
7
16
  module Connector
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
4
+ require_relative "device"
5
+ require_relative "batch"
6
+
3
7
  module Net
4
8
  module Connector
5
9
  module Netdisco
@@ -27,6 +31,7 @@ module Net
27
31
  unless inventory.all?(Device) && outcomes.size == inventory.size
28
32
  raise ArgumentError, "plan inventory and outcomes must have the same device slots"
29
33
  end
34
+ validate_duplicate_hosts!
30
35
 
31
36
  selected_indices = {}
32
37
  selected_filenames = {}
@@ -65,6 +70,14 @@ module Net
65
70
 
66
71
  private
67
72
 
73
+ # 外部快照也遵守清单入口的地址唯一性;采样不能把重复设备重新变为可执行。
74
+ 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 } }
77
+
78
+ raise ArgumentError, "plan duplicate hosts must be marked as duplicate_host"
79
+ end
80
+
68
81
  # 跳过结果不携带执行产物;文件冲突必须有本批次实际执行的目标作为依据。
69
82
  def valid_skipped_outcome?(result, device, selected_filenames)
70
83
  return false unless result.is_a?(Outcome) && result.device.equal?(device) && result.backup.nil? &&
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "plan"
4
+ require_relative "batch"
4
5
 
5
6
  module Net
6
7
  module Connector
@@ -20,7 +21,7 @@ module Net
20
21
  end
21
22
  selected_tasks = if limit_per_vendor
22
23
  ready_tasks.group_by { |_index, device| device.vendor }.values.flat_map do |tasks|
23
- tasks.sort_by { |_index, device| device.host }.first(limit_per_vendor)
24
+ tasks.min_by(limit_per_vendor) { |_index, device| device.host }
24
25
  end
25
26
  else
26
27
  ready_tasks
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "io/console"
4
+ require_relative "../engine/error_metadata"
5
+ require_relative "../engine/log/event"
4
6
 
5
7
  module Net
6
8
  module Connector
7
9
  module Netdisco
8
10
  # 共享一个实例接收并发会话事件和任务回调;人类进度不混入 JSON 输出。
9
11
  class Progress
12
+ attr_reader :output_error
13
+
10
14
  def initialize(io: $stderr, enabled: true, verbose: false)
11
15
  @io, @enabled, @verbose = io, enabled, verbose
12
16
  @tty = io.respond_to?(:tty?) && io.tty?
@@ -101,7 +105,9 @@ module Net
101
105
  end
102
106
 
103
107
  def finish(report)
104
- summary = report.summary
108
+ return unless @enabled
109
+
110
+ summary = report.statistics
105
111
  failures = report.outcomes.reject { |item| item.success? || %i[filtered sample_limit].include?(item.status) }
106
112
  .group_by { |item| item.error_code || item.status }.transform_values(&:size)
107
113
  unless failures.empty?
@@ -146,8 +152,8 @@ module Net
146
152
  @mutex.synchronize { refresh(force: true) }
147
153
  end
148
154
 
149
- def location(path)
150
- write(nil, "报告:#{path}")
155
+ def location(path, label: "报告")
156
+ write(nil, "#{label}:#{path}")
151
157
  end
152
158
 
153
159
  private
@@ -207,9 +213,7 @@ module Net
207
213
 
208
214
  clear_refresh
209
215
  @last_status = lines
210
- @io.write(lines.join("\n"))
211
- @io.flush
212
- @refreshing = true
216
+ @refreshing = write_output(lines.join("\n"))
213
217
  end
214
218
 
215
219
  # 留一列避免终端自动换行,窄窗口也不会留下旧进度行。
@@ -229,7 +233,7 @@ module Net
229
233
  def clear_refresh
230
234
  return unless @refreshing
231
235
 
232
- @io.write("\r\e[2K" + ("\e[1A\r\e[2K" * (@last_status.length - 1)))
236
+ write_output("\r\e[2K" + ("\e[1A\r\e[2K" * (@last_status.length - 1)))
233
237
  @refreshing = false
234
238
  end
235
239
 
@@ -244,8 +248,21 @@ module Net
244
248
  clear_refresh
245
249
  line = "[#{Time.now.strftime("%H:%M:%S")}] [#{@completed}/#{@total}#{percent} 执行中 #{@active}] [#{host || "批次"}] #{message}"
246
250
  line = "[#{Time.now.strftime("%H:%M:%S")}] #{host ? "#{host} " : ""}#{message}" unless @verbose
247
- @io.write(line.gsub(/[[:cntrl:]]/, " ") + "\n")
251
+ write_output(line.gsub(/[[:cntrl:]]/, " ") + "\n")
252
+ end
253
+
254
+ # 显示失败只停用进度输出;批次仍保存回执和事件,并记录一次受控回调诊断。
255
+ # 所有调用均持有 @mutex,心跳与设备线程不会重复访问已失败的输出。
256
+ def write_output(text)
257
+ return false unless @enabled
258
+
259
+ @io.write(text)
248
260
  @io.flush
261
+ true
262
+ rescue IOError, SystemCallError => error
263
+ @output_error = { host: nil, error_type: ErrorMetadata.type(error.class.name) }.freeze
264
+ @enabled = false
265
+ false
249
266
  end
250
267
  end
251
268
  end
@@ -16,16 +16,16 @@ module Net
16
16
  Storage::PrivateFile.write(destination, JSON.pretty_generate(finalized.summary))
17
17
  finalized
18
18
  rescue StandardError => error
19
- location = if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed? && error.receipt.path == destination
20
- destination
21
- end
22
- failed = report.with_report_error(error, location: location)
19
+ location = committed_location(error, destination) || report.report_location
20
+ # 更新已有诊断时仍保留首次报告故障,以及之前已提交的报告位置。
21
+ failed = report.report_error ? report.with(report_location: location) : report.with_report_error(error, location: location)
23
22
  begin
24
23
  saved = failed.with(report_location: destination)
25
24
  Storage::PrivateFile.write(destination, JSON.pretty_generate(saved.summary))
26
25
  failed = saved
27
- rescue StandardError
28
- # JSON 仍无法保存时,返回带有已知提交位置的失败报告。
26
+ rescue StandardError => persistence_error
27
+ # 补写也可能在替换后失败;保留位置,但不覆盖首次报告故障。
28
+ failed = saved if committed_location(persistence_error, destination)
29
29
  end
30
30
  begin
31
31
  Text.write(directory: directory, report: failed, plan: plan, concurrency: concurrency)
@@ -34,6 +34,11 @@ module Net
34
34
  end
35
35
  failed
36
36
  end
37
+
38
+ def self.committed_location(error, destination)
39
+ destination if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed? && error.receipt.path == File.expand_path(destination)
40
+ end
41
+ private_class_method :committed_location
37
42
  end
38
43
  end
39
44
  end
@@ -10,7 +10,7 @@ module Net
10
10
  # 将批次结果写为人类可读的文本摘要。
11
11
  class Text
12
12
  def self.write(directory:, report:, plan:, concurrency:)
13
- summary = report.summary
13
+ summary = report.statistics
14
14
  failed = report.outcomes.reject { |item| item.success? || %i[filtered sample_limit].include?(item.status) }
15
15
  groups = failed.group_by { |item| item.error_code || item.status }
16
16
  lines = ["网络设备备份报告", "=" * 72,