ctx_checker 0.1.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 +7 -0
- data/CHANGELOG.md +17 -0
- data/COUNTER_CONTRACT.md +33 -0
- data/LICENSE +22 -0
- data/README-zh.md +93 -0
- data/README.md +93 -0
- data/docs/architecture-zh.md +487 -0
- data/docs/release.md +13 -0
- data/examples/offline.rb +17 -0
- data/lib/ctx_checker/applicability.rb +187 -0
- data/lib/ctx_checker/checks/aggregation.rb +73 -0
- data/lib/ctx_checker/checks/alarms.rb +25 -0
- data/lib/ctx_checker/checks/base.rb +66 -0
- data/lib/ctx_checker/checks/clock.rb +30 -0
- data/lib/ctx_checker/checks/counters.rb +77 -0
- data/lib/ctx_checker/checks/device_counters.rb +32 -0
- data/lib/ctx_checker/checks/hardware.rb +45 -0
- data/lib/ctx_checker/checks/interfaces.rb +35 -0
- data/lib/ctx_checker/checks/ntp.rb +47 -0
- data/lib/ctx_checker/checks/optics.rb +107 -0
- data/lib/ctx_checker/checks/resources.rb +63 -0
- data/lib/ctx_checker/checks/routing.rb +86 -0
- data/lib/ctx_checker/checks/temperature.rb +53 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_clock_detail.rb +56 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_environment_all.rb +153 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_etherchannel_summary.rb +110 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_facility_alarm_status.rb +41 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_interfaces.rb +140 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_interfaces_transceiver_detail.rb +107 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_ip_bgp_summary.rb +164 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_ip_ospf_neighbor.rb +96 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_ip_traffic.rb +65 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_memory_statistics.rb +36 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_ntp_status.rb +44 -0
- data/lib/ctx_checker/cisco_ios/parsers/show_processes_cpu.rb +45 -0
- data/lib/ctx_checker/cisco_ios/parsers/system_values.rb +62 -0
- data/lib/ctx_checker/cisco_ios/profile.rb +39 -0
- data/lib/ctx_checker/cisco_nxos/parsers/show_bgp_ipv4_unicast_summary.rb +69 -0
- data/lib/ctx_checker/cisco_nxos/parsers/show_clock.rb +13 -0
- data/lib/ctx_checker/cisco_nxos/parsers/show_environment.rb +157 -0
- data/lib/ctx_checker/cisco_nxos/parsers/show_interface.rb +78 -0
- data/lib/ctx_checker/cisco_nxos/parsers/show_interface_transceiver_details.rb +141 -0
- data/lib/ctx_checker/cisco_nxos/parsers/show_ip_ospf_neighbors.rb +14 -0
- data/lib/ctx_checker/cisco_nxos/parsers/show_ip_traffic.rb +151 -0
- data/lib/ctx_checker/cisco_nxos/parsers/show_ntp_peer_status.rb +116 -0
- data/lib/ctx_checker/cisco_nxos/parsers/show_port_channel_summary.rb +21 -0
- data/lib/ctx_checker/cisco_nxos/parsers/show_system_resources.rb +89 -0
- data/lib/ctx_checker/cisco_nxos/profile.rb +35 -0
- data/lib/ctx_checker/collector.rb +175 -0
- data/lib/ctx_checker/evaluator.rb +49 -0
- data/lib/ctx_checker/failure.rb +24 -0
- data/lib/ctx_checker/finding.rb +19 -0
- data/lib/ctx_checker/h3c/parsers/counters_table.rb +56 -0
- data/lib/ctx_checker/h3c/parsers/display_alarm_active.rb +43 -0
- data/lib/ctx_checker/h3c/parsers/display_bgp_peer.rb +44 -0
- data/lib/ctx_checker/h3c/parsers/display_clock.rb +72 -0
- data/lib/ctx_checker/h3c/parsers/display_counters_inbound_interface.rb +21 -0
- data/lib/ctx_checker/h3c/parsers/display_counters_outbound_interface.rb +21 -0
- data/lib/ctx_checker/h3c/parsers/display_cpu_usage.rb +64 -0
- data/lib/ctx_checker/h3c/parsers/display_environment.rb +74 -0
- data/lib/ctx_checker/h3c/parsers/display_fan.rb +72 -0
- data/lib/ctx_checker/h3c/parsers/display_interface.rb +127 -0
- data/lib/ctx_checker/h3c/parsers/display_link_aggregation_verbose.rb +72 -0
- data/lib/ctx_checker/h3c/parsers/display_memory.rb +132 -0
- data/lib/ctx_checker/h3c/parsers/display_ntp_service_status.rb +84 -0
- data/lib/ctx_checker/h3c/parsers/display_ospf_peer.rb +47 -0
- data/lib/ctx_checker/h3c/parsers/display_power.rb +63 -0
- data/lib/ctx_checker/h3c/parsers/display_transceiver_diagnosis.rb +109 -0
- data/lib/ctx_checker/h3c/parsers/system_values.rb +46 -0
- data/lib/ctx_checker/h3c/profile.rb +49 -0
- data/lib/ctx_checker/hillstone/parsers/show_clock.rb +68 -0
- data/lib/ctx_checker/hillstone/parsers/show_cpu.rb +44 -0
- data/lib/ctx_checker/hillstone/parsers/show_cpu_cntr.rb +114 -0
- data/lib/ctx_checker/hillstone/parsers/show_environment.rb +90 -0
- data/lib/ctx_checker/hillstone/parsers/show_interface.rb +82 -0
- data/lib/ctx_checker/hillstone/parsers/show_interface_counter.rb +53 -0
- data/lib/ctx_checker/hillstone/parsers/show_ip_bgp_summary.rb +22 -0
- data/lib/ctx_checker/hillstone/parsers/show_ip_ospf_neighbor.rb +21 -0
- data/lib/ctx_checker/hillstone/parsers/show_memory.rb +85 -0
- data/lib/ctx_checker/hillstone/parsers/show_ntp_status.rb +110 -0
- data/lib/ctx_checker/hillstone/parsers/show_transceiver.rb +101 -0
- data/lib/ctx_checker/hillstone/profile.rb +40 -0
- data/lib/ctx_checker/observation.rb +165 -0
- data/lib/ctx_checker/observation_merger.rb +80 -0
- data/lib/ctx_checker/palo_alto/parsers/show_clock.rb +50 -0
- data/lib/ctx_checker/palo_alto/parsers/show_counter_global.rb +47 -0
- data/lib/ctx_checker/palo_alto/parsers/show_counter_interface_all.rb +68 -0
- data/lib/ctx_checker/palo_alto/parsers/show_interface_all.rb +70 -0
- data/lib/ctx_checker/palo_alto/parsers/show_lacp_aggregate_ethernet.rb +52 -0
- data/lib/ctx_checker/palo_alto/parsers/show_ntp.rb +78 -0
- data/lib/ctx_checker/palo_alto/parsers/show_routing_protocol_bgp_peer.rb +66 -0
- data/lib/ctx_checker/palo_alto/parsers/show_routing_protocol_ospf_neighbor.rb +54 -0
- data/lib/ctx_checker/palo_alto/parsers/show_system_environmentals.rb +142 -0
- data/lib/ctx_checker/palo_alto/parsers/show_system_resources.rb +102 -0
- data/lib/ctx_checker/palo_alto/parsers/show_system_state_alarms.rb +54 -0
- data/lib/ctx_checker/palo_alto/parsers/show_transceiver_all.rb +44 -0
- data/lib/ctx_checker/palo_alto/profile.rb +39 -0
- data/lib/ctx_checker/parser_registry.rb +43 -0
- data/lib/ctx_checker/parsers/base.rb +68 -0
- data/lib/ctx_checker/parsers/interface_names.rb +65 -0
- data/lib/ctx_checker/parsers/scoped_result.rb +35 -0
- data/lib/ctx_checker/policy.rb +104 -0
- data/lib/ctx_checker/radware_alteon/parsers/info_l2_lacp_dump.rb +48 -0
- data/lib/ctx_checker/radware_alteon/parsers/info_l2_trunk.rb +46 -0
- data/lib/ctx_checker/radware_alteon/parsers/info_l3_bgp_summary.rb +22 -0
- data/lib/ctx_checker/radware_alteon/parsers/info_l3_ip.rb +71 -0
- data/lib/ctx_checker/radware_alteon/parsers/info_l3_ospf_nbr.rb +22 -0
- data/lib/ctx_checker/radware_alteon/parsers/info_link.rb +44 -0
- data/lib/ctx_checker/radware_alteon/parsers/info_sys_fan.rb +47 -0
- data/lib/ctx_checker/radware_alteon/parsers/info_sys_general.rb +48 -0
- data/lib/ctx_checker/radware_alteon/parsers/info_sys_ps.rb +34 -0
- data/lib/ctx_checker/radware_alteon/parsers/info_sys_temp.rb +53 -0
- data/lib/ctx_checker/radware_alteon/parsers/info_transceiver.rb +68 -0
- data/lib/ctx_checker/radware_alteon/parsers/stats_mp_cpu.rb +39 -0
- data/lib/ctx_checker/radware_alteon/parsers/stats_mp_mem.rb +58 -0
- data/lib/ctx_checker/radware_alteon/parsers/stats_port_ether.rb +67 -0
- data/lib/ctx_checker/radware_alteon/parsers/stats_port_if.rb +74 -0
- data/lib/ctx_checker/radware_alteon/parsers/stats_sp_allutils.rb +99 -0
- data/lib/ctx_checker/radware_alteon/parsers/stats_sys_ntp.rb +86 -0
- data/lib/ctx_checker/radware_alteon/port_counter_parser.rb +34 -0
- data/lib/ctx_checker/radware_alteon/profile.rb +145 -0
- data/lib/ctx_checker/radware_alteon.rb +8 -0
- data/lib/ctx_checker/report.rb +112 -0
- data/lib/ctx_checker/runtime_parser.rb +234 -0
- data/lib/ctx_checker/snapshot.rb +44 -0
- data/lib/ctx_checker/system_policy.rb +70 -0
- data/lib/ctx_checker/system_record.rb +81 -0
- data/lib/ctx_checker/version.rb +6 -0
- data/lib/ctx_checker.rb +17 -0
- metadata +271 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "../../parsers/base"
|
|
5
|
+
require "ctx_parser/result_set"
|
|
6
|
+
|
|
7
|
+
module CtxChecker
|
|
8
|
+
module RadwareAlteon
|
|
9
|
+
module Parsers
|
|
10
|
+
# 累计 NTP 请求与更新次数不能证明当前同步状态。
|
|
11
|
+
class StatsSysNtp < CtxChecker::Parsers::Base
|
|
12
|
+
COUNTERS = { "Requests Sent" => "requests_sent", "Responses Received" => "responses_received", "Updates" => "updates" }.freeze
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# 计数器完整性独立于当前 NTP 状态是否可用。
|
|
17
|
+
def parse_output(text, complete:)
|
|
18
|
+
source = clean(text)
|
|
19
|
+
rows, headers, sections, time_fields = [], 0, [], []
|
|
20
|
+
counter_issues, ntp_issues, section = [], [], nil
|
|
21
|
+
source.each_line.with_index(1) do |raw, number|
|
|
22
|
+
line = raw.strip
|
|
23
|
+
next if line.empty? || /\A-+\z/.match?(line)
|
|
24
|
+
|
|
25
|
+
if line == "NTP server statistics:"
|
|
26
|
+
headers += 1
|
|
27
|
+
elsif (match = /\A(Primary|Secondary) Server:\z/.match(line))
|
|
28
|
+
section = match[1].downcase
|
|
29
|
+
sections << section
|
|
30
|
+
elsif (match = /\A(Requests Sent|Responses Received|Updates):\s*(.*?)\z/.match(line))
|
|
31
|
+
row = counter(section, match[1], match[2], number, counter_issues)
|
|
32
|
+
rows << row if row
|
|
33
|
+
elsif /\ALast update based on response from (?:primary|secondary) server\.\z/.match?(line)
|
|
34
|
+
time_fields << :server
|
|
35
|
+
elsif (match = /\A(Last update time|Current system time):\s*(.*?)\z/.match(line))
|
|
36
|
+
field = match[1] == "Last update time" ? :last_update : :current_time
|
|
37
|
+
time_fields << field
|
|
38
|
+
ntp_issues << { code: :invalid_ntp_time, field: field, line: number } unless valid_time?(match[2])
|
|
39
|
+
else
|
|
40
|
+
issue = { code: :unrecognized_ntp_line, line: number }
|
|
41
|
+
counter_issues << issue
|
|
42
|
+
ntp_issues << issue
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
counter_issues << { code: :missing_ntp_statistics_header } unless headers == 1
|
|
46
|
+
%w[primary secondary].each do |peer|
|
|
47
|
+
counter_issues << { code: :incomplete_ntp_counter_section, component: peer } unless sections.count(peer) == 1 && rows.count { |row| row[:component] == "ntp/#{peer}" } == COUNTERS.size
|
|
48
|
+
end
|
|
49
|
+
ntp_issues << { code: :ntp_system_status_unavailable }
|
|
50
|
+
ntp_issues << { code: :ntp_last_sync_offset_unknown } if time_fields.include?(:last_update)
|
|
51
|
+
%i[server last_update current_time].each do |field|
|
|
52
|
+
ntp_issues << { code: :ambiguous_ntp_time_field, field: field, count: time_fields.count(field) } unless time_fields.count(field) == 1
|
|
53
|
+
end
|
|
54
|
+
ntp_rows = headers.positive? ? [ { component: "system", synchronized: nil, last_sync_status: :unknown, last_sync_at: nil } ] : []
|
|
55
|
+
CtxParser::ResultSet.new(results: {
|
|
56
|
+
ntp: build_result(ntp_rows, complete: complete, recognized: headers.positive?, issues: ntp_issues, identity: [ :component ]),
|
|
57
|
+
device_counters: build_result(rows, complete: complete, recognized: rows.any?, issues: counter_issues, identity: %i[component metric])
|
|
58
|
+
})
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 仅暴露具名整数计数器,不通过运算推导故障或重置次数。
|
|
62
|
+
def counter(section, label, raw, number, issues)
|
|
63
|
+
unless section
|
|
64
|
+
issues << { code: :ntp_counter_section_missing, line: number }
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
value = /\A\d+\z/.match?(raw) ? Integer(raw, 10) : nil
|
|
68
|
+
issues << { code: :invalid_ntp_counter, line: number } unless value
|
|
69
|
+
{ component: "ntp/#{section}", metric: COUNTERS.fetch(label), category: :informational,
|
|
70
|
+
value: value, discontinuity: nil, sampled_at: nil }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 校验本地时间戳,不虚构时区偏移或同步尝试结果。
|
|
74
|
+
def valid_time?(text)
|
|
75
|
+
match = /\A(\d{2}:\d{2}:\d{2}) (Sun|Mon|Tue|Wed|Thu|Fri|Sat) ([A-Z][a-z]{2}) (\d{1,2}), (\d{4})\z/.match(text)
|
|
76
|
+
return false unless match && match[1].split(":").map(&:to_i).zip([ 24, 60, 60 ]).all? { |value, limit| value < limit }
|
|
77
|
+
|
|
78
|
+
date = Date.strptime("#{match[3]} #{match[4]} #{match[5]}", "%b %d %Y")
|
|
79
|
+
Date::ABBR_DAYNAMES[date.wday] == match[2]
|
|
80
|
+
rescue ArgumentError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parsers/base"
|
|
4
|
+
|
|
5
|
+
module CtxChecker
|
|
6
|
+
module RadwareAlteon
|
|
7
|
+
# 动态端口查询绑定预期身份,不仅依赖命令回显确认目标。
|
|
8
|
+
class PortCounterParser < CtxChecker::Parsers::Base
|
|
9
|
+
# 各发现命令的目标绑定到其解析器类,避免共享可变状态。
|
|
10
|
+
def self.for_port(number)
|
|
11
|
+
raise ArgumentError, "Expected port must be a positive integer" unless number.is_a?(Integer) && number.positive?
|
|
12
|
+
|
|
13
|
+
Class.new(self) { define_method(:expected_port) { "port#{number}" } }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# 未绑定目标的解析器仍可用于已保存回显及独立格式回归。
|
|
19
|
+
def expected_port = nil
|
|
20
|
+
|
|
21
|
+
# 其他端口的响应不能算作请求端口的覆盖证据。
|
|
22
|
+
def build_port_result(rows, complete:, recognized:, issues:)
|
|
23
|
+
if expected_port
|
|
24
|
+
mismatched = rows.reject { |row| row[:interface] == expected_port }
|
|
25
|
+
if mismatched.any?
|
|
26
|
+
issues << { code: :counter_port_mismatch, interface: expected_port }
|
|
27
|
+
rows -= mismatched
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
build_result(rows, complete: complete, recognized: recognized, issues: issues, identity: %i[interface metric])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_parser/profile"
|
|
4
|
+
require_relative "../observation"
|
|
5
|
+
require_relative "parsers/info_link"
|
|
6
|
+
require_relative "parsers/info_l3_ip"
|
|
7
|
+
require_relative "parsers/info_transceiver"
|
|
8
|
+
require_relative "parsers/info_l2_trunk"
|
|
9
|
+
require_relative "parsers/info_l2_lacp_dump"
|
|
10
|
+
require_relative "parsers/stats_port_if"
|
|
11
|
+
require_relative "parsers/stats_port_ether"
|
|
12
|
+
require_relative "parsers/info_l3_bgp_summary"
|
|
13
|
+
require_relative "parsers/info_l3_ospf_nbr"
|
|
14
|
+
require_relative "parsers/stats_mp_cpu"
|
|
15
|
+
require_relative "parsers/stats_mp_mem"
|
|
16
|
+
require_relative "parsers/stats_sp_allutils"
|
|
17
|
+
require_relative "parsers/info_sys_temp"
|
|
18
|
+
require_relative "parsers/info_sys_fan"
|
|
19
|
+
require_relative "parsers/info_sys_ps"
|
|
20
|
+
require_relative "parsers/info_sys_general"
|
|
21
|
+
require_relative "parsers/stats_sys_ntp"
|
|
22
|
+
|
|
23
|
+
module CtxChecker
|
|
24
|
+
module RadwareAlteon
|
|
25
|
+
# Alteon 菜单查询独立于其他 Radware 产品系列。
|
|
26
|
+
class Profile < CtxParser::Profile
|
|
27
|
+
MAX_DISCOVERED_PORTS = 256
|
|
28
|
+
CATALOG = CtxParser::CommandCatalog.new([
|
|
29
|
+
{ dimensions: [ :interfaces ], command: "/info/link", parser: Parsers::InfoLink },
|
|
30
|
+
{ dimensions: [ :interfaces ], command: "/info/l3/ip", parser: Parsers::InfoL3Ip },
|
|
31
|
+
{ dimensions: [ :aggregation ], command: "/info/l2/trunk", parser: Parsers::InfoL2Trunk },
|
|
32
|
+
{ dimensions: [ :aggregation ], command: "/info/l2/lacp/dump", parser: Parsers::InfoL2LacpDump },
|
|
33
|
+
{ dimensions: [ :bgp ], command: "/info/l3/bgp/summary", parser: Parsers::InfoL3BgpSummary },
|
|
34
|
+
{ dimensions: [ :ospf ], command: "/info/l3/ospf/nbr", parser: Parsers::InfoL3OspfNbr },
|
|
35
|
+
{ dimensions: [ :cpu ], command: "/stats/mp/cpu", parser: Parsers::StatsMpCpu },
|
|
36
|
+
{ dimensions: [ :memory ], command: "/stats/mp/mem", parser: Parsers::StatsMpMem },
|
|
37
|
+
{ dimensions: %i[cpu memory], command: "/stats/sp 1/allutils", parser: Parsers::StatsSpAllutils },
|
|
38
|
+
{ dimensions: [ :temperature ], command: "/info/sys/temp", parser: Parsers::InfoSysTemp },
|
|
39
|
+
{ dimensions: [ :environment ], command: "/info/sys/fan", parser: Parsers::InfoSysFan },
|
|
40
|
+
{ dimensions: [ :power ], command: "/info/sys/ps", parser: Parsers::InfoSysPs },
|
|
41
|
+
{ dimensions: [ :clock ], command: "/info/sys/general", parser: Parsers::InfoSysGeneral },
|
|
42
|
+
{ dimensions: %i[ntp device_counters], command: "/stats/sys/ntp", parser: Parsers::StatsSysNtp }
|
|
43
|
+
], multi_command: true)
|
|
44
|
+
COMMANDS = CATALOG.commands
|
|
45
|
+
PARSERS = CATALOG.parsers
|
|
46
|
+
|
|
47
|
+
# 打开连接器前,拒绝已知不兼容的产品系列。
|
|
48
|
+
def initialize(model: nil)
|
|
49
|
+
super
|
|
50
|
+
raise ArgumentError, "DefensePro requires a separate checker profile" if /defen[cs]e[\s_-]*pro/i.match?(model.to_s)
|
|
51
|
+
|
|
52
|
+
@catalog = CATALOG
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 扩展后的实例保留不可变命令与实现索引。
|
|
56
|
+
def commands = @catalog.commands
|
|
57
|
+
|
|
58
|
+
# 绑定端口的解析器复用基础型号覆盖及命令选择契约。
|
|
59
|
+
def parsers = @catalog.parsers
|
|
60
|
+
|
|
61
|
+
# 已知光模块命令缺陷根据固件事实判断,不依据型号名称猜测。
|
|
62
|
+
def with_software_version(value)
|
|
63
|
+
raise ArgumentError, "Software version must be text" unless value.nil? || value.is_a?(String)
|
|
64
|
+
|
|
65
|
+
entries = @catalog.entries.reject { |entry| entry[:dimensions].include?(:optics) }
|
|
66
|
+
if transceiver_fixed?(value)
|
|
67
|
+
entries += [ { dimensions: [ :optics ], command: "/info/transceiver", parser: Parsers::InfoTransceiver } ]
|
|
68
|
+
end
|
|
69
|
+
expanded = dup
|
|
70
|
+
expanded.instance_variable_set(:@catalog, CtxParser::CommandCatalog.new(entries, multi_command: true))
|
|
71
|
+
expanded
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Alteon 不提供全端口叶级查询,计数器采集必须依赖完整端口清单。
|
|
75
|
+
def discovery_dimensions(requested)
|
|
76
|
+
requested.include?(:counters) ? [ :interfaces ] : []
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# 仅为经过校验的清单端口生成查询;发现不完整时不猜测端口。
|
|
80
|
+
def expand(observations, dimensions:)
|
|
81
|
+
return self unless dimensions.include?(:counters) && !commands.key?(:counters)
|
|
82
|
+
|
|
83
|
+
interfaces = observations[:interfaces]
|
|
84
|
+
return self unless physical_source_complete?(interfaces)
|
|
85
|
+
|
|
86
|
+
ports = discovered_ports(interfaces)
|
|
87
|
+
return self if ports.empty?
|
|
88
|
+
|
|
89
|
+
additions = ports.flat_map do |port|
|
|
90
|
+
[ { dimensions: [ :counters ], command: "/stats/port #{port}/if", parser: Parsers::StatsPortIf.for_port(port) },
|
|
91
|
+
{ dimensions: [ :counters ], command: "/stats/port #{port}/ether", parser: Parsers::StatsPortEther.for_port(port) } ]
|
|
92
|
+
end
|
|
93
|
+
expanded = dup
|
|
94
|
+
expanded.instance_variable_set(:@catalog, CtxParser::CommandCatalog.new(@catalog.entries + additions, multi_command: true))
|
|
95
|
+
expanded
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# 每个发现端口均需标准计数器和以太网错误计数器证据。
|
|
99
|
+
def counter_metrics = %i[in_errors out_errors in_discards out_discards crc_errors out_carrier_errors].freeze
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# 官方修复版本为 30.5.9.0、31.0.6.0、32.1.0.0;其他版本分支保持不可用。
|
|
104
|
+
def transceiver_fixed?(value)
|
|
105
|
+
return false unless /\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\z/.match?(value.to_s)
|
|
106
|
+
|
|
107
|
+
major, minor, patch, build = value.split(".").map { |part| Integer(part, 10) }
|
|
108
|
+
case [ major, minor ]
|
|
109
|
+
when [ 30, 5 ] then ([ patch, build ] <=> [ 9, 0 ]) >= 0
|
|
110
|
+
when [ 31, 0 ] then ([ patch, build ] <=> [ 6, 0 ]) >= 0
|
|
111
|
+
else major == 32 && minor >= 1
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# 逻辑接口证据失败不能使完整物理接口清单失效。
|
|
116
|
+
def physical_source_complete?(interfaces)
|
|
117
|
+
return false unless interfaces.is_a?(Observation) && interfaces.name == :interfaces && %i[complete partial].include?(interfaces.status)
|
|
118
|
+
|
|
119
|
+
sources = interfaces.evidence.select { |entry| entry[:command] == "/info/link" }
|
|
120
|
+
sources.one? && sources.first[:status] == :complete && interfaces.records.all? do |row|
|
|
121
|
+
!row[:name].start_with?("port") || row[:source_complete] == true
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# 限制动态查询范围,不将数字后缀视为任意 CLI 文本。
|
|
126
|
+
def discovered_ports(interfaces)
|
|
127
|
+
ports = interfaces.records.filter_map do |row|
|
|
128
|
+
next unless row[:kind] == :physical
|
|
129
|
+
name = row[:name]
|
|
130
|
+
next unless name.start_with?("port")
|
|
131
|
+
|
|
132
|
+
match = /\Aport([1-9]\d*)\z/.match(name)
|
|
133
|
+
port = Integer(match[1], 10) if match
|
|
134
|
+
unless port && port <= MAX_DISCOVERED_PORTS
|
|
135
|
+
raise ArgumentError, "Invalid discovered Alteon port identity"
|
|
136
|
+
end
|
|
137
|
+
port
|
|
138
|
+
end.uniq.sort
|
|
139
|
+
raise ArgumentError, "Alteon port discovery exceeds the query limit" if ports.size > MAX_DISCOVERED_PORTS
|
|
140
|
+
|
|
141
|
+
ports
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "snapshot"
|
|
4
|
+
require_relative "finding"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module CtxChecker
|
|
8
|
+
# 提供紧凑、可序列化的结果,独立保留采集覆盖率与故障信息。
|
|
9
|
+
class Report
|
|
10
|
+
attr_reader :snapshot, :findings, :counts, :commands
|
|
11
|
+
|
|
12
|
+
# 冻结报告证据与检查结果,便于复现评估。
|
|
13
|
+
def initialize(snapshot:, findings:)
|
|
14
|
+
raise ArgumentError, "Report requires a snapshot" unless snapshot.is_a?(Snapshot)
|
|
15
|
+
raise ArgumentError, "Report requires findings" unless findings.is_a?(Array) && findings.all?(Finding)
|
|
16
|
+
@snapshot, @findings = snapshot, findings.dup.freeze
|
|
17
|
+
@counts = %i[pass fail unknown not_applicable].to_h { |state| [ state, findings.count { |row| row.status == state } ] }.freeze
|
|
18
|
+
@commands = CtxParser::Immutable.copy(command_results)
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 判断所有已选事实是否都有完整证据支持。
|
|
23
|
+
def complete? = counts[:unknown].zero? && snapshot.issues.empty? && snapshot.observations.values.all? { |row| %i[complete not_requested not_applicable].include?(row.status) }
|
|
24
|
+
# 仅在覆盖充分且无失败检查时声明健康。
|
|
25
|
+
def healthy? = complete? && counts[:pass].positive? && counts[:fail].zero?
|
|
26
|
+
# 推导汇总状态,避免将证据缺失当作成功。
|
|
27
|
+
def status = counts[:fail].positive? ? :fail : healthy? ? :pass : :unknown
|
|
28
|
+
|
|
29
|
+
# 独立衡量已评估证据的覆盖情况,不与设备是否通过混淆。
|
|
30
|
+
def coverage
|
|
31
|
+
applicable = counts[:pass] + counts[:fail] + counts[:unknown]
|
|
32
|
+
applicable.zero? ? 0.0 : ((counts[:pass] + counts[:fail]) * 100.0 / applicable).round(2)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 返回公开的数据结构,并保留状态与诊断信息。
|
|
36
|
+
def to_h = { status: status, complete: complete?, healthy: healthy?, coverage: coverage, counts: counts, commands: commands, snapshot: snapshot.to_h, findings: findings.map(&:to_h) }
|
|
37
|
+
|
|
38
|
+
# 持久化摘要避免展开全部观测和测量值。
|
|
39
|
+
def summary
|
|
40
|
+
{ status: status, complete: complete?, healthy: healthy?, coverage: coverage, counts: counts,
|
|
41
|
+
target: snapshot.target, observed_at: snapshot.observed_at.iso8601(6), commands: commands,
|
|
42
|
+
observations: snapshot.observations.transform_values do |row|
|
|
43
|
+
details = { status: row.status, records: row.records.size, issues: row.issues }
|
|
44
|
+
row.covered_scopes.empty? ? details : details.merge(covered_scopes: row.covered_scopes)
|
|
45
|
+
end,
|
|
46
|
+
issues: snapshot.issues, findings: findings.map { |row| row.to_h.reject { |key, _| %i[actual expected].include?(key) } } }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# 生成易读报告,区分已知故障与证据缺失。
|
|
50
|
+
def to_text
|
|
51
|
+
[ "Health: #{status} | coverage: #{coverage}% | pass=#{counts[:pass]} fail=#{counts[:fail]} unknown=#{counts[:unknown]} not_applicable=#{counts[:not_applicable]}",
|
|
52
|
+
*commands.reject { |entry| entry[:status] == :complete }.map { |entry| command_text(entry) },
|
|
53
|
+
*findings.map do |row|
|
|
54
|
+
details = [ "#{row.status.to_s.ljust(14)} #{display(row.id)} #{display(row.entity)} (#{row.reason})" ]
|
|
55
|
+
details << "actual=#{display(row.actual)}" unless row.actual.nil?
|
|
56
|
+
details << "expected=#{display(row.expected)}" unless row.expected.nil?
|
|
57
|
+
details.join(" | ")
|
|
58
|
+
end ].join("\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# 分别保留命令覆盖率、归并维度的健康与完整性。
|
|
64
|
+
def command_results
|
|
65
|
+
grouped = {}
|
|
66
|
+
snapshot.observations.each_value do |observation|
|
|
67
|
+
sources = observation.evidence.filter_map { |entry| entry[:command] }.uniq
|
|
68
|
+
observation.evidence.each do |evidence|
|
|
69
|
+
command = evidence[:command]
|
|
70
|
+
next unless command
|
|
71
|
+
|
|
72
|
+
issues = observation.issues.select { |issue| issue[:command] == command || !issue[:command] && sources.one? }
|
|
73
|
+
state = issues.any? { |issue| issue[:skipped] } ? :skipped : evidence.fetch(:status, observation.status)
|
|
74
|
+
entry = grouped[command] ||= { command: command, dimensions: {}, issues: [] }
|
|
75
|
+
entry[:dimensions][observation.name] = command_status([ entry[:dimensions][observation.name], state ].compact)
|
|
76
|
+
entry[:issues].concat(issues.map { |issue| issue.merge(dimension: observation.name) })
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
grouped.each_value do |entry|
|
|
80
|
+
entry[:status] = command_status(entry[:dimensions].values)
|
|
81
|
+
entry[:issues].uniq!
|
|
82
|
+
end
|
|
83
|
+
grouped.values
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 共享查询可以同时产生一个有效视图和一个不可用视图。
|
|
87
|
+
def command_status(states)
|
|
88
|
+
unique = states.uniq
|
|
89
|
+
return unique.first if unique.one?
|
|
90
|
+
return :partial if unique.any? { |state| %i[complete partial].include?(state) }
|
|
91
|
+
|
|
92
|
+
:failed
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# 展示安全失败代码,不包含异常消息或原始响应。
|
|
96
|
+
def command_text(entry)
|
|
97
|
+
dimensions = entry[:dimensions].map { |name, state| "#{name}:#{state}" }.join(",")
|
|
98
|
+
diagnostics = entry[:issues].map do |issue|
|
|
99
|
+
code = [ issue[:stage], issue[:code] ].compact.join(":")
|
|
100
|
+
issue[:error_type] ? "#{code} (#{issue[:error_type]})" : code
|
|
101
|
+
end.uniq
|
|
102
|
+
[ "Command: #{entry[:status]}", display(entry[:command]), "dimensions=#{dimensions}", *diagnostics.map { |value| display(value) } ].join(" | ")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# 结构化测量保持可读,同时限制报告单元格的换行与长度。
|
|
106
|
+
def display(value)
|
|
107
|
+
text = value.is_a?(String) ? value : JSON.generate(value)
|
|
108
|
+
text = text.gsub(/[\x00-\x1f\x7f]/) { |character| format("\\u%04x", character.ord) }
|
|
109
|
+
text.length > 512 ? "#{text[0, 509]}..." : text
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "parser_registry"
|
|
5
|
+
require_relative "observation"
|
|
6
|
+
require_relative "observation_merger"
|
|
7
|
+
require_relative "parsers/base"
|
|
8
|
+
require_relative "failure"
|
|
9
|
+
|
|
10
|
+
module CtxChecker
|
|
11
|
+
# 离线与实时采集使用同一解析入口,每条命令回显仅解析一次。
|
|
12
|
+
class RuntimeParser
|
|
13
|
+
DIMENSIONS = Observation::NAMES
|
|
14
|
+
MAX_DISCOVERED_QUERIES = 512
|
|
15
|
+
attr_reader :profile, :platform
|
|
16
|
+
|
|
17
|
+
# 按请求的平台和型号选择运行解析行为。
|
|
18
|
+
def initialize(platform:, model: nil, software_version: nil)
|
|
19
|
+
@platform = ParserRegistry.canonical(platform)
|
|
20
|
+
@profile = ParserRegistry.fetch(@platform, model: model, software_version: software_version)
|
|
21
|
+
@discovery_observations = {}.freeze
|
|
22
|
+
validate_profile!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 通过所选平台与命令策略解析单个运行维度。
|
|
26
|
+
def parse(name, text, complete: false, command: nil)
|
|
27
|
+
parse_many([ name ], text, complete: complete, command: command).fetch(name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# 共享命令仅解析一次,再投影各请求维度。
|
|
31
|
+
def parse_many(names, text, complete: false, command: nil)
|
|
32
|
+
self.class.validate_dimensions!(names)
|
|
33
|
+
raise ArgumentError, "CLI output must be text" unless text.is_a?(String)
|
|
34
|
+
raise ArgumentError, "Completeness must be boolean" unless [ true, false ].include?(complete)
|
|
35
|
+
|
|
36
|
+
unavailable = names - profile.commands.keys
|
|
37
|
+
if unavailable.any?
|
|
38
|
+
raise ArgumentError, "One output must belong to one supported command" unless unavailable == names
|
|
39
|
+
|
|
40
|
+
return names.to_h { |name| [ name, unavailable_observation(name) ] }
|
|
41
|
+
end
|
|
42
|
+
candidates = names.map { |name| profile.commands_for(name) }.reduce(:&)
|
|
43
|
+
command ||= candidates.first if candidates.one?
|
|
44
|
+
raise ArgumentError, "Select one declared command or use parse_outputs for multiple sources" unless candidates.include?(command)
|
|
45
|
+
implementation = profile.parser_for(names.first, command: command)
|
|
46
|
+
command_counts = names.to_h { |name| [ name, profile.commands_for(name).size ] }
|
|
47
|
+
evidence = [ { command: command, sha256: Digest::SHA256.hexdigest(text), bytes: text.bytesize } ]
|
|
48
|
+
source = begin
|
|
49
|
+
normalized = CtxParser::Input.text(text, encoding: :transcode, newlines: :lf, max_bytes: Parsers::Base::MAX_BYTES)
|
|
50
|
+
if /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.match?(normalized)
|
|
51
|
+
raise ArgumentError, "CLI body contains terminal control bytes"
|
|
52
|
+
end
|
|
53
|
+
normalized
|
|
54
|
+
rescue ArgumentError, EncodingError => error
|
|
55
|
+
return names.to_h do |name|
|
|
56
|
+
[ name, Failure.observation(name, code: :invalid_output, stage: :parse, command: command, error: error, evidence: evidence) ]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
result = begin
|
|
61
|
+
implementation.parse(source, complete: complete)
|
|
62
|
+
rescue StandardError => error
|
|
63
|
+
return names.to_h do |name|
|
|
64
|
+
[ name, Failure.observation(name, code: :parser_error, stage: :parse, command: command, error: error, evidence: evidence) ]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
names.to_h do |name|
|
|
68
|
+
[ name, project(name, result, command: command, command_count: command_counts.fetch(name), evidence: evidence) ]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# 离线调用方逐条提供命令正文,并显式声明完整性。
|
|
73
|
+
def parse_outputs(name, outputs, complete: false)
|
|
74
|
+
self.class.validate_dimensions!([ name ])
|
|
75
|
+
raise ArgumentError, "Completeness must be boolean" unless [ true, false ].include?(complete)
|
|
76
|
+
unless profile.commands.key?(name)
|
|
77
|
+
raise ArgumentError, "Outputs must map declared commands to their bodies" unless outputs.is_a?(Hash) && outputs.empty?
|
|
78
|
+
return unavailable_observation(name)
|
|
79
|
+
end
|
|
80
|
+
unless outputs.is_a?(Hash) && (outputs.keys - profile.commands_for(name)).empty?
|
|
81
|
+
raise ArgumentError, "Outputs must map declared commands to their bodies"
|
|
82
|
+
end
|
|
83
|
+
combine(name, outputs.map { |command, body| parse(name, body, complete: complete, command: command) })
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 实时采集与离线组装遵循相同的来源及指标覆盖契约。
|
|
87
|
+
def combine(name, observations)
|
|
88
|
+
metrics = name == :counters && profile.respond_to?(:counter_metrics) ? profile.counter_metrics : []
|
|
89
|
+
ObservationMerger.call(name, observations, commands: profile.commands_for(name), required_metrics: metrics)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# 按命令归组所选维度,避免重复执行共享查询。
|
|
93
|
+
def queries(names)
|
|
94
|
+
self.class.validate_dimensions!(names)
|
|
95
|
+
profile.queries(profile.commands.keys & names)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# 可选发现依赖仅来自固定平台命令,不递归生成查询。
|
|
99
|
+
def discovery_dimensions(names)
|
|
100
|
+
self.class.validate_dimensions!(names)
|
|
101
|
+
return [] unless profile.respond_to?(:discovery_dimensions)
|
|
102
|
+
|
|
103
|
+
dependencies = profile.discovery_dimensions(names)
|
|
104
|
+
unless profile.respond_to?(:expand) && dependencies.is_a?(Array) && dependencies.uniq == dependencies &&
|
|
105
|
+
(dependencies - DIMENSIONS).empty? && (dependencies - profile.commands.keys).empty?
|
|
106
|
+
raise ArgumentError, "Discovery requires unique fixed dimensions and an expansion method"
|
|
107
|
+
end
|
|
108
|
+
dependencies.dup.freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# 扩展有界不可变查询目录前,由平台策略校验来源证据完整性。
|
|
112
|
+
def expand(observations, dimensions:)
|
|
113
|
+
dependencies = discovery_dimensions(dimensions)
|
|
114
|
+
unless observations.is_a?(Hash) && observations.all? { |name, value| value.is_a?(Observation) && value.name == name }
|
|
115
|
+
raise ArgumentError, "Discovery requires named observations"
|
|
116
|
+
end
|
|
117
|
+
return self if dependencies.empty?
|
|
118
|
+
|
|
119
|
+
discovered = observations.slice(*dependencies).freeze
|
|
120
|
+
expanded = dup
|
|
121
|
+
expanded.instance_variable_set(:@discovery_observations, discovered)
|
|
122
|
+
candidate = profile.expand(discovered, dimensions: dimensions)
|
|
123
|
+
validate_expansion!(candidate, dimensions)
|
|
124
|
+
expanded.instance_variable_set(:@profile, candidate)
|
|
125
|
+
expanded.send(:validate_profile!)
|
|
126
|
+
expanded
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# 发现不可用时保留实际来源错误及证据,不据此声称硬件不受支持。
|
|
130
|
+
def unavailable_observation(name, discoveries: @discovery_observations, error: nil, code: nil, stage: :discovery)
|
|
131
|
+
dependencies = discovery_dimensions([ name ])
|
|
132
|
+
if dependencies.empty?
|
|
133
|
+
return Observation.new(name: name, status: :unsupported, issues: [ { code: :unsupported_dimension } ])
|
|
134
|
+
end
|
|
135
|
+
sources = discoveries.values_at(*dependencies).compact
|
|
136
|
+
issue = { code: code || (error ? :discovery_expansion_failed : :discovery_unavailable), stage: stage }
|
|
137
|
+
if error
|
|
138
|
+
type = error.class.name
|
|
139
|
+
issue[:error_type] = type.is_a?(String) && Observation::ERROR_TYPE_PATTERN.match?(type) ? type : "StandardError"
|
|
140
|
+
end
|
|
141
|
+
Observation.new(name: name, status: :failed, issues: [ issue, *sources.flat_map(&:issues) ].uniq,
|
|
142
|
+
evidence: sources.flat_map(&:evidence).uniq)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 拒绝空、重复或不支持的观测选择。
|
|
146
|
+
def self.validate_dimensions!(names)
|
|
147
|
+
unless names.is_a?(Array) && names.any? && names.uniq == names && (names - DIMENSIONS).empty?
|
|
148
|
+
raise ArgumentError, "Dimensions must be a nonempty unique list of supported symbols"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
# 发现结果仅可在边界内增加请求维度,不能替换已有命令契约。
|
|
155
|
+
def validate_expansion!(candidate, dimensions)
|
|
156
|
+
unless candidate.is_a?(CtxParser::Profile) && candidate.commands.is_a?(Hash)
|
|
157
|
+
raise ArgumentError, "Discovery must return a parser profile"
|
|
158
|
+
end
|
|
159
|
+
added = candidate.commands.keys - profile.commands.keys
|
|
160
|
+
allowed = dimensions.select { |name| discovery_dimensions([ name ]).any? }
|
|
161
|
+
raise ArgumentError, "Discovery added an unrelated dimension" unless (added - allowed).empty?
|
|
162
|
+
profile.commands.each_key do |name|
|
|
163
|
+
unless candidate.commands.key?(name) && candidate.commands_for(name) == profile.commands_for(name) &&
|
|
164
|
+
profile.commands_for(name).all? { |command| candidate.parser_for(name, command: command) == profile.parser_for(name, command: command) }
|
|
165
|
+
raise ArgumentError, "Discovery cannot replace existing commands or parsers"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
original = profile.commands.keys.flat_map { |name| profile.commands_for(name) }.uniq
|
|
169
|
+
if added.any? { |name| (candidate.commands_for(name) & original).any? }
|
|
170
|
+
raise ArgumentError, "Discovery cannot reuse an existing command for a new dimension"
|
|
171
|
+
end
|
|
172
|
+
expanded = candidate.commands.keys.flat_map { |name| candidate.commands_for(name) }.uniq
|
|
173
|
+
raise ArgumentError, "Discovery exceeds the command limit" if (expanded - original).size > MAX_DISCOVERED_QUERIES
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# 目录和型号错误在打开连接器前抛出,不进入运行失败隔离逻辑。
|
|
177
|
+
def validate_profile!
|
|
178
|
+
unless profile.commands.is_a?(Hash) && profile.commands.any? && (profile.commands.keys - DIMENSIONS).empty?
|
|
179
|
+
raise ArgumentError, "Checker profile must declare supported dimensions"
|
|
180
|
+
end
|
|
181
|
+
profile.commands.each_key do |name|
|
|
182
|
+
profile.commands_for(name).each do |command|
|
|
183
|
+
implementation = profile.parser_for(name, command: command)
|
|
184
|
+
unless implementation.is_a?(Class) && implementation < CtxParser::Parser
|
|
185
|
+
raise ArgumentError, "Command parser must inherit from Parser"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# 单个投影缺失不能使同一回显的其他视图失效。
|
|
192
|
+
def project(name, result, command:, command_count:, evidence:)
|
|
193
|
+
if result.is_a?(CtxParser::ResultSet)
|
|
194
|
+
begin
|
|
195
|
+
result = result.fetch(name)
|
|
196
|
+
rescue StandardError => error
|
|
197
|
+
code = error.is_a?(KeyError) ? :missing_parser_view : :parser_projection_failed
|
|
198
|
+
return Failure.observation(name, code: code, stage: :project, command: command, error: error, evidence: evidence)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
build_observation(name, result, command: command, command_count: command_count, evidence: evidence)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# 来源状态归属于单个脚本,即使归并维度仅部分完整也独立保留。
|
|
205
|
+
def build_observation(name, result, command:, command_count:, evidence:)
|
|
206
|
+
raise ArgumentError, "Command parser must return a Result or ResultSet" unless result.is_a?(CtxParser::Result)
|
|
207
|
+
|
|
208
|
+
status, issues = result.status, result.issues.dup
|
|
209
|
+
# 仅明确的全功能声明可证明不适用;局部空邻居表不能证明。
|
|
210
|
+
inactive = %i[aggregation bgp ospf].include?(name) && result.status == :partial && result.records.empty? && result.issues.any? &&
|
|
211
|
+
result.issues.all? { |issue| %i[protocol_disabled aggregation_not_configured].include?(issue[:code]) }
|
|
212
|
+
inactive ||= name == :aggregation && result.complete? && result.records.empty?
|
|
213
|
+
if inactive && command_count == 1
|
|
214
|
+
issues = [ { code: :aggregation_not_configured } ] if issues.empty?
|
|
215
|
+
return Observation.new(name: name, status: :not_applicable, issues: issues,
|
|
216
|
+
evidence: evidence.map { |entry| entry.merge(status: :complete) })
|
|
217
|
+
end
|
|
218
|
+
issues += [ { code: :incomplete_command_output } ] if status == :partial && issues.empty?
|
|
219
|
+
if command_count > 1
|
|
220
|
+
issues += [ { code: :incomplete_command_set, count: command_count - 1 } ]
|
|
221
|
+
status = :partial if status == :complete
|
|
222
|
+
end
|
|
223
|
+
metadata = { source_complete: result.complete? }
|
|
224
|
+
metadata[:source_command] = command if name == :counters
|
|
225
|
+
defaults = name == :counters ? { unit: :packets, scope: :interface } : {}
|
|
226
|
+
records = result.records.map { |record| defaults.merge(record).merge(metadata) }
|
|
227
|
+
Observation.new(name: name, status: status, records: records, issues: issues,
|
|
228
|
+
covered_scopes: result.is_a?(Parsers::ScopedResult) ? result.covered_scopes : [],
|
|
229
|
+
evidence: evidence.map { |entry| entry.merge(status: result.status) })
|
|
230
|
+
rescue StandardError => error
|
|
231
|
+
Failure.observation(name, code: :invalid_observation, stage: :validate, command: command, error: error, evidence: evidence)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "observation"
|
|
5
|
+
|
|
6
|
+
module CtxChecker
|
|
7
|
+
# 快照绑定目标及采样时刻;缺少的维度明确保持未请求。
|
|
8
|
+
class Snapshot
|
|
9
|
+
attr_reader :target, :observed_at, :observations, :issues
|
|
10
|
+
|
|
11
|
+
# 冻结设备身份、观测结果与采样时间。
|
|
12
|
+
def initialize(target:, observed_at:, observations:, issues: [])
|
|
13
|
+
unless target.is_a?(Hash) && (target.keys - %i[host platform model software_version id]).empty? &&
|
|
14
|
+
%i[host platform].all? { |key| target[key].is_a?(String) && !target[key].strip.empty? } &&
|
|
15
|
+
target.all? { |key, value| value.nil? || value.is_a?(String) && !/[\x00-\x1f\x7f]/.match?(value) || key == :id && value.is_a?(Integer) && value >= 0 }
|
|
16
|
+
raise ArgumentError, "Target requires host and platform without connection secrets"
|
|
17
|
+
end
|
|
18
|
+
unless observations.is_a?(Hash) && observations.all? { |name, row| Observation::NAMES.include?(name) && row.is_a?(Observation) && row.name == name }
|
|
19
|
+
raise ArgumentError, "Snapshot observations must match their dimensions"
|
|
20
|
+
end
|
|
21
|
+
unless issues.is_a?(Array) && issues.all? { |issue| Observation.valid_issue?(issue) }
|
|
22
|
+
raise ArgumentError, "Snapshot issues require safe codes"
|
|
23
|
+
end
|
|
24
|
+
@observed_at = case observed_at
|
|
25
|
+
when Time then observed_at.getutc.freeze
|
|
26
|
+
when String then Time.iso8601(observed_at).getutc.freeze
|
|
27
|
+
else raise ArgumentError, "Observation time must be Time or ISO8601"
|
|
28
|
+
end
|
|
29
|
+
@target = CtxParser::Immutable.copy(target)
|
|
30
|
+
@issues = CtxParser::Immutable.copy(issues)
|
|
31
|
+
@observations = Observation::NAMES.to_h { |name| [ name, observations.fetch(name) { Observation.new(name: name, status: :not_requested) } ] }.freeze
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 返回请求的观测,不虚构不可用记录。
|
|
36
|
+
def observation(name) = observations.fetch(name)
|
|
37
|
+
# 检查两次采样是否指向同一设备与平台。
|
|
38
|
+
def same_target?(other) = other.is_a?(Snapshot) && %i[host platform id].all? { |key| target[key] == other.target[key] }
|
|
39
|
+
# 返回公开的数据结构,并保留状态与诊断信息。
|
|
40
|
+
def to_h = { target: target, observed_at: observed_at.iso8601(6), observations: observations.transform_values(&:to_h), issues: issues }
|
|
41
|
+
# 展示对象身份与状态,避免泄露原始回显或凭据。
|
|
42
|
+
def inspect = "#<#{self.class} observations=#{observations.size}>"
|
|
43
|
+
end
|
|
44
|
+
end
|