net-connector 0.17.2 → 0.18.1

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/docs/architecture.md +14 -0
  4. data/docs/usage.md +4 -0
  5. data/docs/usage.zh-CN.md +4 -0
  6. data/lib/net/connector/collect/progress.rb +21 -21
  7. data/lib/net/connector/device/base.rb +12 -8
  8. data/lib/net/connector/device/save_config.rb +2 -2
  9. data/lib/net/connector/engine/error/metadata.rb +2 -1
  10. data/lib/net/connector/engine/execution.rb +37 -10
  11. data/lib/net/connector/engine/known_hosts.rb +19 -6
  12. data/lib/net/connector/engine/log/messages.rb +16 -16
  13. data/lib/net/connector/engine/session.rb +5 -4
  14. data/lib/net/connector/engine/transports.rb +60 -41
  15. data/lib/net/connector/inventory/filters.rb +2 -2
  16. data/lib/net/connector/netdisco/cli/backup.rb +6 -6
  17. data/lib/net/connector/netdisco/cli/credentials.rb +2 -2
  18. data/lib/net/connector/netdisco/cli/help.rb +16 -16
  19. data/lib/net/connector/netdisco/cli/options.rb +44 -44
  20. data/lib/net/connector/netdisco/cli/setup.rb +18 -18
  21. data/lib/net/connector/netdisco/cli/topology.rb +21 -21
  22. data/lib/net/connector/netdisco/cli.rb +24 -24
  23. data/lib/net/connector/netdisco/config/defaults.rb +1 -1
  24. data/lib/net/connector/netdisco/config/initializer.rb +13 -13
  25. data/lib/net/connector/netdisco/progress.rb +17 -17
  26. data/lib/net/connector/netdisco/report/text.rb +15 -15
  27. data/lib/net/connector/netdisco/ssh/archive/client.rb +109 -79
  28. data/lib/net/connector/netdisco/ssh/archive/protocol.rb +107 -0
  29. data/lib/net/connector/netdisco/ssh/archive/worker.py +35 -19
  30. data/lib/net/connector/netdisco/ssh/authentication.rb +8 -10
  31. data/lib/net/connector/netdisco/topology/files.rb +8 -8
  32. data/lib/net/connector/netdisco/topology/presentation.rb +15 -15
  33. data/lib/net/connector/progress.rb +36 -36
  34. data/lib/net/connector/resources.rb +1 -1
  35. data/lib/net/connector/storage/atomic_file.rb +60 -3
  36. data/lib/net/connector/version.rb +1 -1
  37. metadata +11 -10
@@ -59,14 +59,14 @@ module Net
59
59
  @error.puts "net-backup: #{message}"
60
60
  2
61
61
  rescue OptionParser::ParseError
62
- @error.puts "net-backup: 参数无效;使用 --help 查看参数"
62
+ @error.puts "net-backup: invalid arguments; use --help for options"
63
63
  2
64
64
  rescue ArgumentError, KeyError, Errno::ENOENT,
65
65
  Psych::Exception, JSON::ParserError, Inventory::Error => exception
66
66
  @error.puts "net-backup: #{exception.message}"
67
67
  2
68
68
  rescue StandardError => exception
69
- @error.puts "net-backup 失败(#{exception.class})"
69
+ @error.puts "net-backup failed (#{exception.class})"
70
70
  2
71
71
  end
72
72
 
@@ -111,7 +111,7 @@ module Net
111
111
  fleet.plan_backup(limit_per_vendor: limit, **refresh)
112
112
  end
113
113
  options[:host]&.split(",")&.each do |host|
114
- raise ArgumentError, "清单中未找到设备 #{host}" unless plan.inventory.any? { |device| device.host == host }
114
+ raise ArgumentError, "Device not found in inventory: #{host}" unless plan.inventory.any? { |device| device.host == host }
115
115
  end
116
116
  plan
117
117
  end
@@ -120,9 +120,9 @@ module Net
120
120
  def inventory_warning(fleet)
121
121
  status = fleet.inventory_status if fleet.respond_to?(:inventory_status)
122
122
  if status && status[:source] == :stale_cache
123
- @error.puts "net-backup: 清单刷新失败,使用历史缓存(#{status[:refresh_error_code]})"
123
+ @error.puts "net-backup: inventory refresh failed; using the previous cache (#{status[:refresh_error_code]})"
124
124
  end
125
- @error.puts "net-backup: 清单缓存写入失败(#{status[:write_error_type]})" if status && status[:write_error_type]
125
+ @error.puts "net-backup: inventory cache write failed (#{status[:write_error_type]})" if status && status[:write_error_type]
126
126
  end
127
127
 
128
128
  # 仅执行已经校验的计划,保留本地与 TFTP 两种结果语义。
@@ -143,25 +143,25 @@ module Net
143
143
  def parse_options
144
144
  options = { environment: {} }
145
145
  parser = OptionParser.new do |args|
146
- args.banner = "用法:net-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
146
+ args.banner = "Usage: net-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
147
147
  Options.add_settings(args, options)
148
148
  Options.add_execution(args, options)
149
- args.on("--init", "创建用户目录中的缺失配置和环境模板后退出") { options[:init] = true }
150
- args.on("--interactive", "通过终端问答创建缺失配置") { options[:interactive] = true }
151
- args.on("--non-interactive", "禁用配置问答") { options[:interactive] = false }
152
- args.on("--source SOURCE", %w[http postgres], "清单来源:http(默认)或 postgres") { |value| options[:source] = value }
153
- args.on("--query SQL", "PostgreSQL 清单查询,使用 $1、$2 绑定参数") { |value| options[:query] = value }
154
- args.on("--query-params JSON", "查询参数的 JSON 数组") { |value| options[:query_params] = value }
155
- args.on("--plan", "预览设备清单,不连接设备") { options[:plan] = true }
156
- args.on("--tftp", "由设备发起 TFTP 备份") { options[:tftp] = true }
157
- args.on("--export IP", "导出已保存的本地配置") { |value| options[:export] = value }
158
- args.on("--output FILE", "将导出配置写入私有文件") { |value| options[:output] = value }
159
- args.on("--max-script-output-bytes N", Integer, "每个脚本的累计响应字节上限") { |value| options[:max_script_output_bytes] = value }
160
- args.on("-v", "--version", "显示版本") do
149
+ args.on("--init", "Create missing configuration and environment templates in the user directory, then exit") { options[:init] = true }
150
+ args.on("--interactive", "Create missing configuration through terminal prompts") { options[:interactive] = true }
151
+ args.on("--non-interactive", "Disable configuration prompts") { options[:interactive] = false }
152
+ args.on("--source SOURCE", %w[http postgres], "Inventory source: http (default) or postgres") { |value| options[:source] = value }
153
+ args.on("--query SQL", "PostgreSQL inventory query using $1 and $2 bound parameters") { |value| options[:query] = value }
154
+ args.on("--query-params JSON", "JSON array of query parameters") { |value| options[:query_params] = value }
155
+ args.on("--plan", "Preview device inventory without connecting to devices") { options[:plan] = true }
156
+ args.on("--tftp", "Have devices initiate TFTP backups") { options[:tftp] = true }
157
+ args.on("--export IP", "Export a saved local configuration") { |value| options[:export] = value }
158
+ args.on("--output FILE", "Write exported configuration to a private file") { |value| options[:output] = value }
159
+ args.on("--max-script-output-bytes N", Integer, "Cumulative response byte limit per script") { |value| options[:max_script_output_bytes] = value }
160
+ args.on("-v", "--version", "Show version") do
161
161
  @output.puts Net::Connector::VERSION
162
162
  options[:done] = true
163
163
  end
164
- args.on("-h", "--help", "显示帮助") do
164
+ args.on("-h", "--help", "Show help") do
165
165
  @output.puts args
166
166
  options[:done] = true
167
167
  end
@@ -179,15 +179,15 @@ module Net
179
179
  Planner.validate_limit!(options[:limit])
180
180
  raise ArgumentError, "--tftp-root requires --tftp" if options[:tftp_root] && !options[:tftp]
181
181
  modes = [:show_config, :plan, :export].count { |key| options[key] }
182
- raise ArgumentError, "--show-config、--plan 和 --export 只能选择一项" if modes > 1
183
- raise ArgumentError, "--output 需要同时指定 --export" if options[:output] && !options[:export]
184
- raise ArgumentError, "--all 不能与 --limit-per-vendor 同时使用" if options[:all] && options[:limit]
182
+ raise ArgumentError, "Select only one of --show-config, --plan and --export" if modes > 1
183
+ raise ArgumentError, "--output requires --export" if options[:output] && !options[:export]
184
+ raise ArgumentError, "--all cannot be combined with --limit-per-vendor" if options[:all] && options[:limit]
185
185
  if options[:export] && (options[:source] || options[:query] || options[:query_params])
186
- raise ArgumentError, "--export 不能与清单查询选项同时使用"
186
+ raise ArgumentError, "--export cannot be combined with inventory query options"
187
187
  end
188
188
  if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:environment].key?("NC_PARALLEL") || options[:host] ||
189
189
  options[:success_policy] || options[:tftp_root] || options[:max_script_output_bytes] || options[:force_refresh])
190
- raise ArgumentError, "--export 不能与备份选项同时使用"
190
+ raise ArgumentError, "--export cannot be combined with backup options"
191
191
  end
192
192
  end
193
193
 
@@ -48,7 +48,7 @@ module Net
48
48
  else value
49
49
  end
50
50
  rescue ArgumentError, TypeError, JSON::ParserError
51
- raise ArgumentError, "初始化参数 #{key} 格式无效", cause: nil
51
+ raise ArgumentError, "Initialization parameter #{key} has an invalid format", cause: nil
52
52
  end
53
53
 
54
54
  # 同一字段声明同时约束加载与生成,不持久化密码或未知环境变量。
@@ -29,7 +29,7 @@ module Net
29
29
  values = YAML.safe_load(YAML.dump(document), aliases: false)
30
30
  validate!(values)
31
31
  interactive = CtxCli::Terminal.tty?(@input, @output) if interactive.nil?
32
- configure(values, mode: mode, inherited_parallel: inherited_parallel) if interactive && answer("进入交互配置?[y/N]", "n").casecmp?("y")
32
+ configure(values, mode: mode, inherited_parallel: inherited_parallel) if interactive && answer("Start interactive configuration? [y/N]", "n").casecmp?("y")
33
33
  validate!(values)
34
34
  name = mode == :topology ? "topology" : "backup"
35
35
  write(path, Net::Connector::Config.dump(values, name: name, path: path))
@@ -54,7 +54,7 @@ module Net
54
54
 
55
55
  # 显示有效默认值;空输入可由字段选择沿用或保留继承,输入结束则中止生成。
56
56
  def answer(label, default, blank: default)
57
- @output.print "#{label}#{default.nil? ? "" : " [#{default}]"}:"
57
+ @output.print "#{label}#{default.nil? ? "" : " [#{default}]"}: "
58
58
  @output.flush
59
59
  line = @input.gets("\n")
60
60
  raise EOFError, "configuration input ended before completion" unless line
@@ -67,7 +67,7 @@ module Net
67
67
  def field(document, section, key, label, default, list: false)
68
68
  values = (document[section] ||= {})
69
69
  previous = values.fetch(key, default)
70
- label = "#{label}(留空继承)" if key == "parallel" && !values.key?(key)
70
+ label = "#{label}(leave blank to inherit)" if key == "parallel" && !values.key?(key)
71
71
  loop do
72
72
  displayed = previous.is_a?(Array) ? previous.join(",") : previous&.to_s
73
73
  text = answer(label, displayed, blank: key == "parallel" ? nil : displayed)
@@ -86,30 +86,30 @@ module Net
86
86
  values[key] = value
87
87
  return
88
88
  rescue ArgumentError
89
- @output.puts "值无效,请重新输入。"
89
+ @output.puts "Invalid value; enter it again."
90
90
  end
91
91
  end
92
92
  end
93
93
 
94
94
  # 按清单来源和业务模式逐项填写非敏感配置。
95
95
  def configure(document, mode: nil, inherited_parallel:)
96
- field(document, "netdisco", "source", "清单来源 http/postgres", "http")
96
+ field(document, "netdisco", "source", "Inventory source http/postgres", "http")
97
97
  if document.dig("netdisco", "source") == "http"
98
- field(document, "netdisco", "url", "清单 URL", mode.nil? ? "https://netdisco.example.invalid" : nil)
98
+ field(document, "netdisco", "url", "Inventory URL", mode.nil? ? "https://netdisco.example.invalid" : nil)
99
99
  end
100
100
  %w[include_hosts exclude_hosts include_names exclude_names include_models exclude_models].each do |key|
101
101
  field(document, "inventory", key, key, [], list: true)
102
102
  end
103
- field(document, "inventory", "name_match", "名称匹配 contains/prefix/glob", "contains")
104
- field(document, "inventory", "model_match", "型号匹配 contains/prefix/glob", "contains")
103
+ field(document, "inventory", "name_match", "Name matching contains/prefix/glob", "contains")
104
+ field(document, "inventory", "model_match", "Model matching contains/prefix/glob", "contains")
105
105
  %w[include_vendors exclude_vendors].each { |key| field(document, "inventory", key, key, [], list: true) }
106
106
  if document.key?("topology")
107
- field(document, "topology", "parallel", "拓扑并发数 1..500", inherited_parallel)
108
- field(document, "topology", "directory", "拓扑报告目录", "./topologys")
107
+ field(document, "topology", "parallel", "Topology concurrency 1..500", inherited_parallel)
108
+ field(document, "topology", "directory", "Topology report directory", "./topologys")
109
109
  else
110
- field(document, "backup", "parallel", "并发数 1..500", inherited_parallel)
111
- field(document, "backup", "directory", "备份目录", "./backups")
112
- field(document, "tftp", "server", "TFTP 服务器", nil) if mode.nil? || mode == :tftp
110
+ field(document, "backup", "parallel", "Concurrency 1..500", inherited_parallel)
111
+ field(document, "backup", "directory", "Backup directory", "./backups")
112
+ field(document, "tftp", "server", "TFTP server", nil) if mode.nil? || mode == :tftp
113
113
  end
114
114
  end
115
115
 
@@ -12,26 +12,26 @@ module Net
12
12
  # 备份入口保留清单对象接口,显示由公共批次组件处理。
13
13
  def plan(plan, parallel:, limit_per_vendor: nil)
14
14
  @operation_mode = false
15
- mode = limit_per_vendor ? "抽样(每厂商最多 #{limit_per_vendor} 台)" : "全量"
15
+ mode = limit_per_vendor ? "sample (at most #{limit_per_vendor} devices per vendor)" : "all devices"
16
16
  super(total: plan.inventory.size, selected: plan.ready.size, parallel: parallel, label: mode)
17
17
  end
18
18
 
19
19
  # 设备对象只在领域入口转换为显示身份。
20
- def start(device, label: "备份")
21
- super(host: device.host, label: "#{label}(#{device.vendor})")
20
+ def start(device, label: "Backup")
21
+ super(host: device.host, label: "#{label}(#{device.vendor})")
22
22
  end
23
23
 
24
24
  # 分别展示已保存、设备报告上传和服务器核验状态。
25
25
  def result(outcome)
26
26
  error_code = diagnostic_code(outcome)
27
27
  message = case outcome.status
28
- when :backed_up then "备份已保存(#{outcome.backup.bytes} 字节)"
28
+ when :backed_up then "Backup saved (#{outcome.backup.bytes} bytes)"
29
29
  when :reported_uploaded
30
- outcome.backup&.verification == :server_verified ? "配置已归档(#{outcome.backup.server_bytes} 字节)" :
31
- "设备报告上传完成;服务器文件尚未核验"
32
- when :saved_with_error then "备份有产物但未完全成功(#{error_code || outcome.status})"
33
- when :reported_with_error then "设备报告上传但收尾异常(#{error_code || outcome.status})"
34
- else "未完成(#{error_code || outcome.status})"
30
+ outcome.backup&.verification == :server_verified ? "Configuration archived (#{outcome.backup.server_bytes} bytes)" :
31
+ "Device reported a completed upload; server file has not been verified"
32
+ when :saved_with_error then "Backup artifact exists but the operation did not fully succeed (#{error_code || outcome.status})"
33
+ when :reported_with_error then "Device reported an upload but finalization failed (#{error_code || outcome.status})"
34
+ else "Incomplete (#{error_code || outcome.status})"
35
35
  end
36
36
  super(host: outcome.device.host, success: outcome.success?, duration_ms: outcome.duration_ms, message: message, error_code: error_code)
37
37
  end
@@ -45,10 +45,10 @@ module Net
45
45
  .group_by { |item| diagnostic_code(item) || item.status }.transform_values(&:size)
46
46
  super(success: report.policy_success?, **summary.slice(:succeeded, :partial, :failed, :skipped), failures: failures)
47
47
  unverified = summary[:verification]&.fetch(:unverified, 0) || 0
48
- write(nil, "其中 #{unverified} 台仅设备报告上传完成,服务器文件尚未核验") if unverified.positive?
48
+ write(nil, "Of these, #{unverified} devices only reported a completed upload; server files have not been verified") if unverified.positive?
49
49
  return unless !report.callback_errors.empty? || report.report_error
50
50
 
51
- write(nil, "回调异常 #{report.callback_errors.size},报告错误 #{summary[:report_error] || "无"}")
51
+ write(nil, "Callback errors #{report.callback_errors.size}, report error #{summary[:report_error] || "none"}")
52
52
  end
53
53
 
54
54
  # 清空上一阶段计数,初始化本次拓扑操作的任务规模与计时。
@@ -61,7 +61,7 @@ module Net
61
61
  @stages.clear
62
62
  @task_started.clear
63
63
  @run_started = monotonic
64
- emit(nil, "#{label} | 本次 #{@total} 台;并发 #{parallel}")
64
+ emit(nil, "#{label} | Selected #{@total} devices; concurrency #{parallel}")
65
65
  end
66
66
  end
67
67
 
@@ -89,8 +89,8 @@ module Net
89
89
  @failures[error_code || status] += 1 unless succeeded || skipped
90
90
  if @verbose || failed
91
91
  label = Topology::Presentation.status(status, changes: changes)
92
- reasons = issues.empty? ? "" : ";#{Topology::Presentation.issues(issues)}"
93
- emit(device.host, "#{label};耗时 #{duration_ms} ms#{error_code ? "(#{error_code})" : ""}#{reasons}")
92
+ reasons = issues.empty? ? "" : "; #{Topology::Presentation.issues(issues)}"
93
+ emit(device.host, "#{label}; elapsed #{duration_ms} ms#{error_code ? "(#{error_code})" : ""}#{reasons}")
94
94
  elsif !@tty && ((@completed % 25).zero? || @completed == @total)
95
95
  emit(nil, status_line)
96
96
  end
@@ -101,15 +101,15 @@ module Net
101
101
  # 输出拓扑批次的最终状态计数和完成判断。
102
102
  def finish_operations(success:, counts:)
103
103
  failed = counts.any? { |status, count| Topology::Presentation::FAILURES.include?(status) && count.positive? }
104
- result = success ? "完成" : (failed ? "未完成" : "存在待处理项")
105
- write(nil, "拓扑批次结束:#{counts.map { |status, count| "#{Topology::Presentation.status(status)} #{count} 台" }.join(",")};结果 #{result}")
104
+ result = success ? "complete" : (failed ? "incomplete" : "pending items remain")
105
+ write(nil, "Topology batch finished: #{counts.map { |status, count| "#{Topology::Presentation.status(status)} #{count} devices" }.join(", ")}; result #{result}")
106
106
  end
107
107
 
108
108
  private
109
109
 
110
110
  # 拓扑进度保留待核实项计数,备份使用通用成功与失败计数。
111
111
  def progress_counts
112
- @operation_mode ? "完成 #{@completed} 待核实 #{@operation_pending} 失败 #{@failed}" : super
112
+ @operation_mode ? "complete #{@completed} unverified #{@operation_pending} failed #{@failed}" : super
113
113
  end
114
114
 
115
115
  # 与持久化摘要采用同一诊断白名单,保留领域结果自身的原始错误字段。
@@ -14,24 +14,24 @@ module Net
14
14
  summary = report.statistics
15
15
  failed = report.outcomes.reject { |item| item.success? || %i[filtered sample_limit].include?(item.status) }
16
16
  groups = failed.group_by { |item| diagnostic_code(item) || item.status }
17
- lines = ["网络设备备份报告", "=" * 72,
18
- "开始时间:#{report.started_at.getlocal("+08:00").strftime("%Y-%m-%d %H:%M:%S %:z")}",
19
- "结束时间:#{report.finished_at.getlocal("+08:00").strftime("%Y-%m-%d %H:%M:%S %:z")}",
20
- "清单:#{plan.inventory.size} 台;本次:#{plan.ready.size} 台;并发:#{parallel}",
21
- "成功:#{summary[:succeeded]};部分成功:#{summary[:partial]};失败:#{summary[:failed]};跳过:#{summary[:skipped]}",
22
- "耗时:#{(report.duration_ms.to_f / 1000).round(1)} 秒;批次状态:#{summary[:status]}",
23
- "成功策略:#{report.policy};策略通过:#{report.policy_success?}",
24
- "回调异常:#{report.callback_errors.size};报告错误:#{summary[:report_error] || "无"}",
25
- "状态计数:#{report.counts.map { |key, value| "#{key}=#{value}" }.join(",")}",
26
- "未完成分类:#{groups.empty? ? "无" : groups.map { |key, items| "#{key}=#{items.size}" }.join(",")}",
27
- "-" * 72, "未完成设备:"]
17
+ lines = ["Network device backup report", "=" * 72,
18
+ "Started: #{report.started_at.getlocal("+08:00").strftime("%Y-%m-%d %H:%M:%S %:z")}",
19
+ "Finished: #{report.finished_at.getlocal("+08:00").strftime("%Y-%m-%d %H:%M:%S %:z")}",
20
+ "Inventory: #{plan.inventory.size} devices; selected: #{plan.ready.size} devices; concurrency: #{parallel}",
21
+ "Succeeded: #{summary[:succeeded]}; partial: #{summary[:partial]}; failed: #{summary[:failed]}; skipped: #{summary[:skipped]}",
22
+ "Elapsed: #{(report.duration_ms.to_f / 1000).round(1)} seconds; batch status: #{summary[:status]}",
23
+ "Success policy: #{report.policy}; policy passed: #{report.policy_success?}",
24
+ "Callback errors: #{report.callback_errors.size}; report error: #{summary[:report_error] || "none"}",
25
+ "Status counts: #{report.counts.map { |key, value| "#{key}=#{value}" }.join(", ")}",
26
+ "Incomplete categories: #{groups.empty? ? "none" : groups.map { |key, items| "#{key}=#{items.size}" }.join(", ")}",
27
+ "-" * 72, "Incomplete devices: "]
28
28
  lines.concat(failure_lines(failed))
29
- lines << "无" if failed.empty?
29
+ lines << "none" if failed.empty?
30
30
  if report.mode == :tftp
31
31
  verification = summary.fetch(:verification)
32
- lines << "TFTP 服务器文件:已核验 #{verification[:verified]},未核验 #{verification[:unverified]}。"
32
+ lines << "TFTP server files: verified #{verification[:verified]}, unverified #{verification[:unverified]}."
33
33
  end
34
- lines += ["=" * 72, "备份目录:#{directory}", "完整明细:#{File.join(directory, "summary.json")}"]
34
+ lines += ["=" * 72, "Backup directory: #{directory}", "Full details: #{File.join(directory, "summary.json")}"]
35
35
  Net::Connector::Storage::AtomicFile.write(File.join(directory, "summary.txt"), lines.join("\n") + "\n")
36
36
  end
37
37
 
@@ -39,7 +39,7 @@ module Net
39
39
  def self.failure_lines(failed)
40
40
  failed.map do |item|
41
41
  label = item.device.name.to_s.gsub(/[[:cntrl:]]/, " ")
42
- "#{item.device.host} | #{label} | #{item.status} | #{diagnostic_code(item) || "无错误码"} | #{item.duration_ms} ms"
42
+ "#{item.device.host} | #{label} | #{item.status} | #{diagnostic_code(item) || "no error code"} | #{item.duration_ms} ms"
43
43
  end
44
44
  end
45
45
  private_class_method :failure_lines
@@ -3,7 +3,6 @@
3
3
  require_relative "../../../storage/directory"
4
4
 
5
5
  require "digest"
6
- require "json"
7
6
  require "open3"
8
7
  require "securerandom"
9
8
  require "shellwords"
@@ -11,6 +10,7 @@ require "tmpdir"
11
10
  require_relative "../../tftp/root"
12
11
  require_relative "../authentication"
13
12
  require_relative "../../tftp/archive"
13
+ require_relative "protocol"
14
14
 
15
15
  module Net
16
16
  module Connector
@@ -25,11 +25,11 @@ module Net
25
25
  # 构造不含凭据值的认证不可用提示,在设备任务开始前拒绝执行。
26
26
  def initialize(password_configured: false)
27
27
  detail = if password_configured
28
- "已读取 NC_TFTP_SSH_PASSWORD,但 SSH 认证或主连接建立失败;请检查账号、密码、主机密钥及网络。密码含 # 或 $ 时请在 .env 中用单引号包裹"
28
+ "NC_TFTP_SSH_PASSWORD was read, but SSH authentication or master connection setup failed; check username, password, host key and network. Wrap passwords containing # or $ in single quotes in .env"
29
29
  else
30
- "请在项目根目录 .env 设置 NC_TFTP_SSH_PASSWORD,或配置可用的 SSH 密钥/agent"
30
+ "Set NC_TFTP_SSH_PASSWORD in the project root .env, or configure a working SSH key/agent"
31
31
  end
32
- super("SSH 归档认证不可用:#{detail}。已停止,未连接设备。")
32
+ super("SSH archive authentication unavailable: #{detail}. Stopped without connecting to devices.")
33
33
  end
34
34
  end
35
35
 
@@ -37,16 +37,15 @@ module Net
37
37
  class PreparationUnavailable < ArgumentError
38
38
  # 提示检查远端运行时及私有目录,不携带 SSH 输出或设备配置。
39
39
  def initialize
40
- super("SSH 归档目录预检失败:请检查服务器 python3、TFTP 根目录及写权限;archive、批次目录和 .net-connector-locks 须由 SSH 账号拥有且权限为 0700。已停止,未连接设备。")
40
+ super("SSH archive directory preflight failed: check server python3, the TFTP root and write permissions; archive, batch directories and .net-connector-locks must be owned by the SSH user with mode 0700. Stopped without connecting to devices.")
41
41
  end
42
42
  end
43
43
 
44
- MAX_FRAME_BYTES = 90 * 1024 * 1024
45
44
  SESSION_LIMIT = 4
46
45
  WORKER = File.expand_path("worker.py", __dir__)
47
46
  ACTIVE_CLIENTS = {}
48
47
  ACTIVE_CLIENTS_MUTEX = Mutex.new
49
- private_constant :MAX_FRAME_BYTES, :WORKER, :ACTIVE_CLIENTS, :ACTIVE_CLIENTS_MUTEX
48
+ private_constant :WORKER, :ACTIVE_CLIENTS, :ACTIVE_CLIENTS_MUTEX
50
49
 
51
50
  # 退出处理只持有仍需清理的连接;已关闭的批次与凭据可由 GC 回收。
52
51
  at_exit do
@@ -94,30 +93,26 @@ module Net
94
93
  acquired = @slots.pop
95
94
  filename = TftpTarget.validate_path!(upload_filename(device))
96
95
  archive_name = "#{device.backup_filename(style: :hostname_ip).delete_suffix(".txt")}#{File.extname(filename)}"
97
- @launcher.call do |input, output, error, process|
98
- begin
99
- write_frame(input, root: @root, filename: filename, archive_name: archive_name, batch: @batch)
100
- expect_phase(output, "ready")
101
- outcome = yield filename
102
- receipt = outcome.backup
103
- unless receipt.is_a?(TftpReceipt) && receipt.path == filename
104
- write_frame(input, action: "abort")
105
- next outcome
106
- end
107
- outcome = outcome.with(started_at: started_at)
108
- write_frame(input, action: "verify")
109
- proof = expect_phase(output, "archived")
110
- outcome = retain_upload(outcome, proof, archive_name)
111
- if outcome.success?
112
- write_frame(input, action: "cleanup", sha256: outcome.backup.server_sha256)
113
- expect_phase(output, "complete")
114
- else
115
- write_frame(input, action: "abort")
116
- end
117
- outcome
118
- ensure
119
- stop_process(input, output, error, process)
96
+ with_process do |protocol|
97
+ protocol.send_frame(root: @root, filename: filename, archive_name: archive_name, batch: @batch)
98
+ protocol.expect_phase("ready")
99
+ outcome = yield filename
100
+ receipt = outcome.backup
101
+ unless receipt.is_a?(TftpReceipt) && receipt.path == filename
102
+ protocol.send_frame(action: "abort")
103
+ next outcome
104
+ end
105
+ outcome = outcome.with(started_at: started_at)
106
+ protocol.send_frame(action: "verify")
107
+ proof = protocol.expect_phase("archived")
108
+ outcome = retain_upload(outcome, protocol, proof, archive_name)
109
+ if outcome.success?
110
+ protocol.send_frame(action: "cleanup", sha256: outcome.backup.server_sha256)
111
+ protocol.expect_phase("complete")
112
+ else
113
+ protocol.send_frame(action: "abort")
120
114
  end
115
+ outcome
121
116
  end
122
117
  rescue StandardError
123
118
  raise Tftp::Archive::Failure.new(host: device.host), cause: nil unless outcome
@@ -158,33 +153,26 @@ module Net
158
153
 
159
154
  # 用实际事务进程预检目录与写入边界,不读取或上传任何设备文件。
160
155
  def prepare_archive
161
- @launcher.call do |input, output, error, process|
162
- begin
163
- write_frame(input, root: @root, filename: "preflight.cfg", archive_name: "preflight.cfg", batch: @batch, action: "preflight")
164
- expect_phase(output, "ready")
165
- ensure
166
- stop_process(input, output, error, process)
167
- end
156
+ with_process do |protocol|
157
+ protocol.send_frame(root: @root, filename: "preflight.cfg", archive_name: "preflight.cfg", batch: @batch, action: "preflight")
158
+ protocol.expect_phase("ready")
168
159
  end
169
160
  rescue StandardError
170
161
  raise PreparationUnavailable, cause: nil
171
162
  end
172
163
 
173
164
  # 先核对服务器正文与摘要,再保留远端归档路径;后续本地写入失败仍保留已有证据。
174
- def retain_upload(outcome, proof, archive_name)
175
- bytes = proof.fetch("data").unpack1("m0")
176
- digest = Digest::SHA256.hexdigest(bytes)
177
- unless bytes.bytesize.positive? && bytes.bytesize == proof.fetch("bytes") && digest == proof.fetch("sha256")
178
- raise Tftp::Archive::Failure
179
- end
180
- receipt = outcome.backup.with(verification: :server_verified, server_sha256: digest, server_bytes: bytes.bytesize,
165
+ def retain_upload(outcome, protocol, proof, archive_name)
166
+ receipt = outcome.backup.with(verification: :server_verified, server_sha256: proof.fetch("sha256"), server_bytes: proof.fetch("bytes"),
181
167
  archive_path: remote_path(proof.fetch("archive_path")))
182
- outcome = outcome.with(backup: receipt)
183
168
  path = File.join(@report_directory, "tftp", archive_name)
184
- Storage::Directory.prepare(File.dirname(path))
185
- Storage::AtomicFile.write(path, bytes)
169
+ persist_upload(path) do |file|
170
+ failure = receive_upload(protocol, receipt, file)
171
+ outcome = outcome.with(backup: receipt)
172
+ raise failure if failure
173
+ end
186
174
  outcome = outcome.with(backup: receipt.with(local_path: path))
187
- raise Tftp::Archive::Failure unless Storage::FileReader.fingerprint(path).sha256 == digest
175
+ raise Tftp::Archive::Failure unless Storage::FileReader.fingerprint(path).sha256 == receipt.server_sha256
188
176
 
189
177
  outcome
190
178
  rescue StandardError => error
@@ -194,6 +182,38 @@ module Net
194
182
  outcome.success? ? archive_failure(outcome) : outcome
195
183
  end
196
184
 
185
+ # 本地准备失败仍接收并核验服务器正文;已经开始接收的失败事务不能重读或重写。
186
+ def persist_upload(path)
187
+ received = false
188
+ Storage::Directory.prepare(File.dirname(path))
189
+ Storage::AtomicFile.write_stream(path) do |file|
190
+ received = true
191
+ yield file
192
+ end
193
+ rescue StandardError
194
+ yield nil unless received
195
+ raise
196
+ end
197
+
198
+ # 逐块核对摘要,首次写盘错误后只继续读取;完整交付确认后才返回写盘故障。
199
+ def receive_upload(protocol, receipt, file)
200
+ digest = Digest::SHA256.new
201
+ failure = nil
202
+ protocol.read_payload(receipt.server_bytes) do |chunk|
203
+ digest << chunk
204
+ next unless file && !failure
205
+
206
+ begin
207
+ raise IOError, "incomplete archive write" unless file.write(chunk) == chunk.bytesize
208
+ rescue StandardError => error
209
+ failure = error
210
+ end
211
+ end
212
+ raise Tftp::Archive::Failure unless digest.hexdigest == receipt.server_sha256
213
+
214
+ failure
215
+ end
216
+
197
217
  # 保留设备和服务器回执,将归档收尾异常转换为可诊断的部分成功。
198
218
  def archive_failure(outcome)
199
219
  failure = Tftp::Archive::Failure.new(host: outcome.device.host)
@@ -251,11 +271,10 @@ module Net
251
271
  false
252
272
  end
253
273
 
254
- # 复用主连接启动一次归档事务进程,会话槽位限制由调用作用域管理。
255
- def open_ssh(&)
256
- prepare_master
274
+ # 返回事务管道和等待线程,由调用作用域唯一负责关闭、终止及回收。
275
+ def open_ssh
257
276
  command = "python3 -u -c #{Shellwords.escape(File.read(WORKER))}"
258
- Open3.popen3(*ssh_arguments, @uri.host, command, &)
277
+ Open3.popen3(*ssh_arguments, @uri.host, command)
259
278
  end
260
279
 
261
280
  # 将已校验端点组装为独立 SSH 参数,固定严格主机密钥检查与连接超时。
@@ -265,32 +284,24 @@ module Net
265
284
  "-p", (@uri.port || 22).to_s, "-l", @uri.user]
266
285
  end
267
286
 
268
- # 将路径或事务动作编码为单行 JSON 并刷新,协议帧不携带认证凭据。
269
- def write_frame(input, **values)
270
- input.write(JSON.generate(values) + "\n")
271
- input.flush
272
- end
273
-
274
- # 在总期限和帧大小上限内读取完整响应,拒绝断连、错误阶段及无效协议数据。
275
- def expect_phase(output, phase)
276
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 280
277
- frame = +"".b
278
- loop do
279
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
280
- raise Tftp::Archive::Failure unless remaining.positive? && output.wait_readable(remaining)
281
-
282
- chunk = output.read_nonblock(65_536, exception: false)
283
- next if chunk == :wait_readable
284
- raise Tftp::Archive::Failure if chunk.nil?
285
-
286
- frame << chunk
287
- raise Tftp::Archive::Failure if frame.bytesize > MAX_FRAME_BYTES
288
- next unless frame.end_with?("\n")
289
-
290
- value = JSON.parse(frame)
291
- raise Tftp::Archive::Failure unless value["ok"] == true && value["phase"] == phase
292
-
293
- return value
287
+ # 每次事务独占进程与协议状态,主故障或非局部退出不能被普通清理错误覆盖。
288
+ def with_process
289
+ handles = nil
290
+ prepare_master unless @custom_launcher
291
+ completed = false
292
+ # 取得资源与登记所有权之间暂缓取消;事务本身仍可立即中断。
293
+ Thread.handle_interrupt(Exception => :never) { handles = @launcher.call }
294
+ result = yield Protocol.new(handles[0], handles[1])
295
+ completed = true
296
+ result
297
+ rescue Exception # rubocop:disable Lint/RescueException -- 返回前递送的取消仍属于事务主控制流。
298
+ completed = false
299
+ raise
300
+ ensure
301
+ begin
302
+ stop_process(*handles) if handles
303
+ rescue Exception # rubocop:disable Lint/RescueException -- 取消和非局部退出均保留原始控制流。
304
+ raise if completed
294
305
  end
295
306
  end
296
307
 
@@ -303,7 +314,26 @@ module Net
303
314
 
304
315
  # 先关闭事务管道并等待子进程退出,超时后分级终止,促使服务器释放锁。
305
316
  def stop_process(input, output, error, process)
306
- [input, output, error].each { |io| io.close unless io.closed? }
317
+ Thread.handle_interrupt(Exception => :never) do
318
+ failure = nil
319
+ [input, output, error].each do |io|
320
+ begin
321
+ io.close unless io.closed?
322
+ rescue Exception => caught # rubocop:disable Lint/RescueException -- 单个关闭失败仍须回收其余管道和进程。
323
+ failure ||= caught
324
+ end
325
+ end
326
+ begin
327
+ reap_process(process)
328
+ rescue Exception => caught # rubocop:disable Lint/RescueException -- 保留先发生的清理故障。
329
+ failure ||= caught
330
+ end
331
+ raise failure if failure
332
+ end
333
+ end
334
+
335
+ # 为正常退出保留短暂宽限,随后终止仍未退出的事务进程并回收等待线程。
336
+ def reap_process(process)
307
337
  return if process.join(2)
308
338
 
309
339
  Process.kill("TERM", process.pid)