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
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../../tftp/archive"
5
+
6
+ module Net
7
+ module Connector
8
+ module Netdisco
9
+ module SSH
10
+ module Archive
11
+ # 仅借用事务管道,维护控制帧与二进制正文的分界、缓冲和绝对期限。
12
+ class Protocol
13
+ READ_BYTES = 65_536
14
+ CONTROL_BYTES = 16_384
15
+ PAYLOAD_BYTES = 64 * 1024 * 1024
16
+ private_constant :READ_BYTES, :CONTROL_BYTES, :PAYLOAD_BYTES
17
+
18
+ # 每个事务独占自己的协议状态;管道关闭和进程回收仍由客户端负责。
19
+ def initialize(input, output, timeout: 280)
20
+ @input, @output = input, output
21
+ @timeout = timeout
22
+ @buffer = +"".b
23
+ end
24
+
25
+ # 发送有界单行控制帧,写入不完整不能当作命令已经交付。
26
+ def send_frame(**values)
27
+ frame = JSON.generate(values) + "\n"
28
+ raise Tftp::Archive::Failure if frame.bytesize > CONTROL_BYTES || @input.write(frame) != frame.bytesize
29
+
30
+ @input.flush
31
+ end
32
+
33
+ # 开始一个控制阶段;归档元数据取得的期限同时覆盖后续正文及交付确认。
34
+ def expect_phase(phase)
35
+ @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
36
+ read_phase(phase)
37
+ end
38
+
39
+ # 按声明长度分片交付二进制正文,随后在同一期限内确认服务器完成交付。
40
+ def read_payload(length)
41
+ unless @phase == "archived" && length.is_a?(Integer) && (1..PAYLOAD_BYTES).cover?(length)
42
+ raise Tftp::Archive::Failure
43
+ end
44
+
45
+ remaining = length
46
+ while remaining.positive?
47
+ remaining_time
48
+ @buffer << read_chunk if @buffer.empty?
49
+ chunk = @buffer.slice!(0, [remaining, READ_BYTES, @buffer.bytesize].min)
50
+ remaining -= chunk.bytesize
51
+ yield chunk
52
+ end
53
+ read_phase("delivered")
54
+ end
55
+
56
+ private
57
+
58
+ # 缓冲中可能同时含有控制帧和正文,只消费当前行并限制控制帧长度。
59
+ def read_phase(phase)
60
+ loop do
61
+ remaining_time
62
+ ending = @buffer.index("\n")
63
+ if ending
64
+ raise Tftp::Archive::Failure if ending + 1 > CONTROL_BYTES
65
+
66
+ value = JSON.parse(@buffer.slice!(0, ending + 1))
67
+ unless value.is_a?(Hash) && value["ok"] == true && value["phase"] == phase
68
+ raise Tftp::Archive::Failure
69
+ end
70
+
71
+ @phase = phase
72
+ return value
73
+ end
74
+ raise Tftp::Archive::Failure if @buffer.bytesize >= CONTROL_BYTES
75
+
76
+ @buffer << read_chunk
77
+ end
78
+ rescue JSON::ParserError
79
+ raise Tftp::Archive::Failure, cause: nil
80
+ end
81
+
82
+ # 每次非阻塞读取使用剩余总预算,片段到达不能重新开始计时。
83
+ def read_chunk
84
+ loop do
85
+ raise Tftp::Archive::Failure unless @output.wait_readable(remaining_time)
86
+
87
+ chunk = @output.read_nonblock(READ_BYTES, exception: false)
88
+ next if chunk == :wait_readable
89
+ raise Tftp::Archive::Failure if chunk.nil?
90
+
91
+ return chunk
92
+ end
93
+ end
94
+
95
+ # 即使数据已经缓冲,也检查绝对期限,避免消费者或确认阶段偷偷延长预算。
96
+ def remaining_time
97
+ remaining = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ raise Tftp::Archive::Failure unless remaining.positive?
99
+
100
+ remaining
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -1,7 +1,6 @@
1
1
  # 服务器端单次归档事务:锁覆盖上传与核验,客户端断开后退出且不重放设备命令。
2
2
  """在一个目标锁内处理单次上传事务,客户端断开后退出且不重放设备操作。"""
3
3
 
4
- import base64
5
4
  from contextlib import ExitStack
6
5
  import fcntl
7
6
  import hashlib
@@ -14,6 +13,7 @@ import sys
14
13
  import time
15
14
 
16
15
  MAX_BYTES = 64 * 1024 * 1024
16
+ CHUNK_BYTES = 64 * 1024
17
17
  DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
18
18
 
19
19
 
@@ -107,7 +107,7 @@ def fingerprint(s):
107
107
 
108
108
 
109
109
  # 从固定目录安全读取有界普通文件,读前、读后和路径身份必须一致。
110
- def read_file(parent, name, missing=False):
110
+ def read_file(parent, name, missing=False, consume=None):
111
111
  try:
112
112
  fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
113
113
  except FileNotFoundError:
@@ -118,13 +118,24 @@ def read_file(parent, name, missing=False):
118
118
  before = os.fstat(f.fileno())
119
119
  if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_BYTES:
120
120
  raise ValueError("unsafe or oversized file")
121
- data = f.read(MAX_BYTES + 1)
121
+ digest = hashlib.sha256()
122
+ size = 0
123
+ while True:
124
+ chunk = f.read(CHUNK_BYTES)
125
+ if not chunk:
126
+ break
127
+ size += len(chunk)
128
+ if size > MAX_BYTES:
129
+ raise ValueError("oversized file")
130
+ digest.update(chunk)
131
+ if consume is not None and consume(chunk) != len(chunk):
132
+ raise IOError("incomplete file write")
122
133
  after = os.fstat(f.fileno())
123
- if len(data) > MAX_BYTES or fingerprint(before) != fingerprint(after):
134
+ if size != after.st_size or fingerprint(before) != fingerprint(after):
124
135
  raise ValueError("file changed while reading")
125
136
  if fingerprint(after) != fingerprint(os.stat(name, dir_fd=parent, follow_symlinks=False)):
126
137
  raise ValueError("file replaced while reading")
127
- return data, fingerprint(after)
138
+ return digest.hexdigest(), fingerprint(after)
128
139
 
129
140
 
130
141
  # 在固定目录中以随机名称独占创建私有临时文件,碰撞时重新选择名称。
@@ -139,11 +150,13 @@ def temporary(parent, prefix):
139
150
 
140
151
 
141
152
  # 先写全并同步临时文件,再独占链接到归档名并同步目录,已有归档不会被覆盖。
142
- def persist(parent, name, data):
153
+ def persist(parent, name, source_parent, source_name, expected, digest):
143
154
  fd, pending = temporary(parent, ".net-connector-")
144
155
  try:
145
156
  with os.fdopen(fd, "wb") as f:
146
- f.write(data)
157
+ copied, current = read_file(source_parent, source_name, consume=f.write)
158
+ if current != expected or copied != digest:
159
+ raise ValueError("source changed before archive commit")
147
160
  f.flush()
148
161
  os.fsync(f.fileno())
149
162
  # 临时名与最终名均相对固定目录解析,父路径改名也不能将写入重定向。
@@ -218,9 +231,9 @@ def run():
218
231
  # 上传前先保留同名历史文件;只有服务器确认就绪后,客户端才允许设备导出。
219
232
  verify_boundary()
220
233
  old, previous = read_file(root_fd, name, missing=True)
221
- if old is not None:
234
+ if previous is not None:
222
235
  previous_fd = directories.private(batch_fd, "previous")
223
- persist(previous_fd, archive_name, old)
236
+ persist(previous_fd, archive_name, root_fd, name, previous, old)
224
237
  fd, marker = temporary(root_fd, ".net-connector-upload-")
225
238
  try:
226
239
  started = os.fstat(fd).st_mtime_ns
@@ -233,26 +246,29 @@ def run():
233
246
  return
234
247
  verify_boundary()
235
248
  # 新文件必须非空、区别于旧基线且不早于本次上传边界,旧文件不能冒充新结果。
236
- data, current = read_file(root_fd, name)
237
- if not data or current == previous or current[3] < started:
249
+ digest, current = read_file(root_fd, name)
250
+ if not current[2] or current == previous or current[3] < started:
238
251
  raise ValueError("no new nonempty upload")
239
- digest = hashlib.sha256(data).hexdigest()
240
- persist(batch_fd, archive_name, data)
241
- archived, _ = read_file(batch_fd, archive_name)
242
- if hashlib.sha256(archived).hexdigest() != digest:
252
+ persist(batch_fd, archive_name, root_fd, name, current, digest)
253
+ archived, saved = read_file(batch_fd, archive_name)
254
+ if archived != digest or saved[2] != current[2]:
243
255
  raise ValueError("archive verification failed")
244
256
  verify_boundary()
245
257
  destination = os.path.join(root, "archive", batch, archive_name)
246
- send(phase="archived", bytes=len(data), sha256=digest, archive_path=destination,
247
- data=base64.b64encode(data).decode("ascii"))
258
+ send(phase="archived", bytes=current[2], sha256=digest, archive_path=destination)
259
+ delivered, check = read_file(batch_fd, archive_name, consume=sys.stdout.buffer.write)
260
+ if delivered != digest or check != saved:
261
+ raise ValueError("archive changed during delivery")
262
+ verify_boundary()
263
+ send(phase="delivered")
248
264
  # 远端归档及正文已交付客户端;只有收到客户端核验后的明确清理指令才删除 staging。
249
265
  action = receive()
250
266
  if action.get("action") == "cleanup":
251
267
  if action.get("sha256") != digest:
252
268
  raise ValueError("cleanup digest mismatch")
253
269
  verify_boundary()
254
- data, check = read_file(root_fd, name)
255
- if check != current or hashlib.sha256(data).hexdigest() != digest:
270
+ uploaded, check = read_file(root_fd, name)
271
+ if check != current or uploaded != digest:
256
272
  raise ValueError("upload changed before cleanup")
257
273
  verify_boundary()
258
274
  os.unlink(name, dir_fd=root_fd)
@@ -14,17 +14,15 @@ module Net
14
14
 
15
15
  # 在同一认证期限内等待一次密码提示及连接结束;主连接可用性由调用方另行核对。
16
16
  def self.respond(arguments, password:)
17
- session = Expect::Session.new(buffer_limit: 65_536, write_timeout: 5)
18
- session.spawn(*arguments, raw: true)
19
- deadline = Expect.monotonic + 15
20
- challenge = session.expect(PASSWORD, REJECTED, timeout: [deadline - Expect.monotonic, 0].max)
21
- return false unless challenge.number == 1
17
+ Expect.spawn(*arguments, raw: true, buffer_limit: 65_536, write_timeout: 5) do |session|
18
+ deadline = Expect.monotonic + 15
19
+ challenge = session.expect(PASSWORD, REJECTED, timeout: [deadline - Expect.monotonic, 0].max)
20
+ next false unless challenge.number == 1
22
21
 
23
- session.write(password + "\n")
24
- completion = session.expect(PASSWORD, REJECTED, timeout: [deadline - Expect.monotonic, 0].max)
25
- completion.error == :eof
26
- ensure
27
- session&.close
22
+ session.write(password + "\n")
23
+ completion = session.expect(PASSWORD, REJECTED, timeout: [deadline - Expect.monotonic, 0].max)
24
+ completion.error == :eof
25
+ end
28
26
  end
29
27
  end
30
28
  end
@@ -35,16 +35,16 @@ module Net
35
35
  path = outcome[:device_report]
36
36
  return unless path
37
37
 
38
- lines = ["设备:#{text(outcome[:name])}(#{text(outcome[:host])})", "厂商:#{text(outcome[:vendor])}",
39
- "状态:#{Presentation.status(outcome[:status], changes: outcome.fetch(:changes, 0))};候选变更:#{outcome.fetch(:changes, 0)}",
40
- outcome[:script_file] ? "命令文件:#{outcome[:script_file]}" : "命令文件:未生成(没有已核实的配置变更)"]
41
- lines << "命令校验 SHA-256:#{outcome[:script_sha256]}" if outcome[:script_sha256]
42
- lines << "诊断:#{text(outcome.dig(:diagnostic, :error_code))}" if outcome[:diagnostic]
43
- lines << "配置命令来自本次计划;文件生成不表示已经下发,执行结果以状态和设备回执为准。"
38
+ lines = ["Device: #{text(outcome[:name])} (#{text(outcome[:host])})", "Vendor: #{text(outcome[:vendor])}",
39
+ "Status: #{Presentation.status(outcome[:status], changes: outcome.fetch(:changes, 0))}; candidate changes: #{outcome.fetch(:changes, 0)}",
40
+ outcome[:script_file] ? "Command file: #{outcome[:script_file]}" : "Command file: not generated (no verified configuration changes)"]
41
+ lines << "Command SHA-256: #{outcome[:script_sha256]}" if outcome[:script_sha256]
42
+ lines << "Diagnostics: #{text(outcome.dig(:diagnostic, :error_code))}" if outcome[:diagnostic]
43
+ lines << "Configuration commands come from this plan; generating a file does not mean commands were sent. Check status and device receipts for execution results."
44
44
  lines.concat(outcome.fetch(:neighbors, []).map do |row|
45
- "#{text(row[:interface])} #{text(row[:status])} 现有=#{row[:current].inspect} 目标=#{row[:expected].inspect}"
45
+ "#{text(row[:interface])} #{text(row[:status])} current=#{row[:current].inspect} target=#{row[:expected].inspect}"
46
46
  end)
47
- lines.concat(outcome.fetch(:issues, []).map { |issue| "未核实:#{text(issue[:interface])} #{text(Presentation.reason(issue[:code]))}" })
47
+ lines.concat(outcome.fetch(:issues, []).map { |issue| "Unverified: #{text(issue[:interface])} #{text(Presentation.reason(issue[:code]))}" })
48
48
  FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
49
49
  Storage::AtomicFile.write(path, lines.join("\n") + "\n")
50
50
  end
@@ -6,18 +6,18 @@ module Net
6
6
  module Topology
7
7
  # JSON 状态码保持稳定;终端、汇总和设备说明共用中文含义及分类统计。
8
8
  module Presentation
9
- STATUSES = { planned: "已生成计划", partial_planned: "已生成部分计划", unverified: "含待核实接口",
10
- discovered: "发现完成", applied: "应用完成", partial_applied: "部分应用完成", manual_required: "需人工处理",
11
- unsupported_operation: "不支持,已跳过", failed: "执行失败", partial: "执行部分失败",
12
- missing_credentials: "缺少凭据", interrupted: "已中断", not_started: "未开始", offline_review: "离线重审" }.freeze
13
- REASONS = { unknown_neighbor_name: "邻居未提供名称", unknown_neighbor_interface: "邻居未提供端口",
14
- ambiguous_neighbor: "同一接口存在多个邻居", interface_missing: "配置中未找到本地接口",
15
- unsafe_description: "目标描述不符合安全约束" }.freeze
9
+ STATUSES = { planned: "plan generated", partial_planned: "partial plan generated", unverified: "contains unverified interfaces",
10
+ discovered: "discovery complete", applied: "apply complete", partial_applied: "partial apply complete", manual_required: "manual action required",
11
+ unsupported_operation: "unsupported; skipped", failed: "execution failed", partial: "execution partially failed",
12
+ missing_credentials: "missing credentials", interrupted: "interrupted", not_started: "not started", offline_review: "offline review" }.freeze
13
+ REASONS = { unknown_neighbor_name: "neighbor name missing", unknown_neighbor_interface: "neighbor port missing",
14
+ ambiguous_neighbor: "multiple neighbors on one interface", interface_missing: "local interface not found in configuration",
15
+ unsafe_description: "target description violates safety constraints" }.freeze
16
16
  FAILURES = %i[failed partial missing_credentials interrupted not_started].freeze
17
17
 
18
18
  # 已规划但没有变更时明确显示无需修改,避免读者将 planned 理解为已经执行。
19
19
  def self.status(value, changes: nil)
20
- return "无需修改" if value.to_sym == :planned && changes == 0
20
+ return "no changes needed" if value.to_sym == :planned && changes == 0
21
21
 
22
22
  STATUSES.fetch(value.to_sym, value.to_s)
23
23
  end
@@ -27,7 +27,7 @@ module Net
27
27
 
28
28
  # 汇总逐接口的问题数量,不把身份不足与设备连接或命令失败混为一谈。
29
29
  def self.issues(rows)
30
- rows.map { |row| row[:code] }.tally.map { |code, count| "#{reason(code)} #{count} 个接口" }.join(";")
30
+ rows.map { |row| row[:code] }.tally.map { |code, count| "#{reason(code)} #{count} interfaces" }.join("; ")
31
31
  end
32
32
 
33
33
  # 设备与接口分别统计;含待核实项的设备也可能拥有可执行的局部计划。
@@ -43,14 +43,14 @@ module Net
43
43
 
44
44
  # 只读计划中有差异或未知证据表示待处理,不能把生成计划描述为配置下发完成。
45
45
  def self.result(summary)
46
- return "未完成" if summary[:orchestration_error_type] || summary[:interrupted] || summary[:progress_error]
47
- return "完成" if summary[:success]
48
- return "执行存在失败" if summary.fetch(:statistics).fetch(:failed).positive?
46
+ return "incomplete" if summary[:orchestration_error_type] || summary[:interrupted] || summary[:progress_error]
47
+ return "complete" if summary[:success]
48
+ return "execution contains failures" if summary.fetch(:statistics).fetch(:failed).positive?
49
49
 
50
- return "没有可处理设备" if summary[:devices].empty?
50
+ return "no eligible devices" if summary[:devices].empty?
51
51
 
52
- { plan: "已生成计划,存在待修正或待核实项", discover: "发现结束,仍有待核实接口",
53
- apply: "应用结束,仍有待处理项" }.fetch(summary[:mode], "存在待处理项")
52
+ { plan: "plan generated; corrections or verification still required", discover: "discovery finished; unverified interfaces remain",
53
+ apply: "apply finished; pending items remain" }.fetch(summary[:mode], "pending items remain")
54
54
  end
55
55
  end
56
56
  end
@@ -27,26 +27,26 @@ module Net
27
27
 
28
28
  # 输出当前开始读取清单的进度提示。
29
29
  def reading_inventory(config: nil)
30
- write(nil, "正在读取 Netdisco 设备清单#{config ? "(配置:#{config})" : ""}")
30
+ write(nil, "Reading Netdisco device inventory#{config ? "(configuration: #{config})" : ""}")
31
31
  end
32
32
 
33
33
  # 以实际选中设备初始化进度,跳过的条目不计入执行任务总数。
34
- def plan(total:, selected:, parallel:, label: "全量")
34
+ def plan(total:, selected:, parallel:, label: "all devices")
35
35
  @mutex.synchronize do
36
36
  @total = selected
37
37
  @completed = @active = 0
38
38
  @run_started = monotonic
39
- emit(nil, "#{label} | 清单 #{total} 台;本次 #{@total} 台;跳过 #{total - @total} 台;并发 #{parallel}")
39
+ emit(nil, "#{label} | Inventory #{total} devices; selected #{@total}; skipped #{total - @total} devices; concurrency #{parallel}")
40
40
  end
41
41
  end
42
42
 
43
43
  # 任务开始只表示进入登录阶段,不代表设备已连接。
44
- def start(host:, label: "采集")
44
+ def start(host:, label: "collecting")
45
45
  @mutex.synchronize do
46
46
  @active += 1
47
- @stages[host] = "登录"
47
+ @stages[host] = "logging in"
48
48
  @task_started[host] = monotonic
49
- @verbose ? emit(host, "开始#{label}") : refresh
49
+ @verbose ? emit(host, "starting#{label}") : refresh
50
50
  end
51
51
  end
52
52
 
@@ -59,9 +59,9 @@ module Net
59
59
  @task_started.delete(host)
60
60
  success ? @succeeded += 1 : @failed += 1
61
61
  hint = failure_hint(error_code)
62
- message = "#{message};#{hint}" if hint
62
+ message = "#{message}; #{hint}" if hint
63
63
  if @verbose || !success
64
- emit(host, "#{message};耗时 #{duration_ms} ms")
64
+ emit(host, "#{message}; elapsed #{duration_ms} ms")
65
65
  elsif !@tty && ((@completed % 25).zero? || @completed == @total)
66
66
  emit(nil, status_line)
67
67
  end
@@ -72,9 +72,9 @@ module Net
72
72
  # 完成百分比只表示任务结束,最终结果始终由已保存的业务报告决定。
73
73
  def finish(success:, succeeded:, partial:, failed:, skipped:, failures: {})
74
74
  unless failures.empty?
75
- write(nil, "未完成分类:#{failures.sort_by { |code, count| [-count, code.to_s] }.map { |code, count| "#{code}=#{count}" }.join(",")}")
75
+ write(nil, "Incomplete categories: #{failures.sort_by { |code, count| [-count, code.to_s] }.map { |code, count| "#{code}=#{count}" }.join(", ")}")
76
76
  end
77
- write(nil, "批次结束:成功 #{succeeded},部分成功 #{partial},失败 #{failed},跳过 #{skipped};耗时 #{(monotonic - @started_at).round(1)} 秒;结果 #{success ? "完成" : "未完成"}")
77
+ write(nil, "Batch finished: succeeded #{succeeded}, partial #{partial}, failed #{failed}, skipped #{skipped}; elapsed #{(monotonic - @started_at).round(1)} seconds; result #{success ? "complete" : "incomplete"}")
78
78
  end
79
79
 
80
80
  # 普通批次提示也复用锁、脱敏和输出故障处理。
@@ -89,7 +89,7 @@ module Net
89
89
  unless @verbose
90
90
  @mutex.synchronize do
91
91
  host = event.fields[:host]
92
- @stages[host] = "采集" if @stages.key?(host) && event.name == "login_complete" && event.fields[:status] == "ok"
92
+ @stages[host] = "collecting" if @stages.key?(host) && event.name == "login_complete" && event.fields[:status] == "ok"
93
93
  refresh
94
94
  end
95
95
  return
@@ -97,16 +97,16 @@ module Net
97
97
 
98
98
  fields = event.fields
99
99
  message = case event.name
100
- when "connect" then "正在连接并登录(#{fields[:protocol]})"
100
+ when "connect" then "Connecting and logging in (#{fields[:protocol]})"
101
101
  when "login_complete"
102
- fields[:status] == "ok" ? "登录成功" : "登录失败(#{fields[:code]})"
103
- when "connect_failed" then "连接失败(#{fields[:code]})"
104
- when "command_start" then "执行命令:#{fields[:text]};等待响应"
102
+ fields[:status] == "ok" ? "Login succeeded" : "Login failed (#{fields[:code]})"
103
+ when "connect_failed" then "Connection failed (#{fields[:code]})"
104
+ when "command_start" then "Executing command: #{fields[:text]}; waiting for response"
105
105
  when "command_complete"
106
- fields[:status] == "response_received" ? "命令响应完成(#{fields[:duration_ms]} ms)" :
107
- "命令失败(#{fields[:code]})"
106
+ fields[:status] == "response_received" ? "Command response complete (#{fields[:duration_ms]} ms)" :
107
+ "Command failed (#{fields[:code]})"
108
108
  when "operation_complete"
109
- fields[:status] == "completed" ? "脚本处理完成" : "脚本处理失败(#{fields[:code]})"
109
+ fields[:status] == "completed" ? "Script complete" : "Script failed (#{fields[:code]})"
110
110
  end
111
111
  write(fields[:host], message) if message
112
112
  end
@@ -151,8 +151,8 @@ module Net
151
151
  end
152
152
 
153
153
  # 显示已保存报告的位置与可读标签。
154
- def location(path, label: "报告")
155
- write(nil, "#{label}:#{path}")
154
+ def location(path, label: "report")
155
+ write(nil, "#{label}: #{path}")
156
156
  end
157
157
 
158
158
  private
@@ -160,15 +160,15 @@ module Net
160
160
  # 根据已知错误码返回简短排查建议,不展示任意异常正文。
161
161
  def failure_hint(code)
162
162
  case code
163
- when :authentication_failed then "设备拒绝认证,请核对账号、密码或 AAA 策略"
164
- when :authentication_error then "认证交互未完成,请核对认证方式或设备 AAA 日志"
165
- when :host_key_untrusted then "主机密钥尚未信任,请确认指纹并登记"
166
- when :host_key_changed then "已登记主机密钥发生变化,请核实设备身份"
167
- when :login_timeout then "未在期限内获得登录提示,请检查 SSH 握手与登录交互"
168
- when :connection_timeout then "SSH 连接或握手超时,请检查服务与网络"
169
- when :device_error then "设备拒绝命令,请核对厂商映射和账号权限"
170
- when :missing_completeness_evidence then "缺少完整性证据,未完成维度见设备报告"
171
- when :report_not_saved then "设备报告保存未确认"
163
+ when :authentication_failed then "Device rejected authentication; check username, password or AAA policy"
164
+ when :authentication_error then "Authentication dialogue incomplete; check authentication method or device AAA logs"
165
+ when :host_key_untrusted then "Host key is not yet trusted; verify and register its fingerprint"
166
+ when :host_key_changed then "Registered host key changed; verify device identity"
167
+ when :login_timeout then "Login prompt not received before the deadline; check SSH handshake and login dialogue"
168
+ when :connection_timeout then "SSH connection or handshake timed out; check service and network"
169
+ when :device_error then "Device rejected the command; check vendor mapping and account permissions"
170
+ when :missing_completeness_evidence then "Completeness evidence missing; see the device report for incomplete dimensions"
171
+ when :report_not_saved then "Device report persistence unconfirmed"
172
172
  end
173
173
  end
174
174
 
@@ -176,11 +176,11 @@ module Net
176
176
  def status_line
177
177
  percent = @total.positive? ? @completed * 100 / @total : 0
178
178
  counts = progress_counts
179
- "#{@completed}/#{@total} #{percent}% | #{counts} | 登录 #{@stages.values.count("登录")} 采集 #{@stages.values.count("采集")} 排队 #{[@total - @completed - @active, 0].max}"
179
+ "#{@completed}/#{@total} #{percent}% | #{counts} | Logging in #{@stages.values.count("logging in")} collecting #{@stages.values.count("collecting")} queued #{[@total - @completed - @active, 0].max}"
180
180
  end
181
181
 
182
182
  # 默认统计设备任务,领域适配器可提供自己的计数文案。
183
- def progress_counts = "成功 #{@succeeded} 未完成 #{@failed}"
183
+ def progress_counts = "succeeded #{@succeeded} incomplete #{@failed}"
184
184
 
185
185
  # 读取单调时钟秒数,避免系统校时影响期限与耗时计算。
186
186
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -198,14 +198,14 @@ module Net
198
198
  eta = if @completed == @total && @total.positive?
199
199
  "00:00:00"
200
200
  elsif @completed >= 5 && rate.positive?
201
- "约 #{duration((@total - @completed) / rate)}"
201
+ "about #{duration((@total - @completed) / rate)}"
202
202
  else
203
- "估算中"
203
+ "estimating"
204
204
  end
205
- line = "耗时 #{duration(elapsed)} | #{format("%.1f", rate)} 台/秒 | 剩余 #{eta}"
205
+ line = "elapsed #{duration(elapsed)} | #{format("%.1f", rate)} devices/s | remaining #{eta}"
206
206
  host, started = @task_started.min_by { |_key, value| value }
207
207
  if started && monotonic - started >= 10
208
- line += " | 最久 #{host} #{@stages[host]} #{(monotonic - started).floor}s"
208
+ line += " | longest #{host} #{@stages[host]} #{(monotonic - started).floor}s"
209
209
  end
210
210
  line
211
211
  end
@@ -260,7 +260,7 @@ module Net
260
260
 
261
261
  percent = @total.positive? ? " #{(@completed * 100.0 / @total).floor}%" : ""
262
262
  clear_refresh
263
- line = "[#{Time.now.strftime("%H:%M:%S")}] [#{@completed}/#{@total}#{percent} 执行中 #{@active}] [#{host || "批次"}] #{message}"
263
+ line = "[#{Time.now.strftime("%H:%M:%S")}] [#{@completed}/#{@total}#{percent} running #{@active}] [#{host || "batch"}] #{message}"
264
264
  line = "[#{Time.now.strftime("%H:%M:%S")}] #{host ? "#{host} " : ""}#{message}" unless @verbose
265
265
  write_output(line.gsub(/[[:cntrl:]]/, " ") + "\n")
266
266
  end
@@ -32,7 +32,7 @@ module Net
32
32
  Process.setrlimit(:NOFILE, required, hard)
33
33
  end
34
34
  rescue SystemCallError, ArgumentError, NotImplementedError
35
- raise ArgumentError, "本机文件描述符额度不足:#{parallel} 并发至少预留 #{required} 个文件描述符;请提高终端 ulimit -n 或降低 --parallel。已停止,未连接设备。", cause: nil
35
+ raise ArgumentError, "Insufficient local file descriptor limit: #{parallel} concurrent devices require at least #{required} file descriptors; raise the terminal ulimit -n or lower --parallel. Stopped without connecting to devices.", cause: nil
36
36
  end
37
37
  private_class_method :reserve!
38
38
  end
@@ -79,24 +79,30 @@ module Net
79
79
 
80
80
  # 统一返回写入回执;失败时从异常 receipt 判断目录项是否已经替换。
81
81
  def self.write(path, contents)
82
+ write_stream(path) { |file| file.write(contents) }
83
+ end
84
+
85
+ # 借出私有临时文件供分片写入,生产者正常返回后才同步并替换目标。
86
+ def self.write_stream(path)
82
87
  raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
88
+ raise ArgumentError, "a streaming writer block is required" unless block_given?
83
89
 
84
90
  receipt = Receipt.new(path: File.expand_path(path), state: :not_committed, phase: :resolve_parent)
85
91
  directory = File.realpath(File.dirname(receipt.path))
86
92
  destination = File.join(directory, File.basename(receipt.path))
87
93
  receipt = receipt.with(phase: :temporary_file)
88
- Tempfile.create([".net-connector-", ".tmp"], directory) do |file|
94
+ with_owned_file(-> { Tempfile.create([".net-connector-", ".tmp"], directory) }, temporary: true) do |file|
89
95
  file.binmode
90
96
  file.chmod(0o600)
91
97
  receipt = receipt.with(phase: :write)
92
- file.write(contents)
98
+ yield file
93
99
  receipt = receipt.with(phase: :file_sync)
94
100
  file.flush
95
101
  file.fsync
96
102
  receipt = receipt.with(phase: :rename)
97
103
  File.rename(file.path, destination)
98
104
  receipt = receipt.with(state: :committed, phase: :directory_open)
99
- File.open(directory, File::RDONLY | File::NOFOLLOW) do |parent|
105
+ with_owned_file(-> { File.open(directory, File::RDONLY | File::NOFOLLOW) }) do |parent|
100
106
  raise IOError, "parent is not a directory" unless parent.stat.directory?
101
107
 
102
108
  receipt = receipt.with(phase: :directory_sync)
@@ -118,6 +124,57 @@ module Net
118
124
  end
119
125
  raise klass.new(receipt: receipt, underlying_type: error.class.name.to_s), cause: nil
120
126
  end
127
+
128
+ # 唯一持有本次写入的文件句柄;临时目录项身份在取消递送前登记,退出时保留主控制流。
129
+ def self.with_owned_file(acquire, temporary: false)
130
+ file = path = identity = nil
131
+ completed = false
132
+ Thread.handle_interrupt(Exception => :never) do
133
+ file = acquire.call
134
+ if temporary
135
+ path = file.path
136
+ identity = file.stat
137
+ end
138
+ end
139
+ result = yield file
140
+ completed = true
141
+ result
142
+ rescue Exception # rubocop:disable Lint/RescueException -- 正常返回标记之后递送的取消也必须先于清理故障。
143
+ completed = false
144
+ raise
145
+ ensure
146
+ begin
147
+ cleanup_file(file, path, identity) if file
148
+ rescue StandardError, NotImplementedError
149
+ raise if completed
150
+ end
151
+ end
152
+
153
+ # 关闭失败仍尝试删除属于本次写入的临时目录项,已替换的同名文件不归本作用域所有。
154
+ def self.cleanup_file(file, path, identity)
155
+ Thread.handle_interrupt(Exception => :never) do
156
+ failure = nil
157
+ begin
158
+ file.close unless file.closed?
159
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 关闭故障不能跳过临时目录项清理。
160
+ failure = error
161
+ end
162
+ begin
163
+ if path && identity
164
+ current = File.lstat(path)
165
+ File.unlink(path) if current.dev == identity.dev && current.ino == identity.ino
166
+ end
167
+ rescue Errno::ENOENT
168
+ # 原子替换已经移走临时目录项,或其他清理已将其删除。
169
+ nil
170
+ rescue Exception => error # rubocop:disable Lint/RescueException -- 保留最先发生的清理故障。
171
+ failure ||= error
172
+ end
173
+ raise failure if failure
174
+ end
175
+ end
176
+
177
+ private_class_method :with_owned_file, :cleanup_file
121
178
  end
122
179
  end
123
180
  end
@@ -3,6 +3,6 @@
3
3
  module Net
4
4
  module Connector
5
5
  # gemspec、CLI 与发布工具共用此版本,运行时读取不触发设备依赖加载。
6
- VERSION = "0.17.2"
6
+ VERSION = "0.18.1"
7
7
  end
8
8
  end