netstack 0.1.1 → 0.2.0
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 +22 -3
- data/{LICENSE.txt → LICENSE} +7 -6
- data/NOTICE +25 -0
- data/README.md +58 -20
- data/docs/acceptance.md +92 -0
- data/docs/backups.md +100 -0
- data/docs/configuration.md +198 -0
- data/docs/connector.md +187 -0
- data/docs/ddi.md +142 -0
- data/docs/deployment.md +47 -0
- data/docs/foundations.md +45 -0
- data/docs/implementation.md +68 -0
- data/docs/inventory.md +42 -0
- data/docs/monitoring.md +227 -0
- data/docs/motor_integration.md +96 -0
- data/docs/performance.md +39 -0
- data/docs/release.md +20 -0
- data/docs/vxlan.md +91 -0
- data/docs/wireless.md +81 -0
- data/lib/netstack/backups/artifact.rb +23 -0
- data/lib/netstack/backups/diff.rb +15 -0
- data/lib/netstack/backups/repository.rb +77 -0
- data/lib/netstack/backups/request.rb +78 -0
- data/lib/netstack/circuits/business_calendar.rb +51 -0
- data/lib/netstack/circuits/capacity_review.rb +191 -0
- data/lib/netstack/circuits/endpoint.rb +25 -0
- data/lib/netstack/circuits/monitoring_plan.rb +212 -0
- data/lib/netstack/configuration/artifact.rb +85 -0
- data/lib/netstack/configuration/artifact_resolver.rb +30 -0
- data/lib/netstack/configuration/bootstrap/payload.rb +67 -0
- data/lib/netstack/configuration/bootstrap/validator.rb +224 -0
- data/lib/netstack/configuration/catalog.rb +60 -0
- data/lib/netstack/configuration/generator.rb +87 -0
- data/lib/netstack/configuration/input_contract.rb +82 -0
- data/lib/netstack/configuration/payload.rb +168 -0
- data/lib/netstack/configuration/reader.rb +124 -0
- data/lib/netstack/configuration/support.rb +30 -0
- data/lib/netstack/configuration/system_configuration.rb +56 -0
- data/lib/netstack/configuration/template_manager.rb +174 -0
- data/lib/netstack/configuration/template_store.rb +40 -0
- data/lib/netstack/configuration/validator.rb +566 -0
- data/lib/netstack/connector/authentication.rb +77 -0
- data/lib/netstack/connector/authentication_error.rb +11 -0
- data/lib/netstack/connector/backup.rb +19 -0
- data/lib/netstack/connector/backup_busy.rb +11 -0
- data/lib/netstack/connector/backup_persistence_error.rb +32 -0
- data/lib/netstack/connector/command.rb +63 -0
- data/lib/netstack/connector/command_result.rb +24 -0
- data/lib/netstack/connector/command_timeout.rb +11 -0
- data/lib/netstack/connector/configuration.rb +138 -0
- data/lib/netstack/connector/connection_closed.rb +11 -0
- data/lib/netstack/connector/connection_error.rb +11 -0
- data/lib/netstack/connector/device.rb +284 -0
- data/lib/netstack/connector/device_error.rb +11 -0
- data/lib/netstack/connector/dialogue.rb +55 -0
- data/lib/netstack/connector/error.rb +44 -0
- data/lib/netstack/connector/error_metadata.rb +58 -0
- data/lib/netstack/connector/event.rb +24 -0
- data/lib/netstack/connector/execution.rb +110 -0
- data/lib/netstack/connector/interaction.rb +55 -0
- data/lib/netstack/connector/internal_error.rb +11 -0
- data/lib/netstack/connector/known_hosts.rb +128 -0
- data/lib/netstack/connector/local_backup.rb +63 -0
- data/lib/netstack/connector/log/event.rb +65 -0
- data/lib/netstack/connector/log/formatter.rb +18 -0
- data/lib/netstack/connector/log/messages.rb +54 -0
- data/lib/netstack/connector/log/redacting_writer.rb +33 -0
- data/lib/netstack/connector/log/transcript.rb +35 -0
- data/lib/netstack/connector/log.rb +262 -0
- data/lib/netstack/connector/log_error.rb +11 -0
- data/lib/netstack/connector/login_timeout.rb +11 -0
- data/lib/netstack/connector/output_limit_exceeded.rb +11 -0
- data/lib/netstack/connector/parsing_error.rb +11 -0
- data/lib/netstack/connector/profile/builder.rb +238 -0
- data/lib/netstack/connector/profile.rb +225 -0
- data/lib/netstack/connector/prompt_error.rb +11 -0
- data/lib/netstack/connector/recovery.rb +59 -0
- data/lib/netstack/connector/redactor.rb +100 -0
- data/lib/netstack/connector/response.rb +25 -0
- data/lib/netstack/connector/response_reader.rb +140 -0
- data/lib/netstack/connector/result.rb +50 -0
- data/lib/netstack/connector/running_config/rendered.rb +16 -0
- data/lib/netstack/connector/running_config/strategy.rb +28 -0
- data/lib/netstack/connector/running_config.rb +113 -0
- data/lib/netstack/connector/save_config.rb +23 -0
- data/lib/netstack/connector/script.rb +59 -0
- data/lib/netstack/connector/script_error.rb +11 -0
- data/lib/netstack/connector/script_output_limit_exceeded.rb +11 -0
- data/lib/netstack/connector/session.rb +414 -0
- data/lib/netstack/connector/session_busy.rb +11 -0
- data/lib/netstack/connector/storage/backup_lock.rb +117 -0
- data/lib/netstack/connector/storage/private_file.rb +111 -0
- data/lib/netstack/connector/storage/safe_file.rb +64 -0
- data/lib/netstack/connector/storage/saved_config.rb +76 -0
- data/lib/netstack/connector/terminal_renderer.rb +186 -0
- data/lib/netstack/connector/terminal_text.rb +32 -0
- data/lib/netstack/connector/textfsm.rb +71 -0
- data/lib/netstack/connector/tftp/file_upload.rb +53 -0
- data/lib/netstack/connector/tftp/strategy.rb +67 -0
- data/lib/netstack/connector/tftp.rb +116 -0
- data/lib/netstack/connector/tftp_completion_error.rb +36 -0
- data/lib/netstack/connector/tftp_receipt.rb +54 -0
- data/lib/netstack/connector/tftp_target.rb +65 -0
- data/lib/netstack/connector/topology/checkpoint_error.rb +16 -0
- data/lib/netstack/connector/topology/deployment_adapter.rb +163 -0
- data/lib/netstack/connector/topology/execution_result.rb +34 -0
- data/lib/netstack/connector/topology/immediate_strategy.rb +39 -0
- data/lib/netstack/connector/topology/interface_description.rb +46 -0
- data/lib/netstack/connector/topology/interface_name.rb +60 -0
- data/lib/netstack/connector/topology/plan.rb +105 -0
- data/lib/netstack/connector/topology/strategy.rb +76 -0
- data/lib/netstack/connector/topology.rb +255 -0
- data/lib/netstack/connector/transport_error.rb +11 -0
- data/lib/netstack/connector/transports.rb +201 -0
- data/lib/netstack/connector/underlying_error.rb +25 -0
- data/lib/netstack/connector/unsupported_operation.rb +11 -0
- data/lib/netstack/connector/vendors/cisco_ios/running_config.rb +22 -0
- data/lib/netstack/connector/vendors/cisco_ios/tftp_backup.rb +41 -0
- data/lib/netstack/connector/vendors/cisco_ios/topology.rb +54 -0
- data/lib/netstack/connector/vendors/cisco_ios.rb +44 -0
- data/lib/netstack/connector/vendors/cisco_nxos/running_config.rb +25 -0
- data/lib/netstack/connector/vendors/cisco_nxos/tftp_backup.rb +43 -0
- data/lib/netstack/connector/vendors/cisco_nxos.rb +51 -0
- data/lib/netstack/connector/vendors/h3c/tftp_backup.rb +34 -0
- data/lib/netstack/connector/vendors/h3c/topology.rb +72 -0
- data/lib/netstack/connector/vendors/h3c.rb +75 -0
- data/lib/netstack/connector/vendors/h3c_wireless.rb +13 -0
- data/lib/netstack/connector/vendors/hillstone/running_config.rb +16 -0
- data/lib/netstack/connector/vendors/hillstone/tftp_backup.rb +55 -0
- data/lib/netstack/connector/vendors/hillstone/topology.rb +64 -0
- data/lib/netstack/connector/vendors/hillstone.rb +39 -0
- data/lib/netstack/connector/vendors/huawei/tftp_backup.rb +26 -0
- data/lib/netstack/connector/vendors/huawei.rb +62 -0
- data/lib/netstack/connector/vendors/palo_alto/running_config.rb +77 -0
- data/lib/netstack/connector/vendors/palo_alto/tftp_backup.rb +49 -0
- data/lib/netstack/connector/vendors/palo_alto/topology.rb +77 -0
- data/lib/netstack/connector/vendors/palo_alto.rb +39 -0
- data/lib/netstack/connector/vendors/radware/running_config.rb +21 -0
- data/lib/netstack/connector/vendors/radware/tftp_backup.rb +49 -0
- data/lib/netstack/connector/vendors/radware/topology.rb +19 -0
- data/lib/netstack/connector/vendors/radware.rb +59 -0
- data/lib/netstack/connector/write_timeout.rb +11 -0
- data/lib/netstack/connector.rb +32 -0
- data/lib/netstack/ddi/applier.rb +107 -0
- data/lib/netstack/ddi/deployment.rb +137 -0
- data/lib/netstack/ddi/inputs.rb +61 -0
- data/lib/netstack/ddi/manager.rb +197 -0
- data/lib/netstack/ddi/plan.rb +99 -0
- data/lib/netstack/ddi/scope.rb +127 -0
- data/lib/netstack/ddi/settings.rb +44 -0
- data/lib/netstack/ddi/state.rb +166 -0
- data/lib/netstack/deployment/checkpoint_error.rb +14 -0
- data/lib/netstack/deployment/cli_adapter.rb +64 -0
- data/lib/netstack/deployment/executor.rb +131 -0
- data/lib/netstack/deployment/plan.rb +82 -0
- data/lib/netstack/deployment/receipt.rb +78 -0
- data/lib/netstack/deployment/recovery.rb +19 -0
- data/lib/netstack/deployment/step.rb +16 -0
- data/lib/netstack/dimension.rb +30 -0
- data/lib/netstack/error.rb +14 -0
- data/lib/netstack/input_error.rb +9 -0
- data/lib/netstack/integrations/bluecat/client.rb +84 -0
- data/lib/netstack/integrations/http/client.rb +146 -0
- data/lib/netstack/integrations/http/error.rb +10 -0
- data/lib/netstack/integrations/http/response.rb +28 -0
- data/lib/netstack/integrations/http_delivery/adapter.rb +51 -0
- data/lib/netstack/integrations/http_delivery/client.rb +127 -0
- data/lib/netstack/integrations/http_delivery/result.rb +27 -0
- data/lib/netstack/integrations/oxidized/client.rb +89 -0
- data/lib/netstack/integrations/zabbix/client.rb +211 -0
- data/lib/netstack/integrations/zabbix/error.rb +10 -0
- data/lib/netstack/inventory/address.rb +30 -0
- data/lib/netstack/inventory/changes.rb +13 -0
- data/lib/netstack/inventory/cli_parser.rb +75 -0
- data/lib/netstack/inventory/collector.rb +99 -0
- data/lib/netstack/inventory/device.rb +14 -0
- data/lib/netstack/inventory/interface.rb +25 -0
- data/lib/netstack/inventory/neighbor.rb +15 -0
- data/lib/netstack/inventory/reconciler.rb +74 -0
- data/lib/netstack/inventory/snapshot.rb +69 -0
- data/lib/netstack/inventory/transceiver.rb +23 -0
- data/lib/netstack/ipv4.rb +204 -0
- data/lib/netstack/ipv6.rb +88 -0
- data/lib/netstack/monitoring/applier.rb +179 -0
- data/lib/netstack/monitoring/device_plan.rb +23 -0
- data/lib/netstack/monitoring/host_plan.rb +152 -0
- data/lib/netstack/monitoring/inputs.rb +73 -0
- data/lib/netstack/monitoring/measurements.rb +89 -0
- data/lib/netstack/monitoring/mutation.rb +37 -0
- data/lib/netstack/monitoring/plan.rb +69 -0
- data/lib/netstack/monitoring/probe_plan.rb +19 -0
- data/lib/netstack/monitoring/state_comparison.rb +37 -0
- data/lib/netstack/range_set.rb +181 -0
- data/lib/netstack/routing/lookup.rb +46 -0
- data/lib/netstack/routing/lookup_result.rb +14 -0
- data/lib/netstack/routing/route.rb +32 -0
- data/lib/netstack/routing/snapshot.rb +23 -0
- data/lib/netstack/target.rb +15 -0
- data/lib/netstack/unsupported_capability.rb +9 -0
- data/lib/netstack/values.rb +70 -0
- data/lib/netstack/version.rb +1 -1
- data/lib/netstack/vxlan/auditor.rb +187 -0
- data/lib/netstack/vxlan/cisco_nxos/config_parser.rb +327 -0
- data/lib/netstack/vxlan/cisco_nxos/deployment_adapter.rb +63 -0
- data/lib/netstack/vxlan/cisco_nxos/runtime_parser.rb +182 -0
- data/lib/netstack/vxlan/cisco_nxos/segment_renderer.rb +42 -0
- data/lib/netstack/vxlan/collector.rb +77 -0
- data/lib/netstack/vxlan/fabric.rb +29 -0
- data/lib/netstack/vxlan/finding.rb +20 -0
- data/lib/netstack/vxlan/node.rb +51 -0
- data/lib/netstack/vxlan/route_policy.rb +57 -0
- data/lib/netstack/vxlan/runtime_result.rb +24 -0
- data/lib/netstack/vxlan/segment.rb +35 -0
- data/lib/netstack/vxlan/segment_plan.rb +25 -0
- data/lib/netstack/vxlan/segment_planner.rb +156 -0
- data/lib/netstack/vxlan/snapshot.rb +34 -0
- data/lib/netstack/vxlan/validation.rb +58 -0
- data/lib/netstack/vxlan/verifier.rb +52 -0
- data/lib/netstack/vxlan/vpc_domain.rb +20 -0
- data/lib/netstack/wireless/access_point.rb +12 -0
- data/lib/netstack/wireless/ap_group.rb +12 -0
- data/lib/netstack/wireless/assessment.rb +13 -0
- data/lib/netstack/wireless/assessor.rb +83 -0
- data/lib/netstack/wireless/authentication_connection.rb +12 -0
- data/lib/netstack/wireless/bss.rb +12 -0
- data/lib/netstack/wireless/client.rb +12 -0
- data/lib/netstack/wireless/collector.rb +191 -0
- data/lib/netstack/wireless/configuration.rb +13 -0
- data/lib/netstack/wireless/finding.rb +12 -0
- data/lib/netstack/wireless/h3c/config_parser.rb +229 -0
- data/lib/netstack/wireless/h3c/lldp_parser.rb +69 -0
- data/lib/netstack/wireless/h3c/runtime_parser.rb +240 -0
- data/lib/netstack/wireless/huawei/runtime_parser.rb +83 -0
- data/lib/netstack/wireless/invalid_snapshot.rb +11 -0
- data/lib/netstack/wireless/neighbor.rb +12 -0
- data/lib/netstack/wireless/normalizer.rb +90 -0
- data/lib/netstack/wireless/parse_result.rb +13 -0
- data/lib/netstack/wireless/radio.rb +12 -0
- data/lib/netstack/wireless/reconciler.rb +115 -0
- data/lib/netstack/wireless/reconciliation.rb +11 -0
- data/lib/netstack/wireless/security_policy.rb +12 -0
- data/lib/netstack/wireless/service_template.rb +12 -0
- data/lib/netstack/wireless/service_template_bindings.rb +56 -0
- data/lib/netstack/wireless/snapshot.rb +150 -0
- data/lib/netstack/wireless/state.rb +23 -0
- data/lib/netstack/wireless/view.rb +154 -0
- data/lib/netstack.rb +10 -3
- data/resources/configuration/cisco-bootstrap.erb +118 -0
- data/resources/configuration/cisco-distributed-gateway.erb +40 -0
- data/resources/configuration/cisco-n9k-vpc.erb +62 -0
- data/resources/configuration/cisco-server-port.erb +43 -0
- data/resources/configuration/h3c-bootstrap.erb +108 -0
- data/resources/configuration/h3c-irf.erb +57 -0
- data/resources/configuration/h3c-server-port.erb +36 -0
- data/resources/configuration/hillstone-ipsec-vpn.erb +68 -0
- data/resources/configuration/hillstone-policy.erb +31 -0
- data/resources/configuration/paloalto-ipsec-vpn.erb +39 -0
- data/resources/configuration/radware-load-balancer.erb +26 -0
- data/resources/textfsm/cisco_cdp_neighbors_detail.textfsm +7 -0
- data/resources/textfsm/cisco_ios_running_config_interfaces.textfsm +12 -0
- data/resources/textfsm/cisco_ios_show_ip_interface_brief.textfsm +7 -0
- data/resources/textfsm/h3c_interface_descriptions.textfsm +11 -0
- data/resources/textfsm/h3c_lldp_local_first.textfsm +8 -0
- data/resources/textfsm/h3c_lldp_name_first.textfsm +8 -0
- data/resources/textfsm/hillstone_interface_descriptions.textfsm +11 -0
- data/resources/textfsm/hillstone_lldp_neighbors.textfsm +7 -0
- data/resources/textfsm/index +8 -0
- data/resources/textfsm/palo_alto_interface_descriptions.textfsm +6 -0
- data/resources/textfsm/palo_alto_lldp_neighbors.textfsm +11 -0
- data/resources/textfsm/radware_port_names.textfsm +13 -0
- metadata +588 -27
- data/.rubocop.yml +0 -13
- data/CODE_OF_CONDUCT.md +0 -84
- data/Gemfile +0 -10
- data/Gemfile.lock +0 -40
- data/Rakefile +0 -8
- data/bin/console +0 -15
- data/bin/setup +0 -8
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Netstack
|
|
8
|
+
module Connector
|
|
9
|
+
module Storage
|
|
10
|
+
# 路径锁在设备采集之前取得;锁文件长期保留,避免等待者落在已经删除的 inode 上。
|
|
11
|
+
class BackupLock
|
|
12
|
+
STORAGE_KEY = :net_connector_backup_delegation
|
|
13
|
+
private_constant :STORAGE_KEY
|
|
14
|
+
|
|
15
|
+
attr_reader :path
|
|
16
|
+
|
|
17
|
+
def self.lock_path(path)
|
|
18
|
+
destination = File.expand_path(path)
|
|
19
|
+
directory = File.realpath(File.dirname(destination))
|
|
20
|
+
# 锁名先折叠 Unicode 与大小写,避免大小写不敏感文件系统上的别名绕过互斥。
|
|
21
|
+
# 在区分大小写的文件系统上可能保守地合并两个锁,但不会改写目标文件名。
|
|
22
|
+
filename = File.basename(destination).b.force_encoding(Encoding::UTF_8)
|
|
23
|
+
filename = filename.valid_encoding? ? filename.unicode_normalize(:nfc).downcase(:fold).unicode_normalize(:nfc) : filename.b.downcase
|
|
24
|
+
File.join(directory, ".net-connector-#{Digest::SHA256.hexdigest(filename)}.lock")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.synchronize(path, **options, &) = new(path, **options).synchronize(&)
|
|
28
|
+
|
|
29
|
+
def initialize(path, timeout: 0, host: nil)
|
|
30
|
+
raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
|
|
31
|
+
unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout >= 0 && timeout.to_f.finite?
|
|
32
|
+
raise ArgumentError, "lock_timeout must be a nonnegative finite number"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@path = File.expand_path(path).freeze
|
|
36
|
+
@lock_path = self.class.lock_path(@path).freeze
|
|
37
|
+
@timeout, @host = timeout.to_f, host
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def synchronize
|
|
41
|
+
# Fleet 只向下层备份借用一次;消费后清除,采集回调中的递归备份仍须竞争锁。
|
|
42
|
+
# Thread#[] 是 Fiber 局部存储;fork 后不能消费父进程的借用记录。
|
|
43
|
+
if Thread.current[STORAGE_KEY] == [@lock_path, Process.pid]
|
|
44
|
+
Thread.current[STORAGE_KEY] = nil
|
|
45
|
+
return yield self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
file = nil
|
|
49
|
+
owned = false
|
|
50
|
+
begin
|
|
51
|
+
file = open_lock
|
|
52
|
+
verify_lock!(file)
|
|
53
|
+
acquire(file)
|
|
54
|
+
verify_lock!(file)
|
|
55
|
+
owned = true
|
|
56
|
+
@owner = [Process.pid, Fiber.current]
|
|
57
|
+
yield self
|
|
58
|
+
ensure
|
|
59
|
+
@owner = nil if owned
|
|
60
|
+
file&.close
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# 仅持有实际 flock 的同一 Fiber 可授权一次内层采集,避免把路径锁变成任意可重入锁。
|
|
65
|
+
def with_delegated_lock
|
|
66
|
+
raise ThreadError, "backup path delegation requires lock ownership" unless @owner == [Process.pid, Fiber.current]
|
|
67
|
+
|
|
68
|
+
previous = Thread.current[STORAGE_KEY]
|
|
69
|
+
begin
|
|
70
|
+
Thread.current[STORAGE_KEY] = [@lock_path, Process.pid]
|
|
71
|
+
yield
|
|
72
|
+
ensure
|
|
73
|
+
Thread.current[STORAGE_KEY] = previous
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def open_lock
|
|
80
|
+
File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600)
|
|
81
|
+
rescue Errno::ELOOP, Errno::EISDIR, Errno::ENXIO
|
|
82
|
+
raise ArgumentError, "backup lock must be a private regular file", cause: nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def verify_lock!(file)
|
|
86
|
+
stat = file.stat
|
|
87
|
+
unless stat.file? && stat.uid == Process.euid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
88
|
+
raise ArgumentError, "backup lock must be an owned regular file with mode 0600 and one link"
|
|
89
|
+
end
|
|
90
|
+
current = File.lstat(@lock_path)
|
|
91
|
+
unless current.file? && current.dev == stat.dev && current.ino == stat.ino
|
|
92
|
+
raise ArgumentError, "backup lock path changed while acquiring ownership"
|
|
93
|
+
end
|
|
94
|
+
rescue Errno::ENOENT
|
|
95
|
+
raise ArgumentError, "backup lock path changed while acquiring ownership", cause: nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def acquire(file)
|
|
99
|
+
deadline = monotonic + @timeout
|
|
100
|
+
loop do
|
|
101
|
+
acquired = file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
102
|
+
return if acquired && (@timeout.zero? || monotonic < deadline)
|
|
103
|
+
|
|
104
|
+
remaining = deadline - monotonic
|
|
105
|
+
if remaining <= 0
|
|
106
|
+
raise BackupBusy.new("backup path already belongs to another operation", host: @host, phase: :backup), cause: nil
|
|
107
|
+
end
|
|
108
|
+
wait([remaining, 0.05].min)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
|
+
def wait(seconds) = sleep(seconds)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Netstack
|
|
8
|
+
module Connector
|
|
9
|
+
module Storage
|
|
10
|
+
# 文件同步和目录项同步分别记录;原子可见不等于断电后仍能恢复新目录项。
|
|
11
|
+
module PrivateFile
|
|
12
|
+
Receipt = Data.define(:path, :state, :phase) do
|
|
13
|
+
def initialize(path:, state:, phase:)
|
|
14
|
+
unless path.is_a?(String) && !path.empty? && %i[not_committed committed durable].include?(state) &&
|
|
15
|
+
%i[resolve_parent temporary_file write file_sync rename directory_open directory_sync cleanup complete].include?(phase)
|
|
16
|
+
raise ArgumentError, "invalid file write receipt"
|
|
17
|
+
end
|
|
18
|
+
super(path: path.dup.freeze, state: state, phase: phase)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def committed? = state != :not_committed
|
|
22
|
+
def durable? = state == :durable
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class WriteError < IOError
|
|
26
|
+
attr_reader :receipt, :code, :underlying_type
|
|
27
|
+
|
|
28
|
+
def initialize(receipt:, underlying_type:)
|
|
29
|
+
raise ArgumentError, "receipt must be a file write receipt" unless receipt.instance_of?(Receipt)
|
|
30
|
+
|
|
31
|
+
@receipt = receipt
|
|
32
|
+
@underlying_type = underlying_type.dup.freeze
|
|
33
|
+
@code, message = if receipt.durable?
|
|
34
|
+
[:file_finalize_failed, "file replacement is durable; finalization failed"]
|
|
35
|
+
elsif receipt.committed?
|
|
36
|
+
[:file_persistence_unconfirmed, "file replacement is committed; durability is unconfirmed"]
|
|
37
|
+
else
|
|
38
|
+
[:file_write_failed, "file write failed before replacement was committed"]
|
|
39
|
+
end
|
|
40
|
+
super(message)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inspect = "#<#{self.class} state=#{receipt.state} phase=#{receipt.phase}>"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class PersistenceError < WriteError; end
|
|
47
|
+
|
|
48
|
+
class DirectorySyncUnsupported < WriteError
|
|
49
|
+
def initialize(**options)
|
|
50
|
+
super
|
|
51
|
+
@code = :directory_sync_unsupported
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def message = "file replacement is committed; directory synchronization is unsupported"
|
|
55
|
+
alias to_s message
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
UNSUPPORTED_DIRECTORY_SYNC = [Errno::EINVAL, Errno::ENOSYS, Errno::ENOTSUP, Errno::EOPNOTSUPP,
|
|
59
|
+
NotImplementedError].uniq.freeze
|
|
60
|
+
private_constant :UNSUPPORTED_DIRECTORY_SYNC
|
|
61
|
+
|
|
62
|
+
# 外部子类可重写消息或回执;只有本写入器定义的具体错误类型可提供完成事实。
|
|
63
|
+
def self.receipt_error?(error)
|
|
64
|
+
[WriteError, PersistenceError, DirectorySyncUnsupported].any? { |type| error.instance_of?(type) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# 统一返回写入回执;失败时从异常 receipt 判断目录项是否已经替换。
|
|
68
|
+
def self.write(path, contents)
|
|
69
|
+
raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
|
|
70
|
+
|
|
71
|
+
receipt = Receipt.new(path: File.expand_path(path), state: :not_committed, phase: :resolve_parent)
|
|
72
|
+
directory = File.realpath(File.dirname(receipt.path))
|
|
73
|
+
destination = File.join(directory, File.basename(receipt.path))
|
|
74
|
+
receipt = receipt.with(phase: :temporary_file)
|
|
75
|
+
Tempfile.create([".net-connector-", ".tmp"], directory) do |file|
|
|
76
|
+
file.binmode
|
|
77
|
+
file.chmod(0o600)
|
|
78
|
+
receipt = receipt.with(phase: :write)
|
|
79
|
+
file.write(contents)
|
|
80
|
+
receipt = receipt.with(phase: :file_sync)
|
|
81
|
+
file.flush
|
|
82
|
+
file.fsync
|
|
83
|
+
receipt = receipt.with(phase: :rename)
|
|
84
|
+
File.rename(file.path, destination)
|
|
85
|
+
receipt = receipt.with(state: :committed, phase: :directory_open)
|
|
86
|
+
File.open(directory, File::RDONLY | File::NOFOLLOW) do |parent|
|
|
87
|
+
raise IOError, "parent is not a directory" unless parent.stat.directory?
|
|
88
|
+
|
|
89
|
+
receipt = receipt.with(phase: :directory_sync)
|
|
90
|
+
parent.fsync
|
|
91
|
+
receipt = receipt.with(state: :durable, phase: :cleanup)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
receipt.with(phase: :complete)
|
|
95
|
+
rescue StandardError, NotImplementedError => error
|
|
96
|
+
raise unless receipt
|
|
97
|
+
|
|
98
|
+
unsupported = receipt.phase == :directory_sync && UNSUPPORTED_DIRECTORY_SYNC.any? { |type| error.is_a?(type) }
|
|
99
|
+
klass = if unsupported
|
|
100
|
+
DirectorySyncUnsupported
|
|
101
|
+
elsif receipt.committed?
|
|
102
|
+
PersistenceError
|
|
103
|
+
else
|
|
104
|
+
WriteError
|
|
105
|
+
end
|
|
106
|
+
raise klass.new(receipt: receipt, underlying_type: error.class.name.to_s), cause: nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Netstack
|
|
8
|
+
module Connector
|
|
9
|
+
module Storage
|
|
10
|
+
# 对同一 FD 做类型检查和读取;NOFOLLOW 只保护最后一个路径分量,目录由调用方保护。
|
|
11
|
+
module SafeFile
|
|
12
|
+
Fingerprint = Data.define(:path, :sha256, :mode, :dev, :ino)
|
|
13
|
+
|
|
14
|
+
def self.open(path, missing: false, replace_symlink: false)
|
|
15
|
+
file = open_handle(path, missing: missing, replace_symlink: replace_symlink)
|
|
16
|
+
return nil unless file
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
stat = file.stat
|
|
20
|
+
raise ArgumentError, "saved configuration is not a regular file" unless stat.file?
|
|
21
|
+
|
|
22
|
+
file.binmode
|
|
23
|
+
yield file, stat
|
|
24
|
+
ensure
|
|
25
|
+
file.close
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.fingerprint(path, **options)
|
|
30
|
+
self.open(path, **options) { |file, stat| fingerprint_io(path, file, stat) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.fingerprint_io(path, file, stat)
|
|
34
|
+
digest = Digest::SHA256.new
|
|
35
|
+
while (chunk = file.read(64 * 1024))
|
|
36
|
+
digest << chunk
|
|
37
|
+
end
|
|
38
|
+
Fingerprint.new(path: path.dup.freeze, sha256: digest.hexdigest.freeze,
|
|
39
|
+
mode: stat.mode & 0o7777, dev: stat.dev, ino: stat.ino)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 未变内容可保留 mtime,但不能把已替换的目录项误认成刚读取的旧文件。
|
|
43
|
+
def self.same_entry?(path, fingerprint)
|
|
44
|
+
stat = File.lstat(path)
|
|
45
|
+
stat.file? && stat.dev == fingerprint.dev && stat.ino == fingerprint.ino
|
|
46
|
+
rescue Errno::ENOENT
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.open_handle(path, missing:, replace_symlink:)
|
|
51
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
52
|
+
rescue Errno::ENOENT
|
|
53
|
+
raise unless missing
|
|
54
|
+
rescue Errno::ELOOP
|
|
55
|
+
raise ArgumentError, "saved configuration is not a regular file", cause: nil unless replace_symlink
|
|
56
|
+
rescue Errno::ENXIO
|
|
57
|
+
raise ArgumentError, "saved configuration is not a regular file", cause: nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private_class_method :open_handle
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "ipaddr"
|
|
7
|
+
|
|
8
|
+
module Netstack
|
|
9
|
+
module Connector
|
|
10
|
+
module Storage
|
|
11
|
+
# 直接导出已有本地配置,无需访问清单服务。
|
|
12
|
+
class SavedConfig
|
|
13
|
+
# 只用规范化的管理地址标识配置,避免设备改名改变备份身份。
|
|
14
|
+
def self.filename(host)
|
|
15
|
+
raise ArgumentError, "host must be an IP address" unless host.is_a?(String) && !host.include?("/")
|
|
16
|
+
|
|
17
|
+
"#{IPAddr.new(host).to_s.tr(":", "_")}.txt"
|
|
18
|
+
rescue IPAddr::Error
|
|
19
|
+
raise ArgumentError, "host must be an IP address"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 只读取规范设备地址对应的文件,不维护第二套历史文件命名。
|
|
23
|
+
def initialize(directory:)
|
|
24
|
+
raise ArgumentError, "directory must be a nonempty String" unless directory.is_a?(String) && !directory.empty?
|
|
25
|
+
|
|
26
|
+
@directory = File.expand_path(directory).freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 从同一已验证文件描述符取得路径或内容。
|
|
30
|
+
def find(host, required: true)
|
|
31
|
+
with_file(host, required: required) { |path, _file, _stat| path }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 比较基线也从经过 fstat 的同一 FD 读取,不在 find 后重新按路径打开。
|
|
35
|
+
def fingerprint(host, required: true)
|
|
36
|
+
with_file(host, required: required) { |path, file, stat| SafeFile.fingerprint_io(path, file, stat) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def read(host)
|
|
40
|
+
with_file(host) { |_path, file, _stat| file.read }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def with_file(host, required: true)
|
|
44
|
+
path = File.join(@directory, self.class.filename(host))
|
|
45
|
+
SafeFile.open(path, missing: !required) { |file, stat| yield path, file, stat }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private :with_file
|
|
49
|
+
|
|
50
|
+
# 将已保存配置写入目标文件或标准输出。
|
|
51
|
+
def export(host:, output: nil, io: $stdout)
|
|
52
|
+
contents = read(host)
|
|
53
|
+
if output
|
|
54
|
+
raise ArgumentError, "output must be a nonempty String" unless output.is_a?(String) && !output.empty?
|
|
55
|
+
|
|
56
|
+
destination = File.expand_path(output)
|
|
57
|
+
directory = File.dirname(destination)
|
|
58
|
+
FileUtils.mkdir_p(directory, mode: 0o700)
|
|
59
|
+
PrivateFile.write(destination, contents).path
|
|
60
|
+
else
|
|
61
|
+
io.binmode if io.respond_to?(:binmode)
|
|
62
|
+
io.write(contents)
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# 使用指定模板解析已有配置,无需访问设备或清单服务。
|
|
68
|
+
def parse(host:, template:, template_dir: nil)
|
|
69
|
+
|
|
70
|
+
parser = TextFSM.new(template_dir: template_dir)
|
|
71
|
+
parser.call(read(host), template: template, host: host)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
require "stringio"
|
|
6
|
+
|
|
7
|
+
module Netstack
|
|
8
|
+
module Connector
|
|
9
|
+
# 把终端编辑控制符渲染成可读、兼容 UTF-8 和二进制的逐行日志。
|
|
10
|
+
class TerminalRenderer
|
|
11
|
+
# 将字节流完整渲染为字符串,供测试和一次性转换使用。
|
|
12
|
+
def self.render(input, strict_utf8: false)
|
|
13
|
+
output = StringIO.new("".b)
|
|
14
|
+
renderer = new(output, strict_utf8: strict_utf8)
|
|
15
|
+
renderer.write(input)
|
|
16
|
+
renderer.finish
|
|
17
|
+
output.string
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# 初始化当前行、光标和转义序列状态。
|
|
21
|
+
def initialize(target, max_line_bytes: 32 * 1024 * 1024, strict_utf8: false)
|
|
22
|
+
raise ArgumentError, "target must respond to write" unless target.respond_to?(:write)
|
|
23
|
+
unless max_line_bytes.is_a?(Integer) && max_line_bytes.positive?
|
|
24
|
+
raise ArgumentError, "max_line_bytes must be positive"
|
|
25
|
+
end
|
|
26
|
+
raise ArgumentError, "strict_utf8 must be true or false" unless [true, false].include?(strict_utf8)
|
|
27
|
+
|
|
28
|
+
@target = target
|
|
29
|
+
@line = "".b
|
|
30
|
+
@cursor = 0
|
|
31
|
+
@escape_state = nil
|
|
32
|
+
@csi_parameters = "".b
|
|
33
|
+
@max_line_bytes = max_line_bytes
|
|
34
|
+
@strict_utf8 = strict_utf8
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# 普通文本成段写入;控制序列仍逐字节解析,保留跨分片状态。
|
|
38
|
+
def write(input)
|
|
39
|
+
bytes = input.to_s.b
|
|
40
|
+
offset = 0
|
|
41
|
+
while offset < bytes.bytesize
|
|
42
|
+
byte = bytes.getbyte(offset)
|
|
43
|
+
if @escape_state || (byte < 32 && byte != 9)
|
|
44
|
+
consume(byte)
|
|
45
|
+
offset += 1
|
|
46
|
+
else
|
|
47
|
+
boundary = bytes.index(/[\x00-\x08\x0a-\x1f]/n, offset) || bytes.bytesize
|
|
48
|
+
write_text(bytes.byteslice(offset, boundary - offset))
|
|
49
|
+
offset = boundary
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
bytes.bytesize
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 刷新底层日志目标。
|
|
56
|
+
def flush
|
|
57
|
+
@target.flush if @target.respond_to?(:flush)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 写出尚未换行的尾行并刷新目标。
|
|
61
|
+
def finish
|
|
62
|
+
emit_line(newline: false) unless @line.empty?
|
|
63
|
+
flush
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# 处理普通控制字符、换行、回车和转义序列起始符。
|
|
69
|
+
def consume(byte)
|
|
70
|
+
return consume_escape(byte) if @escape_state
|
|
71
|
+
|
|
72
|
+
case byte
|
|
73
|
+
when 7 then nil
|
|
74
|
+
when 8 then @cursor = [@cursor - 1, 0].max
|
|
75
|
+
when 10 then emit_line(newline: true)
|
|
76
|
+
when 13 then @cursor = 0
|
|
77
|
+
when 27 then @escape_state = :escape
|
|
78
|
+
else write_byte(byte) if byte == 9 || byte >= 32
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 根据转义序列阶段继续解析终端控制字节。
|
|
83
|
+
def consume_escape(byte)
|
|
84
|
+
case @escape_state
|
|
85
|
+
when :escape
|
|
86
|
+
start_escape_sequence(byte)
|
|
87
|
+
when :csi
|
|
88
|
+
finish_csi(byte)
|
|
89
|
+
when :osc
|
|
90
|
+
@escape_state = nil if byte == 7
|
|
91
|
+
@escape_state = :osc_escape if byte == 27
|
|
92
|
+
when :osc_escape
|
|
93
|
+
@escape_state = (byte == 92) ? nil : :osc
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# 识别 CSI 或 OSC 序列,并忽略未知的单字节序列。
|
|
98
|
+
def start_escape_sequence(byte)
|
|
99
|
+
case byte
|
|
100
|
+
when 91
|
|
101
|
+
@csi_parameters.clear
|
|
102
|
+
@escape_state = :csi
|
|
103
|
+
when 93 then @escape_state = :osc
|
|
104
|
+
else
|
|
105
|
+
@escape_state = nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# 收集 CSI 参数并在终止字节到达时执行光标操作。
|
|
110
|
+
def finish_csi(byte)
|
|
111
|
+
if byte.between?(0x40, 0x7e)
|
|
112
|
+
apply_csi(byte)
|
|
113
|
+
@escape_state = nil
|
|
114
|
+
elsif byte.between?(0x20, 0x3f)
|
|
115
|
+
raise OutputLimitExceeded, "terminal escape sequence is too long" if @csi_parameters.bytesize >= 64
|
|
116
|
+
|
|
117
|
+
@csi_parameters << byte
|
|
118
|
+
else
|
|
119
|
+
@escape_state = nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# 应用有限范围内的光标移动和整行擦除控制。
|
|
124
|
+
def apply_csi(command)
|
|
125
|
+
count = Integer(@csi_parameters[/\d+/, 0] || "1", 10)
|
|
126
|
+
case command
|
|
127
|
+
when 67 then @cursor += count
|
|
128
|
+
when 68 then @cursor = [@cursor - count, 0].max
|
|
129
|
+
when 71 then @cursor = [count - 1, 0].max
|
|
130
|
+
when 75 then erase_line(Integer(@csi_parameters[/\d+/, 0] || "0", 10))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# 按终端擦除模式修改当前行缓冲区。
|
|
135
|
+
def erase_line(mode)
|
|
136
|
+
case mode
|
|
137
|
+
when 0 then @line = @line.byteslice(0, @cursor)
|
|
138
|
+
when 1
|
|
139
|
+
@line = (" ".b * [@cursor + 1, @line.bytesize].min) + @line.byteslice((@cursor + 1)..).to_s
|
|
140
|
+
when 2
|
|
141
|
+
@line.clear
|
|
142
|
+
@cursor = 0
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# 在光标位置写入字节,并限制单行缓冲大小。
|
|
147
|
+
def write_byte(byte)
|
|
148
|
+
raise OutputLimitExceeded, "terminal line exceeded max_line_bytes" if @cursor >= @max_line_bytes
|
|
149
|
+
|
|
150
|
+
@line << (" " * (@cursor - @line.bytesize)) if @cursor > @line.bytesize
|
|
151
|
+
(@cursor < @line.bytesize) ? @line.setbyte(@cursor, byte) : @line << byte
|
|
152
|
+
@cursor += 1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# 只合并无控制符的字节;超限时沿用逐字节路径,保留失败前已接受的前缀。
|
|
156
|
+
def write_text(text)
|
|
157
|
+
return text.each_byte { |byte| write_byte(byte) } if @cursor + text.bytesize > @max_line_bytes
|
|
158
|
+
|
|
159
|
+
@line << (" " * (@cursor - @line.bytesize)) if @cursor > @line.bytesize
|
|
160
|
+
if @cursor == @line.bytesize
|
|
161
|
+
@line << text
|
|
162
|
+
else
|
|
163
|
+
@line[@cursor, text.bytesize] = text
|
|
164
|
+
end
|
|
165
|
+
@cursor += text.bytesize
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# 删除行尾空白,写出当前行并重置光标。
|
|
169
|
+
def emit_line(newline:)
|
|
170
|
+
@target.write(utf8_safe(@line.sub(/[ \t]+\z/n, "")))
|
|
171
|
+
@target.write("\n") if newline
|
|
172
|
+
@line.clear
|
|
173
|
+
@cursor = 0
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# 日志把非法字节转义;解析必须拒绝终端编辑产生的损坏字节,不能制造可写业务证据。
|
|
177
|
+
def utf8_safe(line)
|
|
178
|
+
text = line.dup.force_encoding(Encoding::UTF_8)
|
|
179
|
+
return text.b if text.valid_encoding?
|
|
180
|
+
raise Encoding::InvalidByteSequenceError, "terminal editing produced invalid UTF-8" if @strict_utf8
|
|
181
|
+
|
|
182
|
+
text.scrub { |invalid| invalid.bytes.map { |byte| format("\\x%02X", byte) }.join }.b
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
module Netstack
|
|
6
|
+
module Connector
|
|
7
|
+
# 设备/文件提供的是字节,不受 Ruby 源文件编码控制。解析边界只接受 UTF-8,
|
|
8
|
+
# 不猜测编码、不改写原文,也不让终端编辑把非法字节抹掉后变成可信证据。
|
|
9
|
+
module TerminalText
|
|
10
|
+
def self.utf8(bytes, host: nil)
|
|
11
|
+
raise ArgumentError, "text must be a String" unless bytes.is_a?(String)
|
|
12
|
+
|
|
13
|
+
text = bytes.b.force_encoding(Encoding::UTF_8)
|
|
14
|
+
invalid_encoding!(host) unless text.valid_encoding?
|
|
15
|
+
text
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.render(bytes, host: nil)
|
|
19
|
+
TerminalRenderer.render(utf8(bytes, host: host), strict_utf8: true).force_encoding(Encoding::UTF_8)
|
|
20
|
+
rescue Encoding::InvalidByteSequenceError
|
|
21
|
+
invalid_encoding!(host)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.invalid_encoding!(host)
|
|
25
|
+
raise ParsingError.new("device output is not valid UTF-8", code: :invalid_output_encoding,
|
|
26
|
+
host: host, phase: :parse), cause: nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private_class_method :invalid_encoding!
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from net-connector; copyright (c) 2026 net-connector contributors (MIT).
|
|
4
|
+
|
|
5
|
+
module Netstack
|
|
6
|
+
module Connector
|
|
7
|
+
# 将设备命令回显或配置文本按 TextFSM 模板转换为结构化记录。
|
|
8
|
+
class TextFSM
|
|
9
|
+
# 设备入口与功能实现共置,Device 只组合能力,不重复业务流程。
|
|
10
|
+
module Capability
|
|
11
|
+
# 执行命令并按厂商、命令或显式 TextFSM 模板返回结构化记录。
|
|
12
|
+
def parse_command(command, template: nil, template_dir: nil)
|
|
13
|
+
parser = TextFSM.new(template_dir: template_dir)
|
|
14
|
+
parser.call(execute_command(command).value!, template: template, vendor: vendor, command: command, host: host)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# 采集运行配置并使用指定 TextFSM 模板提取结构化记录。
|
|
18
|
+
def parse_config(template:, template_dir: nil)
|
|
19
|
+
parser = TextFSM.new(template_dir: template_dir)
|
|
20
|
+
parser.call(running_config.value!, template: template, host: host)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
DEFAULT_TEMPLATE_DIR = File.expand_path("../../../resources/textfsm", __dir__).freeze
|
|
25
|
+
|
|
26
|
+
# 配置模板目录和 Netmiko 风格的命令索引文件。
|
|
27
|
+
def initialize(template_dir: nil, index: "index")
|
|
28
|
+
template_dir ||= DEFAULT_TEMPLATE_DIR
|
|
29
|
+
raise ArgumentError, "template_dir must be a nonempty String" unless template_dir.is_a?(String) && !template_dir.empty?
|
|
30
|
+
raise ArgumentError, "index must be a nonempty String" unless index.is_a?(String) && !index.empty?
|
|
31
|
+
|
|
32
|
+
@template_dir = File.expand_path(template_dir)
|
|
33
|
+
@index = index
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 显式模板按路径读取;未指定时按厂商和命令从索引选择模板。
|
|
37
|
+
def call(text, template: nil, vendor: nil, command: nil, host: nil)
|
|
38
|
+
require "textfsm"
|
|
39
|
+
|
|
40
|
+
text = TerminalText.render(text, host: host)
|
|
41
|
+
if template
|
|
42
|
+
raise ArgumentError, "template must be a nonempty String" unless template.is_a?(String) && !template.empty?
|
|
43
|
+
|
|
44
|
+
::TextFSM::Parser.from_file(File.expand_path(template, @template_dir)).parse_hashes(text)
|
|
45
|
+
else
|
|
46
|
+
raise ArgumentError, "vendor and command are required for template lookup" unless vendor && command.is_a?(String) && !command.empty?
|
|
47
|
+
|
|
48
|
+
attributes = { "Vendor" => vendor.to_s, "Command" => command }
|
|
49
|
+
table = ::TextFSM::CliTable.new(index: @index, template_dir: @template_dir)
|
|
50
|
+
unless table.index.match(attributes)
|
|
51
|
+
raise ParsingError.new("no TextFSM template matches this vendor and command",
|
|
52
|
+
code: :template_missing, host: host, phase: :parse)
|
|
53
|
+
end
|
|
54
|
+
table.parse(text, attributes: attributes).to_hashes
|
|
55
|
+
end
|
|
56
|
+
rescue ::TextFSM::IndexError
|
|
57
|
+
raise ParsingError.new("TextFSM template index is invalid",
|
|
58
|
+
code: :template_invalid, host: host, phase: :parse), cause: nil
|
|
59
|
+
rescue ::TextFSM::TemplateError
|
|
60
|
+
raise ParsingError.new("TextFSM template is invalid",
|
|
61
|
+
code: :template_invalid, host: host, phase: :parse), cause: nil
|
|
62
|
+
rescue ::TextFSM::ParseError
|
|
63
|
+
raise ParsingError.new("TextFSM could not parse the device output",
|
|
64
|
+
code: :parse_failed, host: host, phase: :parse), cause: nil
|
|
65
|
+
rescue IOError, SystemCallError
|
|
66
|
+
raise ParsingError.new("unable to read TextFSM template",
|
|
67
|
+
code: :template_unreadable, host: host, phase: :parse), cause: nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|