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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "../../parsers/base"
5
+ require "ctx_parser/values"
6
+
7
+ module CtxChecker
8
+ module Hillstone
9
+ module Parsers
10
+ # 上次同步尝试与已配置对等体不能证明当前系统已同步。
11
+ class ShowNtpStatus < CtxChecker::Parsers::Base
12
+ private
13
+
14
+ # 逐对等体解析,同时保留系统同步证据缺失的状态。
15
+ def parse_output(text, complete:)
16
+ source = clean(text)
17
+ rows, clients, versions, settings = [], [], [], []
18
+ source.each_line.with_index(1) do |raw, number|
19
+ line = raw.strip
20
+ next if line.empty?
21
+
22
+ if (match = /\Antp client is (enabled|disabled), authentication is (enabled|disabled)\z/i.match(line))
23
+ clients << match[1].downcase
24
+ elsif (match = /\Antp version is ([1-4])\z/i.match(line))
25
+ versions << match[1]
26
+ elsif /\Antp query-interval is \d+(?: minute(?:s)?)?, max-adjustment time is \d+(?: seconds?)?\z/i.match?(line)
27
+ settings << line
28
+ elsif (match = /\Antp server (\S+?)(?:, (.+))?\z/i.match(line))
29
+ row = parse_peer(match[1], match[2], number)
30
+ rows << row if row
31
+ else
32
+ @issues << { code: :unrecognized_ntp_line, line: number }
33
+ end
34
+ end
35
+ @issues << { code: :duplicate_ntp_field, field: :version } if versions.size > 1
36
+ @issues << { code: :duplicate_ntp_field, field: :settings } if settings.size > 1
37
+ @issues << { code: :missing_ntp_status } if clients.empty?
38
+ @issues << { code: :duplicate_ntp_field, field: :client } if clients.size > 1
39
+ synchronized = false if clients == [ "disabled" ]
40
+ @issues << { code: :ntp_system_status_unavailable } unless synchronized == false
41
+ rows.unshift(component: "system", synchronized: synchronized) unless clients.empty?
42
+ build_result(rows, complete: complete, recognized: clients.any?, identity: [ :component ])
43
+ end
44
+
45
+ # 设备提供虚拟路由器时,将其纳入对等体身份。
46
+ def parse_peer(host, options, number)
47
+ unless valid_peer?(host)
48
+ @issues << { code: :invalid_ntp_peer, line: number }
49
+ return
50
+ end
51
+ fields = {}
52
+ Array(options&.split(/,\s*/)).each do |option|
53
+ key, value = peer_option(option, number)
54
+ next unless key
55
+ if fields.key?(key)
56
+ @issues << { code: :duplicate_ntp_field, field: key, line: number }
57
+ fields[key] = nil
58
+ else
59
+ fields[key] = value
60
+ end
61
+ end
62
+ peer = CtxParser::Values.valid_host?(host) ? CtxParser::Values.host(host) : host.downcase
63
+ { component: "peer/#{fields[:vrouter] || 'unspecified'}/#{peer}", peer: peer, synchronized: nil,
64
+ last_sync_status: fields[:last_sync_status] || :unknown, last_sync_at: nil }
65
+ end
66
+
67
+ # 未知选项作为诊断保留,不能误判为同步证据。
68
+ def peer_option(option, number)
69
+ if (match = /\Avrouter (\S+)\z/.match(option))
70
+ [ :vrouter, match[1] ]
71
+ elsif (match = /\Akey (\d+)\z/.match(option))
72
+ [ :key, match[1] ]
73
+ elsif option == "prefer"
74
+ [ :prefer, true ]
75
+ elsif (match = /\Alast sync: (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) \(([^)]+)\)\z/.match(option))
76
+ [ :last_sync_status, sync_attempt(match, number) ]
77
+ else
78
+ @issues << { code: :unrecognized_ntp_field, line: number }
79
+ nil
80
+ end
81
+ end
82
+
83
+ # 本地时间戳在确定偏移前保留为不可用于偏差评估的证据。
84
+ def sync_attempt(match, number)
85
+ Date.iso8601(match[1])
86
+ unless match[2].split(":").map(&:to_i).zip([ 24, 60, 60 ]).all? { |value, limit| value < limit }
87
+ raise ArgumentError, "Invalid local clock"
88
+ end
89
+ @issues << { code: :ntp_last_sync_offset_unknown, line: number }
90
+ return :failed if match[3] == "Error"
91
+ return :succeeded if match[3] == "Success"
92
+
93
+ @issues << { code: :unknown_ntp_last_sync_status, line: number }
94
+ :unknown
95
+ rescue ArgumentError
96
+ @issues << { code: :invalid_ntp_last_sync_time, line: number }
97
+ :unknown
98
+ end
99
+
100
+ # 服务器名称仅接受主机名或主机地址,不接受任意 CLI 片段。
101
+ def valid_peer?(text)
102
+ return true if CtxParser::Values.valid_host?(text)
103
+ return false if text.match?(/\A[\d.]+\z/) || text.length > 253
104
+
105
+ text.delete_suffix(".").split(".", -1).all? { |label| /\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\z/.match?(label) }
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../parsers/base"
4
+
5
+ module CtxChecker
6
+ module Hillstone
7
+ module Parsers
8
+ # 光模块读数保留模块自身的正常、警告或告警范围。
9
+ class ShowTransceiver < CtxChecker::Parsers::Base
10
+ NUMBER = /[+-]?\d+(?:\.\d+)?/
11
+ HEADER = /\ATransceiver status information is shown as below:\z/i
12
+ BLOCK = /\A=+\s+((?:x?ethernet)\d+(?:\/\d+)+)\s+=+\z/i
13
+ METADATA = /\A(?:SFP(?:\+\/XFP)? information:|Part Number:|Serial Number:|Transmission media:|Module temperature(?: (?:normal|warning|alarm) range)?:|Tx supply voltage(?: (?:normal|warning|alarm) range)?:|Tx bias current(?: (?:normal|warning|alarm) range)?:|Module Wavelength:|single module max transmit distance:|OM[123] max transmit distance:|OM4 or copper max transmit distance:)/i
14
+
15
+ private
16
+
17
+ # 逐模块隔离,避免未知块将测量值错误关联到前一模块。
18
+ def parse_output(text, complete:)
19
+ rows, issues, blocks, current, header = [], [], [], nil, false
20
+ clean(text).each_line do |raw|
21
+ line = raw.strip
22
+ next if line.empty? || /\A=+\z/.match?(line)
23
+
24
+ if HEADER.match?(line)
25
+ issues << { code: :duplicate_optics_header } if header
26
+ header = true
27
+ elsif (match = BLOCK.match(line))
28
+ current = { name: CtxChecker::Parsers::InterfaceNames.normalize(match[1], platform: :hillstone), lines: [] }
29
+ blocks << current
30
+ elsif line.start_with?("=")
31
+ current = nil
32
+ issues << { code: :unrecognized_optics_block }
33
+ elsif current
34
+ current[:lines] << line
35
+ else
36
+ issues << { code: :unrecognized_optics_line }
37
+ end
38
+ end
39
+ blocks.each { |block| rows.concat(measurements(block, issues)) }
40
+ issues << { code: :optics_header_missing } unless header
41
+ issues << { code: :optics_measurements_missing } if blocks.empty?
42
+ build_result(rows, complete: complete, recognized: header || blocks.any?, issues: issues,
43
+ identity: %i[interface lane metric])
44
+ end
45
+
46
+ # 缺失或重复测量保持不可用,不替换为零。
47
+ def measurements(block, issues)
48
+ name = block[:name]
49
+ values, limits, seen = {}, {}, Hash.new(0)
50
+ block[:lines].each do |line|
51
+ if (match = /\A(Tx|Rx) optical power:\s*(#{NUMBER}|N\/A|--)\s*dBm\z/i.match(line))
52
+ metric = metric(match[1])
53
+ set_value(values, metric, number(match[2]), seen, issues, name)
54
+ elsif (match = /\A(Tx|Rx) optical power (normal|warning|alarm) range:\s*\[\s*(#{NUMBER})\s*[,,]\s*(#{NUMBER})\s*\]\z/i.match(line))
55
+ metric = metric(match[1])
56
+ level = match[2].casecmp?("alarm") ? :alarm : :warning
57
+ low, high = number(match[3]), number(match[4])
58
+ limits[metric] ||= {}
59
+ %i[low high].zip([ low, high ]).each do |bound, value|
60
+ key = "#{bound}_#{level}".to_sym
61
+ set_value(limits[metric], key, value, seen, issues, name, identity: [ metric, key ])
62
+ end
63
+ elsif !METADATA.match?(line)
64
+ issues << { code: :unrecognized_optics_field, interface: name }
65
+ end
66
+ end
67
+ %i[tx_power rx_power].map do |metric|
68
+ issues << { code: :optics_measurement_missing, interface: name, metric: metric } unless seen[metric].positive?
69
+ { interface: name, lane: nil, metric: metric, value: values[metric], unit: :dbm,
70
+ low_alarm: nil, high_alarm: nil, low_warning: nil, high_warning: nil,
71
+ alarm: :unknown, present: true, dom_supported: values.key?(metric) ? true : nil }.merge(limits.fetch(metric, {}))
72
+ end
73
+ end
74
+
75
+ # 即使数值相同,重复测量也使该指标失效。
76
+ def set_value(values, key, value, seen, issues, name, identity: key)
77
+ seen[identity] += 1
78
+ if seen[identity] > 1
79
+ values[key] = nil
80
+ issues << { code: :duplicate_optics_value, interface: name }
81
+ else
82
+ values[key] = value
83
+ end
84
+ end
85
+
86
+ # 标准测量名称独立于厂商标签。
87
+ def metric(direction)
88
+ direction.casecmp?("Tx") ? :tx_power : :rx_power
89
+ end
90
+
91
+ # 解析有限读数,明确保留 DOM 值不可用的状态。
92
+ def number(value)
93
+ return unless /\A#{NUMBER}\z/.match?(value)
94
+
95
+ parsed = Float(value)
96
+ parsed if parsed.finite?
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ctx_parser/profile"
4
+ require_relative "parsers/show_interface"
5
+ require_relative "parsers/show_interface_counter"
6
+ require_relative "parsers/show_transceiver"
7
+ require_relative "parsers/show_ip_bgp_summary"
8
+ require_relative "parsers/show_ip_ospf_neighbor"
9
+ require_relative "parsers/show_cpu"
10
+ require_relative "parsers/show_memory"
11
+ require_relative "parsers/show_environment"
12
+ require_relative "parsers/show_clock"
13
+ require_relative "parsers/show_ntp_status"
14
+ require_relative "parsers/show_cpu_cntr"
15
+
16
+ module CtxChecker
17
+ module Hillstone
18
+ # StoneOS 命令已在 SG-6000-A3700 验证;未覆盖作用域明确保留不可用。
19
+ class Profile < CtxParser::Profile
20
+ CATALOG = CtxParser::CommandCatalog.new([
21
+ { dimensions: %i[interfaces aggregation], command: "show interface", parser: Parsers::ShowInterface },
22
+ { dimensions: [ :counters ], command: "show interface counter", parser: Parsers::ShowInterfaceCounter },
23
+ { dimensions: [ :optics ], command: "show transceiver", parser: Parsers::ShowTransceiver },
24
+ { dimensions: [ :bgp ], command: "show ip bgp summary", parser: Parsers::ShowIpBgpSummary },
25
+ { dimensions: [ :ospf ], command: "show ip ospf neighbor", parser: Parsers::ShowIpOspfNeighbor },
26
+ { dimensions: [ :cpu ], command: "show cpu", parser: Parsers::ShowCpu },
27
+ { dimensions: [ :memory ], command: "show memory", parser: Parsers::ShowMemory },
28
+ { dimensions: %i[temperature environment power], command: "show environment", parser: Parsers::ShowEnvironment },
29
+ { dimensions: [ :clock ], command: "show clock", parser: Parsers::ShowClock },
30
+ { dimensions: [ :ntp ], command: "show ntp status", parser: Parsers::ShowNtpStatus },
31
+ { dimensions: [ :device_counters ], command: "show cpu-cntr", parser: Parsers::ShowCpuCntr }
32
+ ])
33
+ COMMANDS = CATALOG.commands
34
+ PARSERS = CATALOG.parsers
35
+
36
+ # 在线接口丢弃表不提供错误包或 CRC 测量。
37
+ def counter_metrics = %i[in_errors out_errors crc_errors].freeze
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ctx_parser/immutable"
4
+ require "time"
5
+ require_relative "system_record"
6
+
7
+ module CtxChecker
8
+ # 提供经过校验的不可变运行事实,与后续健康评估相互独立。
9
+ class Observation
10
+ NAMES = (%i[interfaces counters aggregation optics bgp ospf] + SystemRecord::FIELDS.keys).freeze
11
+ STATUSES = %i[complete partial failed unsupported not_requested not_applicable].freeze
12
+ METRICS = %i[in_errors out_errors in_discards out_discards crc_errors stomped_crc_errors out_carrier_errors].freeze
13
+ COUNTER_UNITS = %i[packets bytes events unknown].freeze
14
+ COUNTER_SCOPES = %i[interface mac cpu logical queue unknown].freeze
15
+ OPTICAL_THRESHOLDS = %i[low_alarm low_warning high_warning high_alarm].freeze
16
+ FIELDS = CtxParser::Immutable.copy({
17
+ interfaces: %i[name kind admin_status oper_status protocol_status protocol_applicable parent],
18
+ counters: %i[interface metric value unit scope discontinuity sampled_at source_command],
19
+ aggregation: %i[group member member_status oper_status],
20
+ optics: %i[interface lane metric value unit low_alarm low_warning high_warning high_alarm alarm present dom_supported applicable],
21
+ bgp: %i[vrf afi_safi peer state received_prefixes remote_as],
22
+ ospf: %i[vrf process_id area interface router_id neighbor_address state network_type local_role neighbor_role], **SystemRecord::FIELDS
23
+ }.transform_values { |fields| fields + [ :source_complete ] })
24
+ IDENTITIES = CtxParser::Immutable.copy(interfaces: %i[name], counters: %i[interface metric],
25
+ aggregation: %i[group member], optics: %i[interface lane metric], bgp: %i[vrf afi_safi peer],
26
+ ospf: %i[vrf process_id interface router_id neighbor_address], **SystemRecord::IDENTITIES)
27
+ REQUIRED = CtxParser::Immutable.copy(interfaces: %i[name], counters: %i[interface metric],
28
+ aggregation: %i[group], optics: %i[interface metric], bgp: %i[peer], ospf: %i[router_id], **SystemRecord::REQUIRED)
29
+ ISSUE_FIELDS = %i[code line lines field count skipped interface metric metrics lane section expected observed group stage component scope window_seconds command error_type].freeze
30
+ ERROR_TYPE_PATTERN = /\A(?:[A-Z][A-Za-z0-9_]*::)*[A-Z][A-Za-z0-9_]*\z/
31
+ ENUMS = CtxParser::Immutable.copy(
32
+ kind: %i[physical aggregate bridge redundant svi loopback tunnel subinterface unknown],
33
+ admin_status: %i[up down unknown], oper_status: %i[up down unknown], protocol_status: %i[up down unknown],
34
+ member_status: %i[active standby down suspended unselected unknown], unit: %i[dbm],
35
+ alarm: %i[normal high_alarm high_warning low_warning low_alarm unknown],
36
+ network_type: %i[broadcast nbma point_to_point point_to_multipoint virtual_link unknown],
37
+ local_role: %i[dr bdr drother unknown], neighbor_role: %i[dr bdr drother unknown])
38
+ SCOPE_FIELDS = CtxParser::Immutable.copy(bgp: %i[vrf afi_safi], ospf: %i[vrf process_id interface])
39
+ attr_reader :name, :status, :records, :issues, :evidence, :covered_scopes
40
+
41
+ # 冻结单个维度的记录、状态及诊断元数据。
42
+ def initialize(name:, status:, records: [], issues: [], evidence: [], covered_scopes: [])
43
+ raise ArgumentError, "Unknown observation dimension" unless NAMES.include?(name)
44
+ raise ArgumentError, "Invalid observation status" unless STATUSES.include?(status)
45
+ raise ArgumentError, "Records must be an array" unless records.is_a?(Array)
46
+ if %i[failed unsupported not_requested not_applicable].include?(status) && records.any?
47
+ raise ArgumentError, "Unavailable observations cannot contain records"
48
+ end
49
+ @name, @status = name, status
50
+ validate_scopes!(covered_scopes)
51
+ normalized = records.map { |record| validate_record(record) }
52
+ identities = normalized.map { |record| identity(record) }
53
+ raise ArgumentError, "Duplicate observation identity" unless identities.uniq.size == identities.size
54
+ unless issues.is_a?(Array) && issues.all? { |issue| self.class.valid_issue?(issue) }
55
+ raise ArgumentError, "Observation issues require safe codes"
56
+ end
57
+ unless evidence.is_a?(Array) && evidence.all? { |entry| entry.is_a?(Hash) && (entry.keys - %i[command sha256 bytes reference observed_at status]).empty? }
58
+ raise ArgumentError, "Invalid observation evidence"
59
+ end
60
+ evidence.each do |entry|
61
+ raise ArgumentError, "Invalid command evidence status" if entry.key?(:status) && !%i[complete partial failed unsupported].include?(entry[:status])
62
+ raise ArgumentError, "Invalid evidence command" if entry.key?(:command) && !text?(entry[:command])
63
+ raise ArgumentError, "Invalid evidence digest" if entry.key?(:sha256) && !/\A[0-9a-f]{64}\z/.match?(entry[:sha256].to_s)
64
+ raise ArgumentError, "Invalid evidence byte count" if entry.key?(:bytes) && !(entry[:bytes].is_a?(Integer) && entry[:bytes] >= 0)
65
+ raise ArgumentError, "Invalid evidence reference" if entry.key?(:reference) && !text?(entry[:reference])
66
+ if entry.key?(:observed_at)
67
+ raise ArgumentError, "Invalid evidence time" unless entry[:observed_at].is_a?(String)
68
+ Time.iso8601(entry[:observed_at])
69
+ end
70
+ end
71
+ @records, @issues, @evidence = [ normalized, issues, evidence ].map { |value| CtxParser::Immutable.copy(value) }
72
+ @covered_scopes = CtxParser::Immutable.copy(covered_scopes)
73
+ @observed_at = evidence.filter_map { |entry| Time.iso8601(entry[:observed_at]).getutc if entry[:observed_at] }.max&.freeze
74
+ freeze
75
+ end
76
+
77
+ # 判断所有已选事实是否都有完整证据支持。
78
+ def complete? = status == :complete
79
+ # 返回当前观测关联的归一化时间戳。
80
+ def observed_at = @observed_at
81
+ # 返回用于关联同一资源多次采样的稳定字段。
82
+ def identity(record) = record.values_at(*IDENTITIES.fetch(name))
83
+ # 未增加作用域证明时,保持旧快照和计数器指纹稳定。
84
+ def to_h
85
+ value = { name: name, status: status, records: records, issues: issues, evidence: evidence }
86
+ covered_scopes.empty? ? value : value.merge(covered_scopes: covered_scopes)
87
+ end
88
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
89
+ def inspect = "#<#{self.class} name=#{name} status=#{status} records=#{records.size}>"
90
+
91
+ # 命令失败与快照失败共用安全诊断结构。
92
+ def self.valid_issue?(issue)
93
+ return false unless issue.is_a?(Hash) && (issue.keys - ISSUE_FIELDS).empty?
94
+
95
+ code = issue[:code]
96
+ return false unless (code.is_a?(Symbol) || code.is_a?(String)) && /\A[a-z][a-z0-9_]*\z/.match?(code.to_s)
97
+
98
+ !issue.key?(:error_type) || issue[:error_type].is_a?(String) && ERROR_TYPE_PATTERN.match?(issue[:error_type])
99
+ end
100
+
101
+ private
102
+
103
+ # 完整协议表仅覆盖其明确的 VRF、地址族或 OSPF 进程。
104
+ def validate_scopes!(scopes)
105
+ unless scopes.is_a?(Array) && scopes.uniq == scopes
106
+ raise ArgumentError, "Covered scopes must be a unique array"
107
+ end
108
+ return if scopes.empty?
109
+
110
+ fields = SCOPE_FIELDS[name]
111
+ required = name == :ospf ? %i[vrf process_id] : fields
112
+ unless fields && %i[complete partial].include?(status) && scopes.all? do |scope|
113
+ scope.is_a?(Hash) && (scope.keys - fields).empty? && (required - scope.keys).empty? &&
114
+ scope.all? { |key, value| text?(value) || key == :process_id && value.is_a?(Integer) && value >= 0 }
115
+ end
116
+ raise ArgumentError, "Covered scopes require explicit protocol identities"
117
+ end
118
+ end
119
+
120
+ # 测量缺失保留 nil;身份字段不能静默消失。
121
+ def validate_record(record)
122
+ unless record.is_a?(Hash) && (record.keys - FIELDS.fetch(name)).empty?
123
+ raise ArgumentError, "Unsupported observation record fields"
124
+ end
125
+ row = FIELDS.fetch(name).to_h { |key| [ key, record[key] ] }
126
+ raise ArgumentError, "Missing observation identity" unless REQUIRED.fetch(name).all? { |key| !row[key].nil? }
127
+ row.each do |key, value|
128
+ next if value.nil?
129
+ valid = if key == :source_complete
130
+ value == true || value == false
131
+ elsif SystemRecord::FIELDS.key?(name)
132
+ SystemRecord.valid?(name, key, value)
133
+ elsif key == :sampled_at
134
+ SystemRecord.timestamp?(value)
135
+ elsif name == :counters && key == :unit
136
+ COUNTER_UNITS.include?(value)
137
+ elsif name == :counters && key == :scope
138
+ COUNTER_SCOPES.include?(value)
139
+ elsif ENUMS.key?(key)
140
+ ENUMS.fetch(key).include?(value)
141
+ elsif key == :metric
142
+ (name == :optics ? %i[tx_power rx_power] : METRICS).include?(value)
143
+ elsif key == :state
144
+ states = name == :bgp ? %i[established idle active connect opensent openconfirm shutdown unknown] : %i[full two_way down attempt init exstart exchange loading unknown]
145
+ states.include?(value)
146
+ elsif %i[value low_alarm low_warning high_warning high_alarm].include?(key)
147
+ name == :counters ? value.is_a?(Integer) && value >= 0 : value.is_a?(Numeric) && value.real? && value.finite?
148
+ elsif %i[received_prefixes remote_as].include?(key)
149
+ value.is_a?(Integer) && value >= 0
150
+ elsif %i[present dom_supported applicable protocol_applicable].include?(key)
151
+ value == true || value == false
152
+ elsif key == :lane || key == :discontinuity || key == :process_id
153
+ value.is_a?(Integer) && value >= 0 || text?(value)
154
+ else
155
+ text?(value)
156
+ end
157
+ raise ArgumentError, "Invalid #{name} field #{key}" unless valid
158
+ end
159
+ row
160
+ end
161
+
162
+ # 校验非空文本,拒绝将含控制字符的值作为标识。
163
+ def text?(value) = value.is_a?(String) && !value.strip.empty? && !/[\x00-\x1f\x7f]/.match?(value)
164
+ end
165
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "observation"
4
+ require "ctx_parser/records"
5
+
6
+ module CtxChecker
7
+ # 组合独立命令证据,不拼接不相容的 CLI 语法正文。
8
+ class ObservationMerger
9
+ # 归并已声明的命令证据,不丢失来源失败或身份歧义。
10
+ def self.call(name, observations, commands:, required_metrics: [])
11
+ unless observations.is_a?(Array) && observations.all? { |observation| observation.is_a?(Observation) && observation.name == name }
12
+ raise ArgumentError, "Command observations must share a dimension"
13
+ end
14
+ sources = observations.map do |observation|
15
+ source = observation.evidence.map { |entry| entry[:command] }.compact.uniq
16
+ raise ArgumentError, "Observation must identify one declared source command" unless source.one? && commands.include?(source.first)
17
+ source.first
18
+ end
19
+ raise ArgumentError, "Duplicate source command" unless sources.uniq == sources
20
+
21
+ if observations.any? && observations.all? { |observation| observation.status == :not_applicable } && (commands - sources).empty?
22
+ return Observation.new(name: name, status: :not_applicable,
23
+ issues: observations.flat_map(&:issues).uniq, evidence: observations.flat_map(&:evidence))
24
+ end
25
+
26
+ issues = observations.zip(sources).flat_map do |observation, command|
27
+ observation.issues.reject { |issue| issue[:code] == :incomplete_command_set }.map { |issue| issue.merge(command: command) }
28
+ end
29
+ if observations.any? { |observation| observation.status == :partial && observation.issues.empty? }
30
+ issues << { code: :incomplete_command_output }
31
+ end
32
+ missing = commands - sources
33
+ issues << { code: :incomplete_command_set, count: missing.size } if missing.any?
34
+ records = observations.flat_map do |observation|
35
+ observation.records.map do |record|
36
+ if %i[counters device_counters].include?(name) && record[:sampled_at].nil? && observation.observed_at
37
+ record.merge(sampled_at: observation.observed_at.iso8601(6))
38
+ else
39
+ record
40
+ end
41
+ end
42
+ end
43
+ grouped = CtxParser::Records.new(records, identity: Observation::IDENTITIES.fetch(name))
44
+ covered_scopes = observations.flat_map(&:covered_scopes).uniq
45
+ if grouped.duplicates?
46
+ issues << { code: :duplicate_identity, count: grouped.duplicate_identity_count }
47
+ records = grouped.unambiguous.dup
48
+ # 隔离的对等体实际已被观察到,因此移除后不能再据其作用域证明缺失。
49
+ ambiguous = grouped.first_occurrences - grouped.unambiguous
50
+ covered_scopes = covered_scopes.reject do |scope|
51
+ ambiguous.any? { |row| scope.all? { |key, value| row[key].nil? || row[key] == value } }
52
+ end
53
+ end
54
+ if name == :counters && required_metrics.any?
55
+ # 补齐缺失指标的未知占位,不把独立方向查询失败解释为零错误。
56
+ records.group_by { |row| row[:interface] }.each do |interface, rows|
57
+ absent = required_metrics - rows.map { |row| row[:metric] }
58
+ next if absent.empty?
59
+ issues << { code: :counter_metrics_missing, interface: interface, metrics: absent }
60
+ records.concat(absent.map { |metric| { interface: interface, metric: metric, value: nil } })
61
+ end
62
+ end
63
+ unavailable = observations.all? { |observation| %i[failed unsupported].include?(observation.status) }
64
+ # 有成功事实时降为部分完整,而非让同级脚本失败抹除整份维度结果。
65
+ status = if unavailable && records.empty?
66
+ observations.any? && observations.all? { |observation| observation.status == :unsupported } && missing.empty? ? :unsupported : :failed
67
+ elsif issues.any? || observations.any? { |observation| %i[failed unsupported].include?(observation.status) }
68
+ :partial
69
+ else
70
+ :complete
71
+ end
72
+ if name == :aggregation && status == :complete && records.empty?
73
+ status = :not_applicable
74
+ issues << { code: :aggregation_not_configured }
75
+ end
76
+ Observation.new(name: name, status: status, records: records, issues: issues.uniq,
77
+ covered_scopes: covered_scopes, evidence: observations.flat_map(&:evidence))
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require_relative "../../parsers/base"
6
+
7
+ module CtxChecker
8
+ module PaloAlto
9
+ module Parsers
10
+ # 命名本地时区缺少明确偏移,不能用于安全判断 UTC 时钟偏差。
11
+ class ShowClock < CtxChecker::Parsers::Base
12
+ private
13
+
14
+ # 解析 show clock,保留不完整或不支持的证据。
15
+ def parse_output(text, complete:)
16
+ source = clean(text)
17
+ rows, issues = [], []
18
+ source.each_line.with_index(1) do |raw, number|
19
+ next if raw.strip.empty?
20
+ match = /\A(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+([A-Z][a-z]{2})\s+(\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+(\S+)\s+(\d{4})\z/.match(raw.strip)
21
+ unless match
22
+ issues << { code: :invalid_clock_output, line: number }
23
+ next
24
+ end
25
+ zone = %w[UTC GMT Z].include?(match[4]) ? "+0000" : match[4]
26
+ timestamp = clock_time(match, zone)
27
+ issues << { code: :clock_offset_unknown, line: number } unless timestamp
28
+ rows << { component: "system", time: timestamp, source: "system_clock", synchronized: nil }
29
+ end
30
+ build_result(rows, complete: complete, recognized: rows.any?, issues: issues, identity: [ :component ])
31
+ end
32
+
33
+ # 解析设备时钟,不虚构未知时区。
34
+ def clock_time(match, zone)
35
+ return unless /\A[+-]\d{2}:?\d{2}\z/.match?(zone)
36
+ month = Date::ABBR_MONTHNAMES.index(match[1])
37
+ year, day = Integer(match[5], 10), Integer(match[2], 10)
38
+ return unless month && Date.valid_date?(year, month, day)
39
+ hours, minutes, seconds = match[3].split(":").map { |part| Integer(part, 10) }
40
+ return unless hours < 24 && minutes < 60 && seconds < 60
41
+ offset = zone.delete(":")
42
+ return unless offset[1, 2].to_i < 24 && offset[3, 2].to_i < 60
43
+ Time.strptime("#{match[1]} #{day} #{match[3]} #{offset} #{year}", "%b %d %H:%M:%S %z %Y").iso8601
44
+ rescue ArgumentError
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../parsers/base"
4
+
5
+ module CtxChecker
6
+ module PaloAlto
7
+ module Parsers
8
+ # 数值自数据平面重启后累计;CLI 速率采样由外部调用方管理。
9
+ class ShowCounterGlobal < CtxChecker::Parsers::Base
10
+ CATEGORIES = { "info" => :informational, "warn" => :error, "error" => :error, "drop" => :discard }.freeze
11
+ # 安全策略执行与设备转发故障区分处理。
12
+ METRIC_CATEGORIES = { "flow_policy_deny" => :policy }.freeze
13
+
14
+ private
15
+
16
+ # 解析 show counter global,保留不完整或不支持的证据。
17
+ def parse_output(text, complete:)
18
+ source = clean(text)
19
+ rows, totals, issues = [], [], []
20
+ title, header = false, false
21
+ source.each_line.with_index(1) do |raw, number|
22
+ line = raw.strip
23
+ if line == "Global counters:"
24
+ title = true
25
+ elsif line.match?(/\Aname\s+value\s+rate\s+severity\s+category\s+aspect\s+description\z/)
26
+ header = true
27
+ elsif (match = /\ATotal counters shown:\s*(\d+)\z/.match(line))
28
+ totals << Integer(match[1], 10)
29
+ elsif line.empty? || line.match?(/\A-+\z/) || line.match?(/\AElapsed time since last sampling:\s*\d+(?:\.\d+)? seconds\z/)
30
+ next
31
+ elsif header && (match = /\A([a-zA-Z][a-zA-Z0-9_]*)\s+(\d+)\s+\d+\s+(info|warn|error|drop)\s+(\S+)\s+(\S+)\s+.+\z/.match(line))
32
+ rows << { component: "dataplane/#{match[4]}/#{match[5]}", metric: match[1], category: METRIC_CATEGORIES.fetch(match[1], CATEGORIES.fetch(match[3])),
33
+ value: Integer(match[2], 10), discontinuity: nil }
34
+ else
35
+ issues << { code: :unrecognized_counter_row, line: number }
36
+ end
37
+ end
38
+ if !totals.one? || totals.first != rows.size
39
+ issues << { code: :counter_total_mismatch, observed: rows.size, expected: totals.one? ? totals.first : nil }
40
+ end
41
+ recognized = title && header && (rows.any? || totals == [ 0 ])
42
+ build_result(rows, complete: complete, recognized: recognized, issues: issues, identity: %i[component metric])
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../parsers/base"
4
+
5
+ module CtxChecker
6
+ module PaloAlto
7
+ module Parsers
8
+ # CPU 接收错误属于 L2 至 L4 错误,不能替代 MAC CRC 错误。
9
+ class ShowCounterInterfaceAll < CtxChecker::Parsers::Base
10
+ METRICS = {
11
+ "receive errors" => :in_errors, "transmit errors" => :out_errors,
12
+ "receive discarded" => :in_discards, "receive packets dropped" => :in_discards,
13
+ "transmit packets dropped" => :out_discards
14
+ }.freeze
15
+ DETAIL = /\A(?:bytes (?:received|transmitted)|packets (?:received|transmitted|dropped(?: by flow state check)?|routed to different zone)|receive incoming errors|forwarding errors|no route|arp not found|neighbor (?:not found|info pending)|mac not found|land attacks|ping-of-death attacks|teardrop attacks|[im]ac spoof attacks|ip spoof attacks|ICMP fragment|layer2 (?:encapsulated|decapsulated) packets|multicast packets received|(?:tcp|udp|sctp|other) cps)\s+\d+\z/i
16
+
17
+ private
18
+
19
+ # 解析 show counter interface all,保留不完整或不支持的证据。
20
+ def parse_output(text, complete:)
21
+ text = clean(text)
22
+ rows, issues, interfaces = [], [], {}
23
+ text.split(/(?=^\s*Interface:)/i).each do |block|
24
+ next if block.strip.empty?
25
+ match = /\A\s*Interface:\s*([^\n]+)\n/i.match(block)
26
+ unless match
27
+ lines = block.lines.map(&:strip).reject { |line| line.empty? || line.match?(/\A-+\z/) }
28
+ issues << { code: :unrecognized_counter_block } unless lines == [ "Hardware interface counters:" ]
29
+ next
30
+ end
31
+ name = match[1].strip.casecmp?("Management Interface") ? "management" : match[1].strip.downcase
32
+ interfaces[name] = true
33
+ kind = CtxChecker::Parsers::InterfaceNames.kind(name)
34
+ wanted = kind == :physical ? :hardware : :logical
35
+ section, values = nil, []
36
+ block.lines.drop(1).each do |raw|
37
+ line = raw.strip
38
+ if (heading = /\A(Hardware|Logical) interface counters(?: read from CPU)?:\z/i.match(line))
39
+ section = heading[1].downcase.to_sym
40
+ elsif line.match?(/\A(?:Detailed )?Physical port counters read from MAC:\z/i)
41
+ section = :mac
42
+ elsif line.empty? || line.match?(/\A-+\z/)
43
+ next
44
+ elsif section == wanted && (metric = /\A(.+?)\s+(\d+)\z/.match(line)) && METRICS.key?(metric[1].downcase)
45
+ values << { interface: name, metric: METRICS.fetch(metric[1].downcase), value: Integer(metric[2], 10), discontinuity: nil,
46
+ unit: :packets, scope: section == :hardware ? :cpu : :logical }
47
+ elsif section == wanted && !DETAIL.match?(line)
48
+ issues << { code: :unrecognized_counter_field, interface: name }
49
+ elsif section.nil?
50
+ issues << { code: :counter_section_missing, interface: name }
51
+ end
52
+ end
53
+ rows.concat(values)
54
+ end
55
+ # 同一接口可能同时出现在硬件与逻辑清单中,须先确定其标准层级,
56
+ # 再补齐缺失指标,不能按每个展示块重复生成。
57
+ by_interface = rows.group_by { |row| row[:interface] }
58
+ interfaces.each_key do |name|
59
+ missing = %i[in_errors out_errors in_discards out_discards crc_errors] - by_interface.fetch(name, []).map { |row| row[:metric] }
60
+ missing.each { |metric| rows << { interface: name, metric: metric, value: nil, discontinuity: nil } }
61
+ issues << { code: :missing_counter_metrics, interface: name, metrics: missing } if missing.any?
62
+ end
63
+ build_result(rows, complete: complete, recognized: interfaces.any?, issues: issues, identity: %i[interface metric])
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end