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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/docs/architecture.md +14 -0
- data/docs/usage.md +4 -0
- data/docs/usage.zh-CN.md +4 -0
- data/lib/net/connector/collect/progress.rb +21 -21
- data/lib/net/connector/device/base.rb +12 -8
- data/lib/net/connector/device/save_config.rb +2 -2
- data/lib/net/connector/engine/error/metadata.rb +2 -1
- data/lib/net/connector/engine/execution.rb +37 -10
- data/lib/net/connector/engine/known_hosts.rb +19 -6
- data/lib/net/connector/engine/log/messages.rb +16 -16
- data/lib/net/connector/engine/session.rb +5 -4
- data/lib/net/connector/engine/transports.rb +60 -41
- data/lib/net/connector/inventory/filters.rb +2 -2
- data/lib/net/connector/netdisco/cli/backup.rb +6 -6
- data/lib/net/connector/netdisco/cli/credentials.rb +2 -2
- data/lib/net/connector/netdisco/cli/help.rb +16 -16
- data/lib/net/connector/netdisco/cli/options.rb +44 -44
- data/lib/net/connector/netdisco/cli/setup.rb +18 -18
- data/lib/net/connector/netdisco/cli/topology.rb +21 -21
- data/lib/net/connector/netdisco/cli.rb +24 -24
- data/lib/net/connector/netdisco/config/defaults.rb +1 -1
- data/lib/net/connector/netdisco/config/initializer.rb +13 -13
- data/lib/net/connector/netdisco/progress.rb +17 -17
- data/lib/net/connector/netdisco/report/text.rb +15 -15
- data/lib/net/connector/netdisco/ssh/archive/client.rb +109 -79
- data/lib/net/connector/netdisco/ssh/archive/protocol.rb +107 -0
- data/lib/net/connector/netdisco/ssh/archive/worker.py +35 -19
- data/lib/net/connector/netdisco/ssh/authentication.rb +8 -10
- data/lib/net/connector/netdisco/topology/files.rb +8 -8
- data/lib/net/connector/netdisco/topology/presentation.rb +15 -15
- data/lib/net/connector/progress.rb +36 -36
- data/lib/net/connector/resources.rb +1 -1
- data/lib/net/connector/storage/atomic_file.rb +60 -3
- data/lib/net/connector/version.rb +1 -1
- 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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
237
|
-
if not
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
archived
|
|
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=
|
|
247
|
-
|
|
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
|
-
|
|
255
|
-
if check != current or
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 = ["
|
|
39
|
-
"
|
|
40
|
-
outcome[:script_file] ? "
|
|
41
|
-
lines << "
|
|
42
|
-
lines << "
|
|
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])}
|
|
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| "
|
|
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: "
|
|
10
|
-
discovered: "
|
|
11
|
-
unsupported_operation: "
|
|
12
|
-
missing_credentials: "
|
|
13
|
-
REASONS = { unknown_neighbor_name: "
|
|
14
|
-
ambiguous_neighbor: "
|
|
15
|
-
unsafe_description: "
|
|
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 "
|
|
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}
|
|
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 "
|
|
47
|
-
return "
|
|
48
|
-
return "
|
|
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 "
|
|
50
|
+
return "no eligible devices" if summary[:devices].empty?
|
|
51
51
|
|
|
52
|
-
{ plan: "
|
|
53
|
-
apply: "
|
|
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, "
|
|
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} |
|
|
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, "
|
|
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}
|
|
62
|
+
message = "#{message}; #{hint}" if hint
|
|
63
63
|
if @verbose || !success
|
|
64
|
-
emit(host, "#{message}
|
|
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, "
|
|
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, "
|
|
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] = "
|
|
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 "
|
|
100
|
+
when "connect" then "Connecting and logging in (#{fields[:protocol]})"
|
|
101
101
|
when "login_complete"
|
|
102
|
-
fields[:status] == "ok" ? "
|
|
103
|
-
when "connect_failed" then "
|
|
104
|
-
when "command_start" then "
|
|
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" ? "
|
|
107
|
-
"
|
|
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" ? "
|
|
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}
|
|
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 "
|
|
164
|
-
when :authentication_error then "
|
|
165
|
-
when :host_key_untrusted then "
|
|
166
|
-
when :host_key_changed then "
|
|
167
|
-
when :login_timeout then "
|
|
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} |
|
|
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 = "
|
|
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
|
-
"
|
|
201
|
+
"about #{duration((@total - @completed) / rate)}"
|
|
202
202
|
else
|
|
203
|
-
"
|
|
203
|
+
"estimating"
|
|
204
204
|
end
|
|
205
|
-
line = "
|
|
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 += " |
|
|
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}
|
|
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, "
|
|
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
|
|
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
|