net-connector 0.10.1 → 0.10.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 +15 -0
- data/docs/architecture.md +2 -0
- data/docs/netdisco.md +8 -0
- data/docs/netdisco.zh-CN.md +8 -0
- data/docs/usage.md +2 -0
- data/docs/usage.zh-CN.md +2 -0
- data/lib/net/connector/background_task.rb +1 -1
- data/lib/net/connector/device/base.rb +1 -1
- data/lib/net/connector/device/local_backup.rb +19 -1
- data/lib/net/connector/device_session.rb +1 -1
- data/lib/net/connector/engine/dialogue.rb +2 -2
- data/lib/net/connector/engine/error_metadata.rb +1 -1
- data/lib/net/connector/engine/session.rb +60 -31
- data/lib/net/connector/engine/terminal_renderer.rb +2 -1
- data/lib/net/connector/execution_pool.rb +7 -2
- data/lib/net/connector/netdisco/diagnostic.rb +2 -0
- data/lib/net/connector/netdisco/fleet.rb +10 -3
- data/lib/net/connector/netdisco/inventory_cache.rb +13 -7
- data/lib/net/connector/netdisco/rules.rb +17 -7
- data/lib/net/connector/netdisco/settings.rb +32 -8
- data/lib/net/connector/netdisco/tftp_archive.rb +12 -3
- 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: 641ad063dfd167ac9a8c45508bda6e759ebbfd0ec4cf62b86d3ca301ef165b8f
|
|
4
|
+
data.tar.gz: 588bc23e9ee7154a45baa07ebbcf0688c32926acc035bf881fa01a64cb999ec3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b5a701fc703b5015609082220e370fcc1837110c511be0b6094abaf37d3a88b2cb1206f807c9ce9c5733fdc7e7c49a1bba33a4788ff08d3f0207d855569bc249
|
|
7
|
+
data.tar.gz: 95c55b593e14974041b216da1160df02ba0770c0ddb8b71a2a2bd0d103245f93ac84555ac7ce0238b63d678be252f6592b0f839842dbc39bc78782a2a17d7452
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 0.10.3 - 2026-10-16
|
|
4
|
+
|
|
5
|
+
- 保护会话锁取得、登记和释放的异步取消边界,完整操作、单次执行和关闭共用持锁作用域;业务等待仍可取消,非局部退出仍释放锁。
|
|
6
|
+
- 工作线程提前退出或被终止时,线程池报告未完成任务并回收其余线程,避免返回貌似成功的空结果;跨作用域重新交付异常保留原始原因链,不附加调用方已处理的无关异常。
|
|
7
|
+
- 修复 UTF-8 命令诊断混用字符与字节偏移导致截断或异常的问题,保留二进制输出及终端重绘前的错误证据。
|
|
8
|
+
- 固定策略快照的相对路径基准,后续目录变化和策略覆盖不再改变同批目录与信任文件;活设置及设备凭据仍动态读取。
|
|
9
|
+
- 拒绝规范化后指向不同连接器的地址或厂商覆盖,以及规范化为空的必需厂商标签;IP 地址校验异常不再携带原始输入。
|
|
10
|
+
- 终端逐行输出改用 Ruby 原生行尾裁剪,减少正则扫描和临时对象,保留控制序列、UTF-8 校验及行长度限额。
|
|
11
|
+
|
|
12
|
+
## 0.10.2 - 2026-10-16
|
|
13
|
+
|
|
14
|
+
- 修复空清单缓存文件阻断查询的问题,空文件按缓存未命中处理,远端查询失败仍正常报告。
|
|
15
|
+
- 减少大清单缓存读取和刷新时的临时对象分配,保留字段白名单、限额和不可变快照。
|
|
16
|
+
- 修复本地备份及 TFTP 归档完成后锁关闭失败导致回执丢失的问题,报告保留已保存路径和原有设备错误,直接备份通过 `BackupFinalizeError#backup` 提供完成元数据。
|
|
17
|
+
|
|
3
18
|
## 0.10.1 - 2026-10-11
|
|
4
19
|
|
|
5
20
|
- 设备 `open`、PTY、SSH 信任快照、日志与本地文件统一保护资源交接和关闭边界;异步取消在资源登记或回收后交付,业务等待仍可取消,收尾故障不覆盖首个操作错误。
|
data/docs/architecture.md
CHANGED
|
@@ -73,6 +73,8 @@ Fleet 在规范目标校验、凭据解析及连接器构造之前加同一把
|
|
|
73
73
|
|
|
74
74
|
提交后的 `BackupPersistenceError` 只携带原 `Backup` 元数据和受控写入回执,Fleet 保留产物并使用已有 saved_with_error 分类。只接受库定义的具体完成错误及匹配产物类型,第三方异常的 backup 属性或自定义 WriteError 子类不作为完成证据。报告保留已提交位置,导出错误说明 committed;设备命令不会自动重放。
|
|
75
75
|
|
|
76
|
+
直接备份在路径锁退出前保存完成元数据,锁关闭的普通故障转换为 `BackupFinalizeError`,其中 `backup` 保留结果,`underlying_type` 经过类型白名单。该错误只确认备份操作已完成,不制造文件同步回执;未变文件的历史持久性仍未补验。Fleet 的外层路径锁及 TFTP 归档锁同样保存最新结果,退出故障转为 saved_with_error 或 reported_with_error,已有设备诊断优先,中断继续传播。
|
|
77
|
+
|
|
76
78
|
TFTP 归档分别保留本地批次副本与服务器归档副本的提交位置;写入器的原生回执必须与本次目标路径一致。目录同步或收尾失败仍保留已提交路径,成功上传转为 `reported_with_error`,诊断记录归档失败及持久化阶段;设备任务此前已有错误时保留原诊断。报告失败后的 JSON 补写同样保留已提交位置,不覆盖首次报告故障,也不重新执行设备任务。
|
|
77
79
|
|
|
78
80
|
TFTP 只确认设备报告的上传结果:策略去掉命令、应答和提示符回显后,检查明确的完成行;文件名或表示“即将上传”的进度文字不算成功。原始输出或终端渲染文本中的失败证据优先于成功文字。
|
data/docs/netdisco.md
CHANGED
|
@@ -57,6 +57,8 @@ The directory must belong to the current user with mode `0700`; cache and lock f
|
|
|
57
57
|
|
|
58
58
|
Expired inventory refreshes automatically. A remote inventory failure falls back to validated history without changing its original timestamp. Configuration errors, missing credentials, interruption and unsafe files remain errors. Failed cache writes preserve newly fetched inventory. `fleet.inventory_status` reports `:fresh_cache`, `:refreshed`, `:stale_cache` or `:uncached`, the original `fetched_at`, and safe failure metadata; the CLI warns on history fallback or cache write failure.
|
|
59
59
|
|
|
60
|
+
An empty or malformed cache file is a cache miss and triggers a remote query. It cannot supply fallback history if that query fails.
|
|
61
|
+
|
|
60
62
|
Use `net-backup --refresh-inventory`, `fleet.devices(force_refresh: true)`, or `fleet.plan_backup(force_refresh: true)` to bypass freshness. TFTP planning accepts the same keyword. A forced refresh also falls back to valid history if the remote request fails. `cache_enabled: false` disables caching. Injected clients stay uncached unless explicitly wrapped in `Netdisco::InventoryCache`; shared wrapper status describes the latest call, so synchronize concurrent access to the same instance. Cache locks bound waiting and coalesce ordinary expired-cache refreshes across processes; independently requested forced refreshes still query the source.
|
|
61
63
|
|
|
62
64
|
## CLI planning and execution
|
|
@@ -179,8 +181,12 @@ HTTP counts actual chunks and closes on excess. Injected `requester:` callbacks
|
|
|
179
181
|
|
|
180
182
|
`Settings#snapshot(mode:)` freezes public policy before side effects. CLI planning and execution share it; later calls can read new settings. Credentials remain dynamic per task. `Settings.new.for_run(mode: :backup)` shares policy across API calls; non-sensitive overrides preserve an existing snapshot. A custom `credentials:` resolver can supply per-device connection options, taking precedence over defaults. Injected clients remain caller-owned.
|
|
181
183
|
|
|
184
|
+
Snapshot backup, cache, log, TFTP verification, SSH known-hosts and explicit PostgreSQL CA certificate paths resolve against the working directory captured when the snapshot is created, including default paths and later relative overrides. Paths beginning with `~/` and the path `~` use the home directory captured by that snapshot. PostgreSQL CA paths retain libpq's literal path semantics without tilde expansion; `sslrootcert: system` keeps its system trust-store meaning, while `./system` names a file. Live settings continue to resolve paths against the current working directory and home directory.
|
|
185
|
+
|
|
182
186
|
Explicit host mappings, device rules, and vendor overrides take precedence over automatic detection. Comware plus a branded H3C model can correct stale vendor labels; H3C WX/AC models select the wireless connector.
|
|
183
187
|
|
|
188
|
+
Host and vendor overrides reject normalized aliases that select different connectors; aliases selecting the same connector can coexist. Required vendor labels must remain nonempty after normalization. Invalid rules fail before device connections begin.
|
|
189
|
+
|
|
184
190
|
## TFTP history and verification
|
|
185
191
|
|
|
186
192
|
Direct upload receipts only establish `device_reported`. Fleet can verify a local/mounted TFTP directory via `verification_root:`; the CLI accepts `--tftp-root DIR` or YAML `tftp.verification_root`. A verified receipt requires a current, nonempty regular file matching the actual receipt path, with SHA-256 evidence. Missing evidence prevents `verified` success. A mismatched receipt path is retained as device evidence, but cannot be verified or archived without a baseline for that locked target.
|
|
@@ -189,6 +195,8 @@ Verification holds a target lock across the pre-upload file-state/time baseline,
|
|
|
189
195
|
|
|
190
196
|
Batch examples enable history preservation. With an accessible server directory, uploads use hostname/IP names and are retained in both `<batch>/tftp/` and server `archive/<batch>/`; pre-existing same-name files are archived under `previous/` before upload. File-state and filesystem-time baselines are captured under the upload lock. Verification or archival failure preserves available receipts and committed paths without reporting success.
|
|
191
197
|
|
|
198
|
+
Path-lock cleanup failures after a completed local backup or TFTP archive retain the saved paths as `:saved_with_error` or `:reported_with_error`. An earlier device error remains the primary diagnostic; callbacks receive the retained outcome and commands are not replayed.
|
|
199
|
+
|
|
192
200
|
Without a server directory, batch examples add a batch identifier to names, retain `device_reported`, and cannot save local configuration copies. PAN-OS uses the fixed `running-config.xml` name, so the history-preserving example refuses its upload without local server access. Plans still prevent same-batch filename collisions.
|
|
193
201
|
|
|
194
202
|
Reports distinguish `verification`, `server_file_verified`, `local_file`, `server_archive_file`, `bytes`, and `sha256`. TFTP has no configuration `change` notification.
|
data/docs/netdisco.zh-CN.md
CHANGED
|
@@ -179,6 +179,10 @@ ssh:
|
|
|
179
179
|
|
|
180
180
|
每台任务开始时仍读取设备凭据,`Settings.from_file` 也保留轮换能力。纯策略快照不保存密码、API key 或凭据解析器。需要让多次 API 调用共用策略时,可传 `Settings.new.for_run(mode: :backup)`。自定义 `credentials:` 解析器仍可逐设备返回连接设置,它显式给出的选项优先于批次默认值,由调用方负责一致性;注入的 `client:` 生命周期也由调用方管理。传入 `plan:` 的执行不会重新拉取或筛选已批准清单。`fleet.devices` 可在不连接设备时检查映射。
|
|
181
181
|
|
|
182
|
+
快照中的备份、缓存、日志、TFTP 核验目录、SSH known-hosts 路径及显式 PostgreSQL CA 证书路径,以创建快照时的工作目录为基准,默认路径和后续相对路径覆盖也沿用这一基准。`~/` 开头的路径及 `~` 使用快照创建时的主目录。PostgreSQL CA 路径保留 libpq 的字面路径语义,不展开波浪号;`sslrootcert: system` 仍表示系统信任库,`./system` 则表示文件。未固定的活设置仍按调用时的当前工作目录和主目录解析路径。
|
|
183
|
+
|
|
184
|
+
地址和厂商覆盖规则规范化后,同一键不能指向不同连接器;指向同一连接器的别名可以并存。必需的厂商标签规范化后也不能为空。非法规则在连接设备前拒绝。
|
|
185
|
+
|
|
182
186
|
## 批量示例与进度
|
|
183
187
|
|
|
184
188
|
`examples/backup.rb` 和 `examples/backup_tftp.rb` 默认全量备份符合筛选条件的设备,并在终端原地刷新两行进度(登录、采集、排队、成功和未完成数,以及耗时、平均吞吐、预计剩余时间);失败单独输出,结束后显示汇总和报告路径。重定向时每 25 台输出一次进度,避免刷屏。`--verbose` 显示逐条登录和命令事件,`--json` 才在 STDOUT 输出 JSON;完整计划和结果始终保存在批次目录。`--no-progress` 关闭人类进度。百分比表示任务完成比例,不是成功率。
|
|
@@ -250,6 +254,8 @@ TFTP 的 `verification` 汇总与设备条目的 `server_file_verified` 区分
|
|
|
250
254
|
|
|
251
255
|
提供 `verification_root:` 时,即使不保留历史,TFTP 核验也在目标锁内采集上传前文件状态与文件系统时间、执行上传并检查指纹。未变化的旧文件不能凭未来时间戳获得服务器核验;服务器目录须可写以建立锁和时间探针。结果回调与最终报告采用核验后的结果,文件证据不覆盖设备操作或关闭故障。回执路径与锁定目标不一致时保留设备证据,但缺少该路径的上传前基线,不核验或归档旧文件。
|
|
252
256
|
|
|
257
|
+
本地备份或 TFTP 归档完成后若路径锁收尾失败,结果以 `:saved_with_error` 或 `:reported_with_error` 保留已保存路径。已有设备错误仍作为首个诊断,回调收到保留回执的结果,命令不自动重放。
|
|
258
|
+
|
|
253
259
|
## 清单缓存
|
|
254
260
|
|
|
255
261
|
HTTP 和 PostgreSQL 默认启用本地清单缓存,有效期三天(259200 秒),目录为当前工作目录下的 `.cache/net-connector/inventory`。YAML `netdisco.cache_directory`、`cache_ttl` 和 `cache_enabled` 分别控制目录、秒数和是否启用;TTL 须为有限正数,设置 `cache_enabled: false` 关闭缓存。
|
|
@@ -260,6 +266,8 @@ HTTP 和 PostgreSQL 默认启用本地清单缓存,有效期三天(259200
|
|
|
260
266
|
|
|
261
267
|
过期后自动刷新;远端请求失败时回退已验证历史,不更新原获取时间。配置错误、缺少凭据、中断和不安全文件不会被回退掩盖。缓存写入失败仍交付刚取得的新清单。`fleet.inventory_status` 提供 `:fresh_cache`、`:refreshed`、`:stale_cache`、`:uncached`、原始 `fetched_at` 和安全故障字段,CLI 对历史回退和缓存写入失败单独提示。
|
|
262
268
|
|
|
269
|
+
空文件或格式损坏的缓存按未命中处理,重新查询远端;查询失败时,这类文件不能作为回退历史。
|
|
270
|
+
|
|
263
271
|
强刷使用 `net-backup --refresh-inventory`、`fleet.devices(force_refresh: true)`、`fleet.plan_backup(force_refresh: true)`;TFTP 规划也支持此参数。强刷失败同样回退已验证历史。直接注入客户端时不自动缓存,可显式包裹 `Netdisco::InventoryCache`。一个包装器的状态描述最近调用,并发共享实例须由调用方同步。有界跨进程锁合并普通过期刷新,独立强刷请求各自访问远端。
|
|
264
272
|
|
|
265
273
|
## 配置批次与接口修正
|
data/docs/usage.md
CHANGED
|
@@ -39,6 +39,8 @@ Create and protect the parent directory first. `open` closes the session on retu
|
|
|
39
39
|
|
|
40
40
|
Writes use mode `0600`, file synchronization, atomic replacement, and parent-directory synchronization. A collection failure leaves the old file intact. Failure after replacement raises `BackupPersistenceError`; `error.backup` retains metadata and `error.receipt` distinguishes `:committed` from `:durable`. Unsupported directory synchronization is reported as `:backup_durability_unsupported`. Do not repeat the device operation to recover file finalization.
|
|
41
41
|
|
|
42
|
+
If path-lock cleanup fails after backup completes, `BackupFinalizeError` retains `error.backup` and a safe `error.underlying_type`. Its code is `:backup_finalize_failed`; it does not claim a new durability check for an unchanged file. Interruptions still propagate.
|
|
43
|
+
|
|
42
44
|
Private `.net-connector-<digest>.lock` files remain in the directory. All writers must follow the same locking protocol; ancestor directories remain the caller's responsibility.
|
|
43
45
|
|
|
44
46
|
For an in-memory configuration, use `device.running_config.value!`. The `Result` retains completed steps on failure. Collection suppresses configuration content in logs and error diagnostics, including debug/raw and application loggers; returned output and backup files remain sensitive.
|
data/docs/usage.zh-CN.md
CHANGED
|
@@ -43,6 +43,8 @@ end
|
|
|
43
43
|
|
|
44
44
|
写入使用 `0600` 临时文件、文件同步、原子替换及父目录同步。替换后同步或收尾失败会抛出 `BackupPersistenceError`,`error.backup` 保留已写文件的元数据,`error.receipt` 区分 `:committed`(持久性未确认)和 `:durable`(同步已完成)。目录同步不受支持对应 `:backup_durability_unsupported`,不会静默报告持久化成功,也不会自动重做设备采集。
|
|
45
45
|
|
|
46
|
+
备份完成后若路径锁收尾失败,`BackupFinalizeError` 保留 `error.backup` 和安全的 `error.underlying_type`,错误码为 `:backup_finalize_failed`;内容未变时不会声称重新验证过文件持久性,中断仍向调用方传播。
|
|
47
|
+
|
|
46
48
|
调用方应创建并保护备份目录,运行配置可能含有设备凭据。同目录下的 `.net-connector-<摘要>.lock` 是私有的长期锁文件,正常释放不删除它。所有写入者须使用本库的锁协议;`NOFOLLOW` 不保护被外部替换的祖先目录。锁名保守合并大小写及 Unicode 等价写法,实际备份文件名不变;在区分大小写的文件系统上,这些名称也会串行。
|
|
47
49
|
|
|
48
50
|
## 设备发起的 TFTP 备份
|
|
@@ -39,6 +39,19 @@ module Net
|
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# 备份已完成后的路径锁收尾错误;不把未变文件冒充本次完成 fsync 的写入回执。
|
|
43
|
+
class BackupFinalizeError < Error
|
|
44
|
+
attr_reader :backup, :underlying_type
|
|
45
|
+
|
|
46
|
+
def initialize(backup:, underlying:, host: nil)
|
|
47
|
+
raise ArgumentError, "backup must be a completed Backup" unless backup.instance_of?(Backup)
|
|
48
|
+
|
|
49
|
+
@backup = backup
|
|
50
|
+
@underlying_type = ErrorMetadata.type(underlying.class.name) || "StandardError"
|
|
51
|
+
super("backup completed; path lock finalization failed", code: :backup_finalize_failed, host: host, phase: :backup)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
42
55
|
# 先取得路径锁再采集和原子写入;提交后的收尾错误仍保留备份元数据。
|
|
43
56
|
class LocalBackup
|
|
44
57
|
# 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
|
|
@@ -56,8 +69,13 @@ module Net
|
|
|
56
69
|
|
|
57
70
|
# 采集配置、识别变更并原子写入私有文件。
|
|
58
71
|
def call(path:, lock_timeout: 0)
|
|
72
|
+
backup = nil
|
|
59
73
|
host = @device.host if @device.respond_to?(:host)
|
|
60
|
-
Storage::BackupLock.synchronize(path, timeout: lock_timeout, host: host) { |lock| collect_and_write(lock.path) }
|
|
74
|
+
Storage::BackupLock.synchronize(path, timeout: lock_timeout, host: host) { |lock| backup = collect_and_write(lock.path) }
|
|
75
|
+
rescue StandardError => error
|
|
76
|
+
raise unless backup
|
|
77
|
+
|
|
78
|
+
raise BackupFinalizeError.new(backup: backup, underlying: error, host: host), cause: nil
|
|
61
79
|
end
|
|
62
80
|
|
|
63
81
|
private
|
|
@@ -33,7 +33,7 @@ module Net
|
|
|
33
33
|
failure ||= error
|
|
34
34
|
end
|
|
35
35
|
# break 和非局部 return 跳过尾部返回值;收尾故障必须在展开作用域时交付。
|
|
36
|
-
raise failure if failure && (!completed || !failure.is_a?(StandardError))
|
|
36
|
+
raise failure, cause: failure.cause if failure && (!completed || !failure.is_a?(StandardError))
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
[value, failure]
|
|
@@ -95,8 +95,8 @@ module Net
|
|
|
95
95
|
|
|
96
96
|
start = match.begin(0).positive? ? output.rindex(/[\r\n]/, match.begin(0) - 1) : nil
|
|
97
97
|
start = start ? start + 1 : 0
|
|
98
|
-
finish = output.index(/[\r\n]/, match.end(0)) || output.
|
|
99
|
-
output
|
|
98
|
+
finish = output.index(/[\r\n]/, match.end(0)) || output.length
|
|
99
|
+
output[start...finish].strip
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
102
|
|
|
@@ -7,7 +7,7 @@ module Net
|
|
|
7
7
|
CONNECTOR_TYPES = %w[Error ConnectionError AuthenticationError LoginTimeout CommandTimeout WriteTimeout
|
|
8
8
|
PromptError ConnectionClosed TransportError DeviceError ScriptError InternalError
|
|
9
9
|
OutputLimitExceeded ScriptOutputLimitExceeded SessionBusy UnsupportedOperation LogError ParsingError BackupBusy
|
|
10
|
-
BackupPersistenceError TftpCompletionError].freeze
|
|
10
|
+
BackupPersistenceError BackupFinalizeError TftpCompletionError].freeze
|
|
11
11
|
TYPES = (CONNECTOR_TYPES.map { |name| "Net::Connector::#{name}" } +
|
|
12
12
|
%w[StandardError RuntimeError ArgumentError TypeError IOError EOFError SystemCallError ThreadError
|
|
13
13
|
RangeError KeyError IndexError NoMethodError NameError FrozenError NotImplementedError
|
|
@@ -67,25 +67,13 @@ module Net
|
|
|
67
67
|
if @operation_owner == [Thread.current, Fiber.current] && !@performing
|
|
68
68
|
return perform_locked(phase, &)
|
|
69
69
|
end
|
|
70
|
-
|
|
71
|
-
raise build_error(SessionBusy, "session already belongs to another operation", phase: phase), cause: nil
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
begin
|
|
75
|
-
perform_locked(phase, &)
|
|
76
|
-
ensure
|
|
77
|
-
@lock.unlock
|
|
78
|
-
end
|
|
70
|
+
with_session_lock(phase) { perform_locked(phase, &) }
|
|
79
71
|
end
|
|
80
72
|
|
|
81
73
|
# 业务操作在多次脚本之间持有租约;只有同一线程和 Fiber 可顺序使用。
|
|
82
74
|
def with_operation(phase)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
end
|
|
86
|
-
@operation_owner = [Thread.current, Fiber.current]
|
|
87
|
-
result = nil
|
|
88
|
-
begin
|
|
75
|
+
with_session_lock(phase, operation: true) do
|
|
76
|
+
result = nil
|
|
89
77
|
@log.with_operation_context(phase) { result = yield }
|
|
90
78
|
rescue Error => error
|
|
91
79
|
raise unless result.is_a?(Result)
|
|
@@ -93,9 +81,6 @@ module Net
|
|
|
93
81
|
# 日志收尾失败不能抹去已完成的设备步骤,也不能覆盖原业务错误。
|
|
94
82
|
Result.new(steps: result.steps, config: result.config,
|
|
95
83
|
error: result.error || normalize_error(error, phase: phase))
|
|
96
|
-
ensure
|
|
97
|
-
@operation_owner = nil
|
|
98
|
-
@lock.unlock
|
|
99
84
|
end
|
|
100
85
|
end
|
|
101
86
|
|
|
@@ -108,15 +93,7 @@ module Net
|
|
|
108
93
|
|
|
109
94
|
# 独占关闭会话;已有操作占用时返回会话繁忙错误。
|
|
110
95
|
def close
|
|
111
|
-
|
|
112
|
-
raise build_error(SessionBusy, "cannot close a session owned by another operation", phase: :close), cause: nil
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
begin
|
|
116
|
-
close_resources
|
|
117
|
-
ensure
|
|
118
|
-
@lock.unlock
|
|
119
|
-
end
|
|
96
|
+
with_session_lock(:close, busy_message: "cannot close a session owned by another operation") { close_resources }
|
|
120
97
|
end
|
|
121
98
|
|
|
122
99
|
# 执行特权认证,并更新提示符及特权状态。
|
|
@@ -234,11 +211,44 @@ module Net
|
|
|
234
211
|
|
|
235
212
|
private
|
|
236
213
|
|
|
214
|
+
# 锁取得与所有权登记不可拆开;只在交接及释放窗口延迟取消,业务等待仍可中断。
|
|
215
|
+
def with_session_lock(phase, operation: false, busy_message: "session already belongs to another operation")
|
|
216
|
+
acquired = false
|
|
217
|
+
failure = nil
|
|
218
|
+
begin
|
|
219
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
220
|
+
acquired = @lock.try_lock
|
|
221
|
+
@operation_owner = [Thread.current, Fiber.current] if acquired && operation
|
|
222
|
+
end
|
|
223
|
+
unless acquired
|
|
224
|
+
raise build_error(SessionBusy, busy_message, phase: phase), cause: nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
yield
|
|
228
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 解锁期间的二次取消不能覆盖原业务故障。
|
|
229
|
+
failure = error
|
|
230
|
+
raise
|
|
231
|
+
ensure
|
|
232
|
+
if acquired
|
|
233
|
+
begin
|
|
234
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
235
|
+
@operation_owner = nil if operation
|
|
236
|
+
@lock.unlock
|
|
237
|
+
end
|
|
238
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 完成释放后再交付取消,并避免原故障形成 cause 环。
|
|
239
|
+
error = failure || error
|
|
240
|
+
raise error, cause: error.cause
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
237
246
|
# 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
|
|
238
247
|
def perform_locked(phase)
|
|
239
|
-
@performing = true
|
|
240
248
|
completed = false
|
|
249
|
+
failure = nil
|
|
241
250
|
begin
|
|
251
|
+
@performing = true
|
|
242
252
|
unless connected?
|
|
243
253
|
close_resources unless state == :closed
|
|
244
254
|
connect_session
|
|
@@ -251,15 +261,34 @@ module Net
|
|
|
251
261
|
failure = normalize_error(error, phase: phase)
|
|
252
262
|
close_preserving_failure
|
|
253
263
|
raise failure, cause: nil
|
|
254
|
-
rescue Exception # rubocop:disable Lint/RescueException -- 中断先释放传输资源,再继续向外传播。
|
|
264
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 中断先释放传输资源,再继续向外传播。
|
|
265
|
+
failure = error
|
|
255
266
|
close_preserving_failure
|
|
256
267
|
raise
|
|
257
268
|
ensure
|
|
269
|
+
finish_perform(completed, failure)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# 资源查询和关闭仍可取消;不论它们如何退出,都先清除执行标记再交付首个故障。
|
|
274
|
+
def finish_perform(completed, failure)
|
|
275
|
+
begin
|
|
258
276
|
# throw/catch 也会离开此块;对话未完成时必须关闭连接,不能把
|
|
259
277
|
# 半途的设备提示符误当成下一条命令的响应。
|
|
260
278
|
close_preserving_failure unless completed || !connected?
|
|
261
|
-
|
|
262
|
-
|
|
279
|
+
connected = connected?
|
|
280
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 收尾取消不能覆盖已记录的操作故障。
|
|
281
|
+
failure ||= error
|
|
282
|
+
ensure
|
|
283
|
+
begin
|
|
284
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
285
|
+
@state = :ready if connected
|
|
286
|
+
@performing = false
|
|
287
|
+
end
|
|
288
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 状态登记完成后才交付二次取消。
|
|
289
|
+
failure ||= error
|
|
290
|
+
end
|
|
291
|
+
raise failure, cause: failure.cause if failure
|
|
263
292
|
end
|
|
264
293
|
end
|
|
265
294
|
|
|
@@ -164,7 +164,8 @@ module Net
|
|
|
164
164
|
|
|
165
165
|
# 删除行尾空白,写出当前行并重置光标。
|
|
166
166
|
def emit_line(newline:)
|
|
167
|
-
|
|
167
|
+
# 其他 ASCII 空白控制字节已由 consume_control 消费,缓冲区只需裁剪空格和制表符。
|
|
168
|
+
@target.write(utf8_safe(@line.rstrip))
|
|
168
169
|
@target.write("\n") if newline
|
|
169
170
|
@line.clear
|
|
170
171
|
@cursor = 0
|
|
@@ -52,6 +52,7 @@ module Net
|
|
|
52
52
|
Thread.handle_interrupt(Exception => :never) do
|
|
53
53
|
threads << Thread.new do
|
|
54
54
|
failure = nil
|
|
55
|
+
exhausted = false
|
|
55
56
|
begin
|
|
56
57
|
# 全部线程成功建立后才允许设备操作,创建失败不能留下已发送的变更。
|
|
57
58
|
Thread.handle_interrupt(Exception => :immediate) do
|
|
@@ -60,10 +61,13 @@ module Net
|
|
|
60
61
|
index, item = task
|
|
61
62
|
results[index] = yield item
|
|
62
63
|
end
|
|
64
|
+
exhausted = true
|
|
63
65
|
end
|
|
64
66
|
rescue Exception => error # rubocop:disable Lint/RescueException -- 将中断同步交给调用线程,避免全局 abort_on_exception 波及其他线程。
|
|
65
67
|
failure = error
|
|
66
68
|
ensure
|
|
69
|
+
# Thread.exit/kill 不经过 rescue;不能把尚未完成的任务伪装成合法 nil 结果。
|
|
70
|
+
failure ||= RuntimeError.new("execution worker stopped before completing its tasks") unless exhausted
|
|
67
71
|
finished << [Thread.current, failure]
|
|
68
72
|
end
|
|
69
73
|
end
|
|
@@ -73,7 +77,7 @@ module Net
|
|
|
73
77
|
count.times do
|
|
74
78
|
thread, failure = finished.pop
|
|
75
79
|
thread.join
|
|
76
|
-
raise failure if failure
|
|
80
|
+
raise failure, cause: failure.cause if failure
|
|
77
81
|
end
|
|
78
82
|
completed = true
|
|
79
83
|
rescue Exception => error # rubocop:disable Lint/RescueException -- 记录取消或原任务故障,收尾中再次取消不能覆盖它。
|
|
@@ -102,7 +106,8 @@ module Net
|
|
|
102
106
|
end
|
|
103
107
|
rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复交付时保留原故障,二次取消不改变批次失败原因。
|
|
104
108
|
# 二次取消可能已以原故障为 cause;不能反向关联形成异常原因环。
|
|
105
|
-
|
|
109
|
+
error = primary_failure || error
|
|
110
|
+
raise error, cause: error.cause
|
|
106
111
|
end
|
|
107
112
|
end
|
|
108
113
|
end
|
|
@@ -52,6 +52,8 @@ module Net
|
|
|
52
52
|
if error.instance_of?(BackupPersistenceError) && backup.instance_of?(Backup) && error.backup.equal?(backup)
|
|
53
53
|
{ artifact_state: error.receipt.state, artifact_phase: error.receipt.phase,
|
|
54
54
|
underlying_type: error.underlying_type }
|
|
55
|
+
elsif error.instance_of?(BackupFinalizeError) && backup.instance_of?(Backup) && error.backup.equal?(backup)
|
|
56
|
+
{ artifact_phase: :finalize, underlying_type: error.underlying_type }
|
|
55
57
|
elsif error.instance_of?(TftpCompletionError) && backup.instance_of?(TftpReceipt) && error.receipt.equal?(backup)
|
|
56
58
|
{ artifact_state: :reported_uploaded, artifact_phase: error.code == :transfer_finalize_failed ? :finalize : :path,
|
|
57
59
|
verification: :device_reported, underlying_type: error.underlying_type }
|
|
@@ -246,14 +246,20 @@ module Net
|
|
|
246
246
|
|
|
247
247
|
# 执行单台设备的本地配置采集。
|
|
248
248
|
def backup_device(device, directory, log_directory, settings_snapshot, filename_style: :ip)
|
|
249
|
+
outcome = nil
|
|
249
250
|
destination = File.join(directory, device.backup_filename(style: filename_style))
|
|
250
251
|
Storage::BackupLock.synchronize(destination, host: device.host) do |path_lock|
|
|
251
252
|
Storage::SafeFile.open(destination, missing: true) { |_file, _stat| nil }
|
|
252
|
-
run_device(device, log_directory, settings_snapshot, success_status: :backed_up,
|
|
253
|
-
|
|
253
|
+
outcome = run_device(device, log_directory, settings_snapshot, success_status: :backed_up,
|
|
254
|
+
partial_status: :saved_with_error, backup_class: Backup) do |connector|
|
|
254
255
|
path_lock.with_delegated_lock { connector.backup(path: destination) }
|
|
255
256
|
end
|
|
256
257
|
end
|
|
258
|
+
rescue StandardError => error
|
|
259
|
+
raise unless outcome
|
|
260
|
+
return outcome unless outcome.success?
|
|
261
|
+
|
|
262
|
+
build_outcome(device, :saved_with_error, backup: outcome.backup, error: error)
|
|
257
263
|
end
|
|
258
264
|
|
|
259
265
|
# 隔离单台设备的凭据、连接、操作和关闭异常。
|
|
@@ -290,7 +296,8 @@ module Net
|
|
|
290
296
|
result
|
|
291
297
|
end
|
|
292
298
|
if failure && backup.nil?
|
|
293
|
-
if backup_class == Backup &&
|
|
299
|
+
if backup_class == Backup && [BackupPersistenceError, BackupFinalizeError].any? { |type| failure.instance_of?(type) } &&
|
|
300
|
+
failure.backup.instance_of?(Backup)
|
|
294
301
|
backup = failure.backup
|
|
295
302
|
elsif backup_class == TftpReceipt && failure.instance_of?(TftpCompletionError) && failure.receipt.instance_of?(TftpReceipt)
|
|
296
303
|
backup = failure.receipt
|
|
@@ -111,7 +111,7 @@ module Net
|
|
|
111
111
|
return nil if stat.size > @max_inventory_bytes
|
|
112
112
|
|
|
113
113
|
body = file.read(@max_inventory_bytes + 1)
|
|
114
|
-
return nil if body.bytesize > @max_inventory_bytes
|
|
114
|
+
return nil if body.nil? || body.bytesize > @max_inventory_bytes
|
|
115
115
|
|
|
116
116
|
entry = JSON.parse(body)
|
|
117
117
|
unless entry.is_a?(Hash) && entry.keys.sort == %w[fetched_at identity rows schema] &&
|
|
@@ -161,15 +161,21 @@ module Net
|
|
|
161
161
|
|
|
162
162
|
bytes = 0
|
|
163
163
|
rows.map do |row|
|
|
164
|
-
unless row.is_a?(Hash) && row["ip"].is_a?(String) && !row["ip"].strip.empty?
|
|
165
|
-
Client::FIELDS.all? { |field| row[field].nil? || row[field].is_a?(String) }
|
|
164
|
+
unless row.is_a?(Hash) && row["ip"].is_a?(String) && !row["ip"].strip.empty?
|
|
166
165
|
raise InvalidCache
|
|
167
166
|
end
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
# 一次遍历完成白名单、限额与复制,避免中间 Hash 和逐字段求和产生的数组。
|
|
168
|
+
Client::FIELDS.each_with_object({}) do |field, selected|
|
|
169
|
+
next unless row.key?(field)
|
|
171
170
|
|
|
172
|
-
|
|
171
|
+
value = row[field]
|
|
172
|
+
raise InvalidCache unless value.nil? || value.is_a?(String)
|
|
173
|
+
|
|
174
|
+
bytes += field.bytesize + (value&.bytesize || 0)
|
|
175
|
+
raise InvalidCache if bytes > @max_inventory_bytes
|
|
176
|
+
|
|
177
|
+
selected[field] = value&.dup&.freeze
|
|
178
|
+
end.freeze
|
|
173
179
|
end.freeze
|
|
174
180
|
end
|
|
175
181
|
|
|
@@ -39,15 +39,20 @@ module Net
|
|
|
39
39
|
unless vendor_overrides.is_a?(Hash)
|
|
40
40
|
raise ArgumentError, "vendor_overrides must be a Hash"
|
|
41
41
|
end
|
|
42
|
-
vendor_overrides.
|
|
42
|
+
vendor_overrides.each_with_object({}) do |(label, vendor), mapping|
|
|
43
43
|
unless label.is_a?(String) && vendor.is_a?(String) && !label.empty?
|
|
44
44
|
raise ArgumentError, "vendor_overrides must map labels to connector names"
|
|
45
45
|
end
|
|
46
46
|
key = normalize(label)
|
|
47
|
+
raise ArgumentError, "vendor_overrides labels must remain nonempty after normalization" if key.empty?
|
|
48
|
+
|
|
47
49
|
value = vendor.to_sym
|
|
48
50
|
raise ArgumentError, "vendor_overrides contains an unsupported connector" unless Net::Connector.vendors.include?(value)
|
|
51
|
+
if mapping.key?(key) && mapping[key] != value
|
|
52
|
+
raise ArgumentError, "vendor_overrides contains conflicting normalized labels"
|
|
53
|
+
end
|
|
49
54
|
|
|
50
|
-
[key
|
|
55
|
+
mapping[key] = value
|
|
51
56
|
end.freeze
|
|
52
57
|
end
|
|
53
58
|
|
|
@@ -55,19 +60,22 @@ module Net
|
|
|
55
60
|
def host_mapping(host_overrides)
|
|
56
61
|
raise ArgumentError, "host_overrides must be a Hash" unless host_overrides.is_a?(Hash)
|
|
57
62
|
|
|
58
|
-
host_overrides.
|
|
63
|
+
host_overrides.each_with_object({}) do |(host, vendor), mapping|
|
|
59
64
|
unless host.is_a?(String) && !host.include?("/") && vendor.is_a?(String)
|
|
60
65
|
raise ArgumentError, "host_overrides must map IP addresses to connector names"
|
|
61
66
|
end
|
|
62
67
|
begin
|
|
63
68
|
address = IPAddr.new(host).to_s
|
|
64
69
|
rescue IPAddr::Error
|
|
65
|
-
raise ArgumentError, "host_overrides contains an invalid IP address"
|
|
70
|
+
raise ArgumentError, "host_overrides contains an invalid IP address", cause: nil
|
|
66
71
|
end
|
|
67
72
|
connector = vendor.to_sym
|
|
68
73
|
raise ArgumentError, "host_overrides contains an unsupported connector" unless Net::Connector.vendors.include?(connector)
|
|
74
|
+
if mapping.key?(address) && mapping[address] != connector
|
|
75
|
+
raise ArgumentError, "host_overrides contains conflicting normalized IP addresses"
|
|
76
|
+
end
|
|
69
77
|
|
|
70
|
-
[address
|
|
78
|
+
mapping[address] = connector
|
|
71
79
|
end.freeze
|
|
72
80
|
end
|
|
73
81
|
|
|
@@ -84,8 +92,10 @@ module Net
|
|
|
84
92
|
end
|
|
85
93
|
connector = rule.fetch("connector").to_sym
|
|
86
94
|
raise ArgumentError, "mapping contains an unsupported connector" unless Net::Connector.vendors.include?(connector)
|
|
95
|
+
vendor = normalize(rule.fetch("vendor"))
|
|
96
|
+
raise ArgumentError, "mapping vendor must remain nonempty after normalization" if vendor.empty?
|
|
87
97
|
|
|
88
|
-
{ vendor:
|
|
98
|
+
{ vendor: vendor, os: rule["os"] && normalize(rule["os"]),
|
|
89
99
|
model_prefix: rule["model_prefix"] && normalize(rule["model_prefix"]), connector: connector }.freeze
|
|
90
100
|
end.freeze
|
|
91
101
|
end
|
|
@@ -153,7 +163,7 @@ module Net
|
|
|
153
163
|
|
|
154
164
|
[IPAddr.new(value).to_s.freeze, true]
|
|
155
165
|
rescue IPAddr::Error
|
|
156
|
-
raise ArgumentError, "host list entries must be IP addresses"
|
|
166
|
+
raise ArgumentError, "host list entries must be IP addresses", cause: nil
|
|
157
167
|
end.freeze
|
|
158
168
|
end
|
|
159
169
|
|
|
@@ -37,9 +37,11 @@ module Net
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# 分开保存默认策略、显式覆盖与凭据来源;fixed 实例只承载已复制的公开快照。
|
|
40
|
-
def initialize(env: ENV, defaults: {}, overrides: {}, policy: nil, fixed: false)
|
|
40
|
+
def initialize(env: ENV, defaults: {}, overrides: {}, policy: nil, fixed: false, path_context: nil)
|
|
41
41
|
@env, @defaults, @overrides = env, defaults, overrides
|
|
42
42
|
@policy, @fixed = policy, fixed
|
|
43
|
+
# 路径上下文只在内部固定策略之间传递,不属于用户配置或凭据来源。
|
|
44
|
+
@path_context = (path_context || [Dir.pwd, Dir.home]).map { |path| path.dup.freeze }.freeze if fixed
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
# 调用入口补充运行目录等非敏感覆盖,凭据仍从同一来源读取。
|
|
@@ -47,7 +49,7 @@ module Net
|
|
|
47
49
|
if @fixed
|
|
48
50
|
public_values = @env.merge(@overrides).merge(values).slice(*non_secret_keys)
|
|
49
51
|
.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
50
|
-
return self.class.new(env: public_values, fixed: true).freeze
|
|
52
|
+
return self.class.new(env: public_values, fixed: true, path_context: @path_context).freeze
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
policy = @policy.with_overrides(values) if @policy
|
|
@@ -150,7 +152,7 @@ module Net
|
|
|
150
152
|
unless directory.is_a?(String) && !directory.empty? && !directory.include?("\0")
|
|
151
153
|
raise ArgumentError, "netdisco.cache_directory must be a nonempty path without NUL"
|
|
152
154
|
end
|
|
153
|
-
{ directory:
|
|
155
|
+
{ directory: absolute_path(directory), ttl: number("ND_CACHE_TTL", InventoryCache::DEFAULT_TTL),
|
|
154
156
|
enabled: boolean("ND_CACHE_ENABLED", true) }.freeze
|
|
155
157
|
end
|
|
156
158
|
|
|
@@ -169,7 +171,7 @@ module Net
|
|
|
169
171
|
path = raw("NC_BACKUP_DIRECTORY", DEFAULT_BACKUP_DIRECTORY)
|
|
170
172
|
raise ArgumentError, "NC_BACKUP_DIRECTORY must be nonempty" unless present?(path)
|
|
171
173
|
|
|
172
|
-
|
|
174
|
+
absolute_path(path)
|
|
173
175
|
end
|
|
174
176
|
|
|
175
177
|
# 与 Worker 共用并发上限,非法值在任务与目录创建前失败。
|
|
@@ -226,11 +228,13 @@ module Net
|
|
|
226
228
|
# 本机核验根目录属于策略快照,解析路径不证明文件已经上传。
|
|
227
229
|
def tftp_root
|
|
228
230
|
path = value("NC_TFTP_ROOT")
|
|
229
|
-
|
|
231
|
+
absolute_path(path) if path
|
|
230
232
|
end
|
|
231
233
|
|
|
232
234
|
# 端口、TLS 与连接超时进入固定策略;主机、数据库名和登录凭据仍从环境读取。
|
|
233
235
|
def database_options
|
|
236
|
+
return @policy.database_options if @policy
|
|
237
|
+
|
|
234
238
|
options = {}
|
|
235
239
|
%i[port connect_timeout].each do |name|
|
|
236
240
|
key = "ND_DB_#{name.to_s.upcase}"
|
|
@@ -242,14 +246,20 @@ module Net
|
|
|
242
246
|
item = value("ND_DB_#{name.to_s.upcase}")
|
|
243
247
|
options[name] = item if item
|
|
244
248
|
end
|
|
245
|
-
DatabaseClient.connection_policy(**options)
|
|
249
|
+
options = DatabaseClient.connection_policy(**options)
|
|
250
|
+
# libpq 的 system 是系统信任库标记;其他显式路径按字面解析,不展开波浪号。
|
|
251
|
+
if @fixed && options[:sslrootcert] && options[:sslrootcert] != "system"
|
|
252
|
+
options = options.merge(sslrootcert: File.absolute_path(options[:sslrootcert], @path_context.first).freeze).freeze
|
|
253
|
+
end
|
|
254
|
+
options
|
|
246
255
|
end
|
|
247
256
|
|
|
248
257
|
# 经 Configuration 复用连接校验;厂商协议覆盖通用协议,不包含设备凭据。
|
|
249
258
|
def connection_options(vendor = nil)
|
|
259
|
+
known_hosts = value("NC_KNOWN_HOSTS")
|
|
250
260
|
configuration = Configuration.new(
|
|
251
261
|
protocol: vendor_protocols[vendor.to_s] || value("NC_PROTOCOL") || "ssh",
|
|
252
|
-
log_level: value("NC_LOG_LEVEL") || "info", known_hosts:
|
|
262
|
+
log_level: value("NC_LOG_LEVEL") || "info", known_hosts: known_hosts && absolute_path(known_hosts),
|
|
253
263
|
host_key_policy: value("NC_HOST_KEY_POLICY") || "strict",
|
|
254
264
|
max_script_output_bytes: optional_integer("NC_MAX_SCRIPT_OUTPUT_BYTES")
|
|
255
265
|
)
|
|
@@ -281,7 +291,7 @@ module Net
|
|
|
281
291
|
# 会话日志目录属于公开策略;调用方和批次入口负责实际创建。
|
|
282
292
|
def log_directory
|
|
283
293
|
path = value("NC_LOG_DIRECTORY")
|
|
284
|
-
|
|
294
|
+
absolute_path(path) if path
|
|
285
295
|
end
|
|
286
296
|
|
|
287
297
|
# 生成已验证的无凭据配置摘要,不展开原环境或 resolver。
|
|
@@ -326,6 +336,20 @@ module Net
|
|
|
326
336
|
@defaults.fetch(key, default)
|
|
327
337
|
end
|
|
328
338
|
|
|
339
|
+
# 固定策略及其覆盖共用快照的工作目录和主目录;活设置仍按调用时的环境解析。
|
|
340
|
+
def absolute_path(path)
|
|
341
|
+
return @policy.absolute_path(path) if @policy
|
|
342
|
+
return File.expand_path(path) unless @fixed
|
|
343
|
+
|
|
344
|
+
if path == "~" || path.start_with?("~/")
|
|
345
|
+
home = @path_context.last
|
|
346
|
+
raise ArgumentError, "non-absolute home" unless home.start_with?(File::SEPARATOR)
|
|
347
|
+
|
|
348
|
+
path = home + path.delete_prefix("~")
|
|
349
|
+
end
|
|
350
|
+
File.expand_path(path, @path_context.first)
|
|
351
|
+
end
|
|
352
|
+
|
|
329
353
|
private
|
|
330
354
|
|
|
331
355
|
# 身份指纹只用于隔离缓存;凭据、SQL 和参数均不写入缓存文件或摘要。
|
|
@@ -55,6 +55,7 @@ module Net
|
|
|
55
55
|
|
|
56
56
|
# 上传与归档在同一目标锁内完成,后续失败仍保留可确认的上传回执和提交路径。
|
|
57
57
|
def upload_and_archive(device, started_at:)
|
|
58
|
+
outcome = nil
|
|
58
59
|
raise Unavailable.new(host: device.host) if device.vendor == :palo_alto && !@server_root
|
|
59
60
|
|
|
60
61
|
remote_filename = upload_filename(device)
|
|
@@ -66,14 +67,22 @@ module Net
|
|
|
66
67
|
upload_started_at = server_timestamp
|
|
67
68
|
outcome = yield remote_filename
|
|
68
69
|
receipt = outcome.backup
|
|
69
|
-
|
|
70
|
+
next outcome unless receipt.is_a?(TftpReceipt) && receipt.path
|
|
70
71
|
|
|
71
72
|
verified = @verifier.verify_outcome(outcome.with(started_at: started_at), started_at: upload_started_at,
|
|
72
73
|
previous: previous, expected_path: remote_filename)
|
|
73
|
-
|
|
74
|
+
unless verified.backup.verification == :server_verified
|
|
75
|
+
outcome = outcome.success? ? archive_failure(outcome) : outcome
|
|
76
|
+
next outcome
|
|
77
|
+
end
|
|
74
78
|
|
|
75
|
-
archive_uploaded_file(verified)
|
|
79
|
+
outcome = archive_uploaded_file(verified)
|
|
76
80
|
end
|
|
81
|
+
rescue StandardError => error
|
|
82
|
+
raise unless outcome
|
|
83
|
+
|
|
84
|
+
# 目标锁的关闭发生在块返回之后;保存最新回执,不能丢失已完成的归档路径。
|
|
85
|
+
outcome.success? ? archive_failure(outcome, persistence_error: error) : outcome
|
|
77
86
|
end
|
|
78
87
|
|
|
79
88
|
private
|
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.10.
|
|
4
|
+
version: 0.10.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- net-connector contributors
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-16 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: expect-pty
|