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