net-connector 0.7.2 → 0.7.3
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 +9 -0
- data/CONTRIBUTING.md +1 -1
- data/README.md +3 -0
- data/docs/RELEASING.md +2 -2
- data/docs/architecture.md +4 -0
- data/lib/net/connector/engine/error_metadata.rb +5 -3
- data/lib/net/connector/engine/known_hosts.rb +14 -1
- data/lib/net/connector/netdisco/plan.rb +9 -0
- data/lib/net/connector/netdisco/progress.rb +3 -1
- data/lib/net/connector/netdisco/report/files.rb +9 -5
- data/lib/net/connector/netdisco/report/text.rb +1 -1
- data/lib/net/connector/netdisco/report.rb +19 -20
- data/lib/net/connector/netdisco/tftp_archive.rb +26 -10
- data/lib/net/connector/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98616a3bf5a59d99a6b9060e1d87d4574ecc7802c8d465d074d1e0b45cda7d6e
|
|
4
|
+
data.tar.gz: 0232eac8ff46db4326e28910f8a17dc64f571866ab567505161a660baa681238
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 525318622f7c1530155b49f1c8f8f196f52537aaf363f89e368e33072a68bf170a9203db49ec6e99f84100d45dbb63713a58e8c4f449b69ddeef5cad16386bdd
|
|
7
|
+
data.tar.gz: 7d6eb1adf97ce404c9c63839aee2ea76e406f4edbdd69059e9ea9f7db0983a7676522d06a69a7449a9d4c959c79a389650b94d9380a5941182d13d37444c411b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## 未发布
|
|
4
4
|
|
|
5
|
+
## 0.7.3 - 2026-10-07
|
|
6
|
+
|
|
7
|
+
- 修复外部备份计划可重复执行同一管理地址的问题,采样或跳过部分条目也不能绕过重复地址检查。
|
|
8
|
+
- TFTP 归档在文件已替换、同步或收尾失败时保留已写入副本的路径和失败阶段,报告保留明确的归档错误码,首次归档自动创建报告目录。
|
|
9
|
+
- 报告补写在文件替换后失败时仍保留已提交路径,并保留首次报告故障,避免把已有文件误报为未保存。
|
|
10
|
+
- 主机密钥共享锁最多等待 `login_timeout` 秒,超时返回 `known_hosts_busy` 并保留原有信任记录,避免认证后无限等待。
|
|
11
|
+
- 减少完整报告生成时的临时对象,文本报告和进度仅计算汇总,关闭进度输出时跳过收尾统计。
|
|
12
|
+
- 发布检查同时识别“未发布”和 `Unreleased`,存在未归档变更时拒绝发布。
|
|
13
|
+
|
|
5
14
|
## 0.7.2 - 2026-10-06
|
|
6
15
|
|
|
7
16
|
- 修复 IOS/NX-OS 配置清理误删 banner 内命令示例、进度文本及其后正文的问题,避免用截断配置覆盖旧备份。
|
data/CONTRIBUTING.md
CHANGED
|
@@ -28,7 +28,7 @@ bundle exec rake lint
|
|
|
28
28
|
- 复用现有 Ruby 对象、不可变档案和厂商策略。复杂的资源所有权、锁、截止时间及脱敏生命周期继续用中文注释说明原因。
|
|
29
29
|
- 测试应证明业务行为:失败不覆盖备份、中断释放全部资源、命令不自动重放、错误不泄露凭据。并发测试用队列等同步手段控制执行顺序。
|
|
30
30
|
- 使用虚构凭据和 `192.0.2.0/24` 等文档地址。不要提交设备配置、备份、私有地址或日志;扫描规则同样适用于测试和示例。
|
|
31
|
-
- 行为或公开契约变更写入 `CHANGELOG.md`
|
|
31
|
+
- 行为或公开契约变更写入 `CHANGELOG.md` 的“未发布”(兼容 `Unreleased`),同步修改相应文档。保留与本次任务无关的工作区改动。
|
|
32
32
|
|
|
33
33
|
### Ruby 范式与语法边界
|
|
34
34
|
|
data/README.md
CHANGED
|
@@ -232,6 +232,8 @@ Fleet 统一返回 `Netdisco::Report`,JSON 的 `schema_version` 固定为 `2`
|
|
|
232
232
|
|
|
233
233
|
`success_policy: :selected` 要求至少一台设备成功,其他记录只因 `filtered` 或 `sample_limit` 跳过,而且没有部分成功、回调或报告错误。缺少凭据、重复地址、无效地址、未知厂商、目标冲突和未知状态均会阻止成功。`coverage.complete` 只表示每条清单记录都已尝试任务;失败任务也计入尝试,不能据此判断配置已保存。自定义 `ResultStore#write(report, directory:)` 始终接收 Report,并通过 `summary` 获取统一 JSON 结构。
|
|
234
234
|
|
|
235
|
+
外部构造的计划也必须把同一管理地址的全部条目标记为 `duplicate_host` 并跳过,不能通过采样恢复其中一条的执行资格;IPv6 的等价写法视为同一地址。只需汇总时使用 `report.statistics`,它返回 `summary` 除 `devices` 外的字段,不生成设备明细,也不缓存调用方持有的批次容器。
|
|
236
|
+
|
|
235
237
|
```sh
|
|
236
238
|
export NETDISCO_URL=https://netdisco.example/netdisco
|
|
237
239
|
export NETDISCO_USERNAME=inventory-reader
|
|
@@ -456,3 +458,4 @@ TFTP 的 `verification` 汇总与设备条目的 `server_file_verified` 区分
|
|
|
456
458
|
|
|
457
459
|
使用 `accept_new` / `replace` 时,SSH 在每个会话的临时 known_hosts 中协商,认证后通过独立锁文件合并到共享文件。
|
|
458
460
|
网络登录不占用共享锁;失败登录不删除原信任记录,`replace` 只替换当前主机。该同步协调本库进程,外部手工工具应避免同时改写同一文件。
|
|
461
|
+
认证后的合并锁另有最多 `login_timeout` 秒的等待预算,超时返回 `ConnectionError`(`code: :known_hosts_busy`),关闭会话且保留共享信任记录;此预算只约束锁等待,不是整批任务的期限。
|
data/docs/RELEASING.md
CHANGED
|
@@ -13,7 +13,7 @@ bundle exec rake release:check
|
|
|
13
13
|
## 准备版本
|
|
14
14
|
|
|
15
15
|
1. 在 `lib/net/connector/version.rb` 设置新的稳定 `X.Y.Z` 版本。
|
|
16
|
-
2. 将 CHANGELOG
|
|
16
|
+
2. 将 CHANGELOG 的“未发布”或 `Unreleased` 内容移入对应的 `## X.Y.Z` 或
|
|
17
17
|
`## X.Y.Z - YYYY-MM-DD` 段;版本说明不能为空。
|
|
18
18
|
3. 提交最终源码,确认工作区干净,并将提交推送到目标 GitHub 仓库的 `main`。
|
|
19
19
|
4. 执行 dry run,保留生成的 gem 和 SHA-256,审核后选择下面一种发布方式。
|
|
@@ -27,7 +27,7 @@ dry run 执行本地检查,创建独占的 `tmp/release/<version>/candidate-*/
|
|
|
27
27
|
它允许未提交的工作区用于预检,但仍要求版本与发布说明已经整理完成。
|
|
28
28
|
正式发布额外要求干净且未变化的 Git 提交。
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
当前任一“未发布”或 `Unreleased` 段包含尚未归档的内容时,发布脚本(包括 dry run)会拒绝继续;
|
|
31
31
|
需要先整理版本。日常使用 `release:check` 不受此限制。
|
|
32
32
|
|
|
33
33
|
## 本地发布
|
data/docs/architecture.md
CHANGED
|
@@ -66,6 +66,8 @@ Fleet 在规范目标校验、凭据解析及连接器构造之前加同一把
|
|
|
66
66
|
|
|
67
67
|
提交后的 `BackupPersistenceError` 只携带原 `Backup` 元数据和受控写入回执,Fleet 保留产物并使用已有 saved_with_error 分类。只接受库定义的具体完成错误及匹配产物类型,第三方异常的 backup 属性或自定义 WriteError 子类不作为完成证据。报告保留已提交位置,导出错误说明 committed;设备命令不会自动重放。
|
|
68
68
|
|
|
69
|
+
TFTP 归档分别保留本地批次副本与服务器归档副本的提交位置;写入器的原生回执必须与本次目标路径一致。目录同步或收尾失败仍保留已提交路径,成功上传转为 `reported_with_error`,诊断记录归档失败及持久化阶段;设备任务此前已有错误时保留原诊断。报告失败后的 JSON 补写同样保留已提交位置,不覆盖首次报告故障,也不重新执行设备任务。
|
|
70
|
+
|
|
69
71
|
TFTP 只确认设备报告的上传结果:策略去掉命令、应答和提示符回显后,检查明确的完成行;文件名或表示“即将上传”的进度文字不算成功。原始输出或终端渲染文本中的失败证据优先于成功文字。
|
|
70
72
|
|
|
71
73
|
TFTP 内置策略的 `validate_options!` 是纯参数校验,执行于 H3C 源文件探测之前。整个探测、脚本、证据核对、回执和事件记录由原有 `with_operation(:tftp_backup)` 独占;其他线程、Fiber 及回调重入均不能插入设备命令。租约只保护当前会话,不能隔离其他设备连接或服务器上的同名文件。
|
|
@@ -104,6 +106,8 @@ Fleet 始终返回 Report,委托 Batch 的执行结果及严格 success?/statu
|
|
|
104
106
|
|
|
105
107
|
报告 summary 使用单一 schema_version: 2,包含 policy、policy_success、coverage、单调 duration_ms、逐台 diagnostic 及 report_diagnostic。coverage 仅按已尝试状态计数,失败和部分成功也算尝试。`ResultStore#write(report, directory:)` 始终接收 Report。已存文件是写入前快照;写入自身失败时,返回对象与 CLI 携带最终报告故障。`Batch#build_report` 只构造报告,不重跑任务,也不能补出未记录的总耗时。
|
|
106
108
|
|
|
109
|
+
`Report#statistics` 仅计算汇总,字段等同于 `summary.except(:devices)`,供文本与进度输出使用;完整设备明细由 JSON 输出时生成。两者不缓存结果,保留调用方持有 Batch 容器的既有语义。静默进度不读取报告。
|
|
110
|
+
|
|
107
111
|
Diagnostic 只保存固定词表中的码、类型、阶段和受控产物状态;不调用异常 inspect/to_h,不保留异常引用、正文、消息、回溯、命令、source 或 line。未知错误码/阶段为 nil,未知类型归为 StandardError。Report 也筛选手工构造结果中的 error_code/error_type、callback_errors 和 report_error。产物阶段仅来自与实际 Backup/TftpReceipt 匹配的库内精确错误类;通用文件回执只在报告写入边界使用,不能冒充设备备份完成。
|
|
108
112
|
|
|
109
113
|
`Planner` 先按厂商采样,再按实际 TFTP 文件名排除覆盖冲突,保留采样顺序中的首台设备。未入选设备仍标记为 `sample_limit`,只有入选后目标重名才标记为 `remote_filename_collision`。`Plan#validate!` 校验任务与清单的对应关系;调用方传入或通过 `with` 修改的计划也必须在读取凭据、创建目录及设备 I/O 前通过校验。冲突规则适用于全部厂商,包括不同地址规范化后产生相同文件名的情况。
|
|
@@ -16,10 +16,11 @@ module Net
|
|
|
16
16
|
Errno::ENOTSUP Errno::EOPNOTSUPP Errno::ETIMEDOUT Errno::ECONNREFUSED Errno::ECONNRESET
|
|
17
17
|
Net::Connector::Storage::PrivateFile::WriteError
|
|
18
18
|
Net::Connector::Storage::PrivateFile::PersistenceError
|
|
19
|
-
Net::Connector::Storage::PrivateFile::DirectorySyncUnsupported
|
|
19
|
+
Net::Connector::Storage::PrivateFile::DirectorySyncUnsupported
|
|
20
|
+
Net::Connector::Netdisco::TftpArchive::Unavailable Net::Connector::Netdisco::TftpArchive::ArchiveFailed]).freeze
|
|
20
21
|
CODES = (CONNECTOR_TYPES.map { |name| name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } +
|
|
21
22
|
%i[authentication_failed connection_failed connection_refused no_route connection_reset
|
|
22
|
-
connection_timeout host_key_changed host_key_untrusted rsa_too_small unsupported_cipher
|
|
23
|
+
connection_timeout host_key_changed host_key_untrusted known_hosts_busy rsa_too_small unsupported_cipher
|
|
23
24
|
ambiguous_description ambiguous_neighbor confirmation_required description_stages_unsupported
|
|
24
25
|
description_unconfirmed description_unsupported incomplete_configuration interface_missing
|
|
25
26
|
neighbor_discovery_unsupported parse_failed invalid_output_encoding persistence_unconfirmed stale_plan startup_config_missing
|
|
@@ -27,7 +28,8 @@ module Net
|
|
|
27
28
|
transfer_path_mismatch transfer_path_unconfirmed transfer_unconfirmed uncommitted_configuration
|
|
28
29
|
unrecognized_output unsupported_configuration_format verification_plan_changed candidate_isolation_unavailable
|
|
29
30
|
backup_durability_unsupported backup_finalize_failed backup_persistence_unconfirmed
|
|
30
|
-
file_finalize_failed file_persistence_unconfirmed file_write_failed directory_sync_unsupported
|
|
31
|
+
file_finalize_failed file_persistence_unconfirmed file_write_failed directory_sync_unsupported
|
|
32
|
+
tftp_history_unavailable tftp_archive_failed]).freeze
|
|
31
33
|
PHASES = %i[connect login enable command script read write close logging backup collect discover parse
|
|
32
34
|
plan apply verify persist save interact tftp_backup report callback].freeze
|
|
33
35
|
private_constant :CONNECTOR_TYPES, :TYPES, :CODES, :PHASES
|
|
@@ -16,6 +16,7 @@ module Net
|
|
|
16
16
|
FileUtils.mkdir_p(File.dirname(destination), mode: 0o700)
|
|
17
17
|
@destination = File.join(File.realpath(File.dirname(destination)), File.basename(destination))
|
|
18
18
|
@host = configuration.host
|
|
19
|
+
@lock_timeout = configuration.login_timeout
|
|
19
20
|
@host = "[#{@host}]:#{configuration.port}" if configuration.port && configuration.port != 22
|
|
20
21
|
@replace = replace
|
|
21
22
|
@original = read_shared
|
|
@@ -100,13 +101,25 @@ module Net
|
|
|
100
101
|
unless stat.file? && stat.uid == Process.euid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
101
102
|
raise IOError, "known_hosts lock must be an owned private regular file"
|
|
102
103
|
end
|
|
103
|
-
file
|
|
104
|
+
acquire_lock(file)
|
|
104
105
|
current = File.lstat(lock_path)
|
|
105
106
|
raise IOError, "known_hosts lock changed" unless current.dev == stat.dev && current.ino == stat.ino
|
|
106
107
|
|
|
107
108
|
yield
|
|
108
109
|
end
|
|
109
110
|
end
|
|
111
|
+
|
|
112
|
+
# 认证后的合并另有有限等待;持锁进程卡住时保留共享信任并交由会话正常清理。
|
|
113
|
+
def acquire_lock(file)
|
|
114
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @lock_timeout
|
|
115
|
+
until file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
116
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
117
|
+
if remaining <= 0
|
|
118
|
+
raise ConnectionError.new("known_hosts lock wait timed out", code: :known_hosts_busy, host: @host, phase: :login)
|
|
119
|
+
end
|
|
120
|
+
sleep [remaining, 0.01].min
|
|
121
|
+
end
|
|
122
|
+
end
|
|
110
123
|
end
|
|
111
124
|
end
|
|
112
125
|
end
|
|
@@ -27,6 +27,7 @@ module Net
|
|
|
27
27
|
unless inventory.all?(Device) && outcomes.size == inventory.size
|
|
28
28
|
raise ArgumentError, "plan inventory and outcomes must have the same device slots"
|
|
29
29
|
end
|
|
30
|
+
validate_duplicate_hosts!
|
|
30
31
|
|
|
31
32
|
selected_indices = {}
|
|
32
33
|
selected_filenames = {}
|
|
@@ -65,6 +66,14 @@ module Net
|
|
|
65
66
|
|
|
66
67
|
private
|
|
67
68
|
|
|
69
|
+
# 外部快照也遵守清单入口的地址唯一性;采样不能把重复设备重新变为可执行。
|
|
70
|
+
def validate_duplicate_hosts!
|
|
71
|
+
groups = inventory.group_by { |device| device.host && IPAddr.new(device.host).to_s }
|
|
72
|
+
return unless groups.any? { |host, devices| host && devices.size > 1 && devices.any? { |device| device.issue != :duplicate_host } }
|
|
73
|
+
|
|
74
|
+
raise ArgumentError, "plan duplicate hosts must be marked as duplicate_host"
|
|
75
|
+
end
|
|
76
|
+
|
|
68
77
|
# 跳过结果不携带执行产物;文件冲突必须有本批次实际执行的目标作为依据。
|
|
69
78
|
def valid_skipped_outcome?(result, device, selected_filenames)
|
|
70
79
|
return false unless result.is_a?(Outcome) && result.device.equal?(device) && result.backup.nil? &&
|
|
@@ -101,7 +101,9 @@ module Net
|
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
def finish(report)
|
|
104
|
-
|
|
104
|
+
return unless @enabled
|
|
105
|
+
|
|
106
|
+
summary = report.statistics
|
|
105
107
|
failures = report.outcomes.reject { |item| item.success? || %i[filtered sample_limit].include?(item.status) }
|
|
106
108
|
.group_by { |item| item.error_code || item.status }.transform_values(&:size)
|
|
107
109
|
unless failures.empty?
|
|
@@ -16,16 +16,15 @@ module Net
|
|
|
16
16
|
Storage::PrivateFile.write(destination, JSON.pretty_generate(finalized.summary))
|
|
17
17
|
finalized
|
|
18
18
|
rescue StandardError => error
|
|
19
|
-
location =
|
|
20
|
-
destination
|
|
21
|
-
end
|
|
19
|
+
location = committed_location(error, destination)
|
|
22
20
|
failed = report.with_report_error(error, location: location)
|
|
23
21
|
begin
|
|
24
22
|
saved = failed.with(report_location: destination)
|
|
25
23
|
Storage::PrivateFile.write(destination, JSON.pretty_generate(saved.summary))
|
|
26
24
|
failed = saved
|
|
27
|
-
rescue StandardError
|
|
28
|
-
#
|
|
25
|
+
rescue StandardError => persistence_error
|
|
26
|
+
# 补写也可能在替换后失败;保留位置,但不覆盖首次报告故障。
|
|
27
|
+
failed = saved if committed_location(persistence_error, destination)
|
|
29
28
|
end
|
|
30
29
|
begin
|
|
31
30
|
Text.write(directory: directory, report: failed, plan: plan, concurrency: concurrency)
|
|
@@ -34,6 +33,11 @@ module Net
|
|
|
34
33
|
end
|
|
35
34
|
failed
|
|
36
35
|
end
|
|
36
|
+
|
|
37
|
+
def self.committed_location(error, destination)
|
|
38
|
+
destination if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed? && error.receipt.path == File.expand_path(destination)
|
|
39
|
+
end
|
|
40
|
+
private_class_method :committed_location
|
|
37
41
|
end
|
|
38
42
|
end
|
|
39
43
|
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.
|
|
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,
|
|
@@ -61,15 +61,14 @@ module Net
|
|
|
61
61
|
policy: policy, duration_ms: duration_ms, report_diagnostic: diagnostic, log_directory: log_directory)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
#
|
|
65
|
-
def
|
|
64
|
+
# 统计不创建逐设备明细;文本与进度输出复用此入口,不缓存调用方持有的批次。
|
|
65
|
+
def statistics
|
|
66
66
|
batch_data = batch_summary
|
|
67
67
|
batch_data.merge(
|
|
68
68
|
schema_version: 2, policy: policy, policy_success: policy_success?, duration_ms: duration_ms,
|
|
69
69
|
coverage: { complete: !outcomes.empty? && outcomes.all? { |outcome| ATTEMPTED_STATUSES.include?(outcome.status) },
|
|
70
70
|
attempted: outcomes.count { |outcome| ATTEMPTED_STATUSES.include?(outcome.status) },
|
|
71
71
|
skipped: outcomes.count { |outcome| !ATTEMPTED_STATUSES.include?(outcome.status) } },
|
|
72
|
-
devices: device_summaries(batch_data.fetch(:devices)),
|
|
73
72
|
callback_errors: callback_errors.map { |entry| { host: entry[:host], error_type: ErrorMetadata.type(entry[:error_type]) } },
|
|
74
73
|
report_location: report_location, report_error: ErrorMetadata.type(report_error),
|
|
75
74
|
verification: mode == :tftp ? { verified: outcomes.count { |outcome| server_verified?(outcome) },
|
|
@@ -78,6 +77,9 @@ module Net
|
|
|
78
77
|
)
|
|
79
78
|
end
|
|
80
79
|
|
|
80
|
+
# 手工构造的批次同样经过诊断白名单,不序列化任意异常内容。
|
|
81
|
+
def summary = statistics.merge(devices: device_summaries)
|
|
82
|
+
|
|
81
83
|
def inspect = "#<#{self.class} policy=#{policy} status=#{status} policy_success=#{policy_success?}>"
|
|
82
84
|
|
|
83
85
|
private
|
|
@@ -92,19 +94,7 @@ module Net
|
|
|
92
94
|
failed: outcomes.count { |outcome| outcome.status == :failed },
|
|
93
95
|
skipped: outcomes.count(&:skipped?),
|
|
94
96
|
counts: counts,
|
|
95
|
-
callback_errors: callback_errors
|
|
96
|
-
devices: outcomes.map do |outcome|
|
|
97
|
-
{ host: outcome.device.host || outcome.device.source_ip, name: outcome.device.name,
|
|
98
|
-
vendor: outcome.device.vendor,
|
|
99
|
-
status: outcome.status, path: outcome.backup&.path,
|
|
100
|
-
bytes: outcome.backup.is_a?(Backup) ? outcome.backup.bytes : nil,
|
|
101
|
-
sha256: outcome.backup.is_a?(Backup) ? outcome.backup.sha256 : nil,
|
|
102
|
-
change: outcome.backup.is_a?(Backup) ? outcome.backup.change : nil,
|
|
103
|
-
previous_sha256: outcome.backup.is_a?(Backup) ? outcome.backup.previous_sha256 : nil,
|
|
104
|
-
started_at: outcome.started_at&.iso8601, finished_at: outcome.finished_at&.iso8601,
|
|
105
|
-
duration_ms: outcome.duration_ms,
|
|
106
|
-
error_code: outcome.error_code, error_type: outcome.error_type }
|
|
107
|
-
end
|
|
97
|
+
callback_errors: callback_errors
|
|
108
98
|
}
|
|
109
99
|
end
|
|
110
100
|
|
|
@@ -122,13 +112,22 @@ module Net
|
|
|
122
112
|
bytes: receipt&.server_bytes, sha256: receipt&.server_sha256 }
|
|
123
113
|
end
|
|
124
114
|
|
|
125
|
-
def device_summaries
|
|
126
|
-
|
|
115
|
+
def device_summaries
|
|
116
|
+
outcomes.map do |outcome|
|
|
127
117
|
diagnostic = outcome.diagnostic || Diagnostic.new(error_code: outcome.error_code, error_type: outcome.error_type)
|
|
118
|
+
entry = { host: outcome.device.host || outcome.device.source_ip, name: outcome.device.name,
|
|
119
|
+
vendor: outcome.device.vendor, status: outcome.status, path: outcome.backup&.path,
|
|
120
|
+
bytes: outcome.backup.is_a?(Backup) ? outcome.backup.bytes : nil,
|
|
121
|
+
sha256: outcome.backup.is_a?(Backup) ? outcome.backup.sha256 : nil,
|
|
122
|
+
change: outcome.backup.is_a?(Backup) ? outcome.backup.change : nil,
|
|
123
|
+
previous_sha256: outcome.backup.is_a?(Backup) ? outcome.backup.previous_sha256 : nil,
|
|
124
|
+
started_at: outcome.started_at&.iso8601, finished_at: outcome.finished_at&.iso8601,
|
|
125
|
+
duration_ms: outcome.duration_ms,
|
|
126
|
+
error_code: diagnostic.error_code, error_type: diagnostic.error_type,
|
|
127
|
+
diagnostic: diagnostic.to_h.except(:error_code, :error_type) }
|
|
128
128
|
entry = entry.merge(tftp_summary(outcome)) if mode == :tftp
|
|
129
129
|
entry[:session_log] = File.join(log_directory, "#{outcome.device.host.tr(":", "_")}.log") if log_directory && outcome.device.host && outcome.started_at
|
|
130
|
-
entry
|
|
131
|
-
diagnostic: diagnostic.to_h.except(:error_code, :error_type))
|
|
130
|
+
entry
|
|
132
131
|
end
|
|
133
132
|
end
|
|
134
133
|
end
|
|
@@ -32,6 +32,7 @@ module Net
|
|
|
32
32
|
def initialize(directory:, root: nil)
|
|
33
33
|
@report_directory = File.expand_path(directory)
|
|
34
34
|
@server_root = root && File.realpath(root)
|
|
35
|
+
FileUtils.mkdir_p(@report_directory, mode: 0o700)
|
|
35
36
|
@server_archive_directory = create_server_archive_directory if @server_root
|
|
36
37
|
@remote_suffix = reserve_remote_suffix unless @server_root
|
|
37
38
|
@verifier = TftpVerification.new(root: @server_root)
|
|
@@ -137,7 +138,7 @@ module Net
|
|
|
137
138
|
|
|
138
139
|
def archive_uploaded_file(outcome)
|
|
139
140
|
receipt = outcome.backup
|
|
140
|
-
|
|
141
|
+
saved_paths = { local_path: nil, archive_path: nil }
|
|
141
142
|
batch_directory = File.join(@report_directory, "tftp")
|
|
142
143
|
FileUtils.mkdir_p(batch_directory, mode: 0o700)
|
|
143
144
|
extension = File.extname(receipt.path)
|
|
@@ -152,9 +153,8 @@ module Net
|
|
|
152
153
|
end
|
|
153
154
|
raise ArchiveFailed.new(host: outcome.device.host) unless Digest::SHA256.hexdigest(bytes) == receipt.server_sha256
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
Storage::PrivateFile.write(server_archive_path, bytes)
|
|
156
|
+
persist_archive(saved_paths, :local_path, batch_archive_path, bytes)
|
|
157
|
+
persist_archive(saved_paths, :archive_path, server_archive_path, bytes)
|
|
158
158
|
if outcome.success?
|
|
159
159
|
uploaded_fingerprint = Storage::SafeFile.fingerprint(uploaded_path)
|
|
160
160
|
raise ArchiveFailed.new(host: outcome.device.host) unless uploaded_fingerprint.sha256 == receipt.server_sha256
|
|
@@ -162,16 +162,32 @@ module Net
|
|
|
162
162
|
|
|
163
163
|
File.unlink(uploaded_path)
|
|
164
164
|
end
|
|
165
|
-
outcome.with(backup: receipt.with(
|
|
166
|
-
rescue StandardError
|
|
167
|
-
retained = outcome.with(backup: receipt.with(
|
|
168
|
-
outcome.success? ? archive_failure(retained) : retained
|
|
165
|
+
outcome.with(backup: receipt.with(**saved_paths))
|
|
166
|
+
rescue StandardError => error
|
|
167
|
+
retained = outcome.with(backup: receipt.with(**saved_paths))
|
|
168
|
+
outcome.success? ? archive_failure(retained, persistence_error: error) : retained
|
|
169
169
|
end
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
# 路径仅由本次写入器的可信回执确认;同步或清理失败不能抹掉已替换的文件。
|
|
172
|
+
def persist_archive(saved_paths, field, path, bytes)
|
|
173
|
+
Storage::PrivateFile.write(path, bytes)
|
|
174
|
+
saved_paths[field] = path
|
|
175
|
+
rescue StandardError => error
|
|
176
|
+
if Storage::PrivateFile.receipt_error?(error) && error.receipt.committed? && error.receipt.path == path
|
|
177
|
+
saved_paths[field] = path
|
|
178
|
+
end
|
|
179
|
+
raise
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def archive_failure(outcome, persistence_error: nil)
|
|
172
183
|
error = ArchiveFailed.new(host: outcome.device.host)
|
|
184
|
+
diagnostic = Diagnostic.from(error, backup: outcome.backup)
|
|
185
|
+
if Storage::PrivateFile.receipt_error?(persistence_error)
|
|
186
|
+
diagnostic = diagnostic.with(artifact_state: persistence_error.receipt.state, artifact_phase: persistence_error.receipt.phase,
|
|
187
|
+
underlying_type: persistence_error.underlying_type)
|
|
188
|
+
end
|
|
173
189
|
outcome.with(status: :reported_with_error, error_code: error.code, error_type: error.class.name,
|
|
174
|
-
diagnostic:
|
|
190
|
+
diagnostic: diagnostic)
|
|
175
191
|
end
|
|
176
192
|
|
|
177
193
|
def archive_filename(device, extension)
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: net-connector
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- net-connector contributors
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: expect-pty
|