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.
Files changed (130) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/COUNTER_CONTRACT.md +33 -0
  4. data/LICENSE +22 -0
  5. data/README-zh.md +93 -0
  6. data/README.md +93 -0
  7. data/docs/architecture-zh.md +487 -0
  8. data/docs/release.md +13 -0
  9. data/examples/offline.rb +17 -0
  10. data/lib/ctx_checker/applicability.rb +187 -0
  11. data/lib/ctx_checker/checks/aggregation.rb +73 -0
  12. data/lib/ctx_checker/checks/alarms.rb +25 -0
  13. data/lib/ctx_checker/checks/base.rb +66 -0
  14. data/lib/ctx_checker/checks/clock.rb +30 -0
  15. data/lib/ctx_checker/checks/counters.rb +77 -0
  16. data/lib/ctx_checker/checks/device_counters.rb +32 -0
  17. data/lib/ctx_checker/checks/hardware.rb +45 -0
  18. data/lib/ctx_checker/checks/interfaces.rb +35 -0
  19. data/lib/ctx_checker/checks/ntp.rb +47 -0
  20. data/lib/ctx_checker/checks/optics.rb +107 -0
  21. data/lib/ctx_checker/checks/resources.rb +63 -0
  22. data/lib/ctx_checker/checks/routing.rb +86 -0
  23. data/lib/ctx_checker/checks/temperature.rb +53 -0
  24. data/lib/ctx_checker/cisco_ios/parsers/show_clock_detail.rb +56 -0
  25. data/lib/ctx_checker/cisco_ios/parsers/show_environment_all.rb +153 -0
  26. data/lib/ctx_checker/cisco_ios/parsers/show_etherchannel_summary.rb +110 -0
  27. data/lib/ctx_checker/cisco_ios/parsers/show_facility_alarm_status.rb +41 -0
  28. data/lib/ctx_checker/cisco_ios/parsers/show_interfaces.rb +140 -0
  29. data/lib/ctx_checker/cisco_ios/parsers/show_interfaces_transceiver_detail.rb +107 -0
  30. data/lib/ctx_checker/cisco_ios/parsers/show_ip_bgp_summary.rb +164 -0
  31. data/lib/ctx_checker/cisco_ios/parsers/show_ip_ospf_neighbor.rb +96 -0
  32. data/lib/ctx_checker/cisco_ios/parsers/show_ip_traffic.rb +65 -0
  33. data/lib/ctx_checker/cisco_ios/parsers/show_memory_statistics.rb +36 -0
  34. data/lib/ctx_checker/cisco_ios/parsers/show_ntp_status.rb +44 -0
  35. data/lib/ctx_checker/cisco_ios/parsers/show_processes_cpu.rb +45 -0
  36. data/lib/ctx_checker/cisco_ios/parsers/system_values.rb +62 -0
  37. data/lib/ctx_checker/cisco_ios/profile.rb +39 -0
  38. data/lib/ctx_checker/cisco_nxos/parsers/show_bgp_ipv4_unicast_summary.rb +69 -0
  39. data/lib/ctx_checker/cisco_nxos/parsers/show_clock.rb +13 -0
  40. data/lib/ctx_checker/cisco_nxos/parsers/show_environment.rb +157 -0
  41. data/lib/ctx_checker/cisco_nxos/parsers/show_interface.rb +78 -0
  42. data/lib/ctx_checker/cisco_nxos/parsers/show_interface_transceiver_details.rb +141 -0
  43. data/lib/ctx_checker/cisco_nxos/parsers/show_ip_ospf_neighbors.rb +14 -0
  44. data/lib/ctx_checker/cisco_nxos/parsers/show_ip_traffic.rb +151 -0
  45. data/lib/ctx_checker/cisco_nxos/parsers/show_ntp_peer_status.rb +116 -0
  46. data/lib/ctx_checker/cisco_nxos/parsers/show_port_channel_summary.rb +21 -0
  47. data/lib/ctx_checker/cisco_nxos/parsers/show_system_resources.rb +89 -0
  48. data/lib/ctx_checker/cisco_nxos/profile.rb +35 -0
  49. data/lib/ctx_checker/collector.rb +175 -0
  50. data/lib/ctx_checker/evaluator.rb +49 -0
  51. data/lib/ctx_checker/failure.rb +24 -0
  52. data/lib/ctx_checker/finding.rb +19 -0
  53. data/lib/ctx_checker/h3c/parsers/counters_table.rb +56 -0
  54. data/lib/ctx_checker/h3c/parsers/display_alarm_active.rb +43 -0
  55. data/lib/ctx_checker/h3c/parsers/display_bgp_peer.rb +44 -0
  56. data/lib/ctx_checker/h3c/parsers/display_clock.rb +72 -0
  57. data/lib/ctx_checker/h3c/parsers/display_counters_inbound_interface.rb +21 -0
  58. data/lib/ctx_checker/h3c/parsers/display_counters_outbound_interface.rb +21 -0
  59. data/lib/ctx_checker/h3c/parsers/display_cpu_usage.rb +64 -0
  60. data/lib/ctx_checker/h3c/parsers/display_environment.rb +74 -0
  61. data/lib/ctx_checker/h3c/parsers/display_fan.rb +72 -0
  62. data/lib/ctx_checker/h3c/parsers/display_interface.rb +127 -0
  63. data/lib/ctx_checker/h3c/parsers/display_link_aggregation_verbose.rb +72 -0
  64. data/lib/ctx_checker/h3c/parsers/display_memory.rb +132 -0
  65. data/lib/ctx_checker/h3c/parsers/display_ntp_service_status.rb +84 -0
  66. data/lib/ctx_checker/h3c/parsers/display_ospf_peer.rb +47 -0
  67. data/lib/ctx_checker/h3c/parsers/display_power.rb +63 -0
  68. data/lib/ctx_checker/h3c/parsers/display_transceiver_diagnosis.rb +109 -0
  69. data/lib/ctx_checker/h3c/parsers/system_values.rb +46 -0
  70. data/lib/ctx_checker/h3c/profile.rb +49 -0
  71. data/lib/ctx_checker/hillstone/parsers/show_clock.rb +68 -0
  72. data/lib/ctx_checker/hillstone/parsers/show_cpu.rb +44 -0
  73. data/lib/ctx_checker/hillstone/parsers/show_cpu_cntr.rb +114 -0
  74. data/lib/ctx_checker/hillstone/parsers/show_environment.rb +90 -0
  75. data/lib/ctx_checker/hillstone/parsers/show_interface.rb +82 -0
  76. data/lib/ctx_checker/hillstone/parsers/show_interface_counter.rb +53 -0
  77. data/lib/ctx_checker/hillstone/parsers/show_ip_bgp_summary.rb +22 -0
  78. data/lib/ctx_checker/hillstone/parsers/show_ip_ospf_neighbor.rb +21 -0
  79. data/lib/ctx_checker/hillstone/parsers/show_memory.rb +85 -0
  80. data/lib/ctx_checker/hillstone/parsers/show_ntp_status.rb +110 -0
  81. data/lib/ctx_checker/hillstone/parsers/show_transceiver.rb +101 -0
  82. data/lib/ctx_checker/hillstone/profile.rb +40 -0
  83. data/lib/ctx_checker/observation.rb +165 -0
  84. data/lib/ctx_checker/observation_merger.rb +80 -0
  85. data/lib/ctx_checker/palo_alto/parsers/show_clock.rb +50 -0
  86. data/lib/ctx_checker/palo_alto/parsers/show_counter_global.rb +47 -0
  87. data/lib/ctx_checker/palo_alto/parsers/show_counter_interface_all.rb +68 -0
  88. data/lib/ctx_checker/palo_alto/parsers/show_interface_all.rb +70 -0
  89. data/lib/ctx_checker/palo_alto/parsers/show_lacp_aggregate_ethernet.rb +52 -0
  90. data/lib/ctx_checker/palo_alto/parsers/show_ntp.rb +78 -0
  91. data/lib/ctx_checker/palo_alto/parsers/show_routing_protocol_bgp_peer.rb +66 -0
  92. data/lib/ctx_checker/palo_alto/parsers/show_routing_protocol_ospf_neighbor.rb +54 -0
  93. data/lib/ctx_checker/palo_alto/parsers/show_system_environmentals.rb +142 -0
  94. data/lib/ctx_checker/palo_alto/parsers/show_system_resources.rb +102 -0
  95. data/lib/ctx_checker/palo_alto/parsers/show_system_state_alarms.rb +54 -0
  96. data/lib/ctx_checker/palo_alto/parsers/show_transceiver_all.rb +44 -0
  97. data/lib/ctx_checker/palo_alto/profile.rb +39 -0
  98. data/lib/ctx_checker/parser_registry.rb +43 -0
  99. data/lib/ctx_checker/parsers/base.rb +68 -0
  100. data/lib/ctx_checker/parsers/interface_names.rb +65 -0
  101. data/lib/ctx_checker/parsers/scoped_result.rb +35 -0
  102. data/lib/ctx_checker/policy.rb +104 -0
  103. data/lib/ctx_checker/radware_alteon/parsers/info_l2_lacp_dump.rb +48 -0
  104. data/lib/ctx_checker/radware_alteon/parsers/info_l2_trunk.rb +46 -0
  105. data/lib/ctx_checker/radware_alteon/parsers/info_l3_bgp_summary.rb +22 -0
  106. data/lib/ctx_checker/radware_alteon/parsers/info_l3_ip.rb +71 -0
  107. data/lib/ctx_checker/radware_alteon/parsers/info_l3_ospf_nbr.rb +22 -0
  108. data/lib/ctx_checker/radware_alteon/parsers/info_link.rb +44 -0
  109. data/lib/ctx_checker/radware_alteon/parsers/info_sys_fan.rb +47 -0
  110. data/lib/ctx_checker/radware_alteon/parsers/info_sys_general.rb +48 -0
  111. data/lib/ctx_checker/radware_alteon/parsers/info_sys_ps.rb +34 -0
  112. data/lib/ctx_checker/radware_alteon/parsers/info_sys_temp.rb +53 -0
  113. data/lib/ctx_checker/radware_alteon/parsers/info_transceiver.rb +68 -0
  114. data/lib/ctx_checker/radware_alteon/parsers/stats_mp_cpu.rb +39 -0
  115. data/lib/ctx_checker/radware_alteon/parsers/stats_mp_mem.rb +58 -0
  116. data/lib/ctx_checker/radware_alteon/parsers/stats_port_ether.rb +67 -0
  117. data/lib/ctx_checker/radware_alteon/parsers/stats_port_if.rb +74 -0
  118. data/lib/ctx_checker/radware_alteon/parsers/stats_sp_allutils.rb +99 -0
  119. data/lib/ctx_checker/radware_alteon/parsers/stats_sys_ntp.rb +86 -0
  120. data/lib/ctx_checker/radware_alteon/port_counter_parser.rb +34 -0
  121. data/lib/ctx_checker/radware_alteon/profile.rb +145 -0
  122. data/lib/ctx_checker/radware_alteon.rb +8 -0
  123. data/lib/ctx_checker/report.rb +112 -0
  124. data/lib/ctx_checker/runtime_parser.rb +234 -0
  125. data/lib/ctx_checker/snapshot.rb +44 -0
  126. data/lib/ctx_checker/system_policy.rb +70 -0
  127. data/lib/ctx_checker/system_record.rb +81 -0
  128. data/lib/ctx_checker/version.rb +6 -0
  129. data/lib/ctx_checker.rb +17 -0
  130. 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CtxChecker
4
+ # 打开厂商命名空间时,不从子解析器提前加载平台策略。
5
+ module RadwareAlteon
6
+ autoload(:Profile, File.expand_path("radware_alteon/profile", __dir__)) unless const_defined?(:Profile, false)
7
+ end
8
+ 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