recheck 0.0.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/recheck +8 -0
- data/lib/recheck/checkers.rb +33 -0
- data/lib/recheck/cli.rb +47 -0
- data/lib/recheck/commands.rb +312 -0
- data/lib/recheck/count_stats.rb +66 -0
- data/lib/recheck/reporters.rb +277 -0
- data/lib/recheck/results.rb +21 -0
- data/lib/recheck/runner.rb +170 -0
- data/lib/recheck/version.rb +5 -0
- data/lib/recheck.rb +20 -0
- data/template/recheck_helper.rb +18 -0
- data/template/regression_checker_sample.rb +206 -0
- data/template/reporter_sample.rb +178 -0
- data/template/site/dns_checker.rb +31 -0
- data/template/site/tls_checker.rb +51 -0
- data/template/site/whois_checker.rb +25 -0
- data/vendor/optimist.rb +1361 -0
- metadata +68 -8
@@ -0,0 +1,206 @@
|
|
1
|
+
# Recheck Checker API Documentation
|
2
|
+
#
|
3
|
+
# This file documents the complete Checker API for Recheck.
|
4
|
+
# Use this as a reference when creating your own checkers.
|
5
|
+
|
6
|
+
# All checkers must inherit from Recheck::Checker::Base to be registered
|
7
|
+
class SampleChecker < Recheck::Checker::Base
|
8
|
+
#
|
9
|
+
# CHECKER LIFECYCLE AND HOOKS
|
10
|
+
# ==========================
|
11
|
+
#
|
12
|
+
# Checkers have a simple lifecycle with four hooks:
|
13
|
+
#
|
14
|
+
# 1. initialize - Optional setup
|
15
|
+
# 2. query_* methods - Required to fetch records to check
|
16
|
+
# 3. check_* methods - Required to validate each record
|
17
|
+
# 4. Metadata methods - Optional for routing and prioritization
|
18
|
+
#
|
19
|
+
|
20
|
+
# Hook 1: initialize (optional)
|
21
|
+
# ----------------------------
|
22
|
+
# Use initialize to set up any resources needed by your checks.
|
23
|
+
# This runs once when the checker is instantiated.
|
24
|
+
def initialize
|
25
|
+
# You can:
|
26
|
+
# - Connect to external services
|
27
|
+
# - Load reference data
|
28
|
+
# - Set up caches or other shared resources
|
29
|
+
# - Configure the checker based on environment
|
30
|
+
|
31
|
+
@external_service = ExternalService.new(api_key: ENV["API_KEY"])
|
32
|
+
@reference_data = load_reference_data
|
33
|
+
@cache = {}
|
34
|
+
end
|
35
|
+
|
36
|
+
# You can use helper methods to organize your code
|
37
|
+
def load_reference_data
|
38
|
+
# Load data from a file, database, or API
|
39
|
+
# This is just a helper method, not part of the Checker API
|
40
|
+
{}
|
41
|
+
end
|
42
|
+
|
43
|
+
# Hook 2: query_* methods (one required)
|
44
|
+
# ---------------------------------
|
45
|
+
# At least one query method is required. Query methods must:
|
46
|
+
# - Start with "query" (or they won't be detected)
|
47
|
+
# - Return an Enumerable of records to check
|
48
|
+
# - Be efficient (they often run against production databases)
|
49
|
+
|
50
|
+
# Basic query method - returns all records of a type
|
51
|
+
def query
|
52
|
+
# The simplest query just returns all records
|
53
|
+
# Use find_each with ActiveRecord for batching
|
54
|
+
Model.find_each
|
55
|
+
end
|
56
|
+
|
57
|
+
# You can have multiple query methods to:
|
58
|
+
# - Check different record types
|
59
|
+
# - Optimize performance with targeted queries
|
60
|
+
# - Focus on specific subsets of data
|
61
|
+
def query_recent
|
62
|
+
# Focus on recently created/updated records
|
63
|
+
Model.where("updated_at > ?", 1.day.ago).find_each
|
64
|
+
end
|
65
|
+
|
66
|
+
def query_problematic
|
67
|
+
# Target records that might have issues
|
68
|
+
Model.where(status: "error")
|
69
|
+
.or(Model.where(processed_at: nil))
|
70
|
+
.includes(:related_records) # Eager load associations
|
71
|
+
.find_each
|
72
|
+
end
|
73
|
+
|
74
|
+
# Query methods can return any Enumerable, not just ActiveRecord
|
75
|
+
def query_external_data
|
76
|
+
# You can check data from external sources
|
77
|
+
@external_service.fetch_records.map do |record|
|
78
|
+
# Transform external data if needed
|
79
|
+
{id: record["id"], data: record["payload"]}
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Query methods can return arrays, hashes, or custom objects
|
84
|
+
def query_composite
|
85
|
+
# You can join data from multiple sources
|
86
|
+
[
|
87
|
+
{type: "config", value: AppConfig.settings},
|
88
|
+
{type: "status", value: SystemStatus.current}
|
89
|
+
]
|
90
|
+
end
|
91
|
+
|
92
|
+
# Hook 3: check_* methods (one required)
|
93
|
+
# ---------------------------------
|
94
|
+
# Check methods must:
|
95
|
+
# - Start with "check_" (or they won't be detected)
|
96
|
+
# - Take a single record parameter
|
97
|
+
# - Return false/nil for failing records, anything else for passing
|
98
|
+
|
99
|
+
# Basic check - validates a single aspect of a record
|
100
|
+
def check_record_is_valid(record)
|
101
|
+
# The simplest check just calls ActiveRecord validations
|
102
|
+
# Returns false if invalid, true if valid
|
103
|
+
record.valid?
|
104
|
+
end
|
105
|
+
|
106
|
+
# Checks can implement complex business rules
|
107
|
+
def check_business_rule(record)
|
108
|
+
# Implement any business logic
|
109
|
+
# Return false/nil for failing records
|
110
|
+
if record.status == "completed" && record.completed_at.nil?
|
111
|
+
# This is a failing condition
|
112
|
+
return false
|
113
|
+
end
|
114
|
+
|
115
|
+
# Any non-false/nil return is considered passing
|
116
|
+
true
|
117
|
+
end
|
118
|
+
|
119
|
+
# Checks can integrate with external systems
|
120
|
+
def check_external_consistency(record)
|
121
|
+
# Check that record matches external system
|
122
|
+
external_data = @external_service.find(record.external_id)
|
123
|
+
|
124
|
+
# Return false if inconsistent
|
125
|
+
return false if external_data.nil?
|
126
|
+
return false if external_data["status"] != record.status
|
127
|
+
|
128
|
+
# Return true if consistent
|
129
|
+
true
|
130
|
+
end
|
131
|
+
|
132
|
+
# Checks can automatically fix issues (use carefully!)
|
133
|
+
def check_and_fix(record)
|
134
|
+
# Check if there's an issue
|
135
|
+
if record.calculated_total != record.stored_total
|
136
|
+
# Fix the issue
|
137
|
+
record.stored_total = record.calculated_total
|
138
|
+
record.save!
|
139
|
+
|
140
|
+
# Log the fix
|
141
|
+
LoggingService.info("Fixed total for record #{record.id}")
|
142
|
+
end
|
143
|
+
|
144
|
+
# Return true since we've fixed the issue
|
145
|
+
true
|
146
|
+
end
|
147
|
+
|
148
|
+
# Checks can handle different record types from different queries
|
149
|
+
def check_config_is_valid(record)
|
150
|
+
# Handle records from query_composite
|
151
|
+
if record[:type] == "config"
|
152
|
+
# Check config settings
|
153
|
+
return record[:value].valid?
|
154
|
+
elsif record[:type] == "status"
|
155
|
+
# Check system status
|
156
|
+
return record[:value].ok?
|
157
|
+
end
|
158
|
+
|
159
|
+
# Skip records this check doesn't understand
|
160
|
+
true
|
161
|
+
end
|
162
|
+
|
163
|
+
#
|
164
|
+
# METADATA METHODS
|
165
|
+
# ===============
|
166
|
+
#
|
167
|
+
# These optional methods provide metadata about your checker
|
168
|
+
# for use by reporters and the Recheck runner.
|
169
|
+
#
|
170
|
+
|
171
|
+
# Team responsible for this checker
|
172
|
+
# Used by reporters to route notifications
|
173
|
+
def team
|
174
|
+
:data_integrity
|
175
|
+
end
|
176
|
+
|
177
|
+
# Priority of this checker
|
178
|
+
# Used by reporters to prioritize notifications
|
179
|
+
def priority
|
180
|
+
:high # :high, :medium, :low
|
181
|
+
end
|
182
|
+
|
183
|
+
# Tags for this checker
|
184
|
+
# Used for filtering and categorization
|
185
|
+
def tags
|
186
|
+
[:critical, :customer_facing]
|
187
|
+
end
|
188
|
+
|
189
|
+
# Documentation URL
|
190
|
+
# Link to runbook or documentation
|
191
|
+
def documentation_url
|
192
|
+
"https://internal-docs.example.com/data-integrity/sample-checker"
|
193
|
+
end
|
194
|
+
|
195
|
+
# Slack channel for notifications
|
196
|
+
# Used by SlackReporter
|
197
|
+
def slack_channel
|
198
|
+
"#data-alerts"
|
199
|
+
end
|
200
|
+
|
201
|
+
# Email recipients for notifications
|
202
|
+
# Perhaps used by EmailReporter
|
203
|
+
def email_recipients
|
204
|
+
["data-team@example.com", "oncall@example.com"]
|
205
|
+
end
|
206
|
+
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Reporters are how you turn failing checks into emails, bug tracker tickets,
|
4
|
+
# or any other useful notification or report. You can notify different teams
|
5
|
+
# however they most "enjoy" hearing about bad data.
|
6
|
+
|
7
|
+
class SampleReporter < Recheck::Reporter::Base
|
8
|
+
# Optional but strongly recommended: Provide help text that appears when running `recheck
|
9
|
+
# reporters`. This should briefly explain what your reporter does and any argument it takes.
|
10
|
+
def self.help
|
11
|
+
"A template reporter showing how to implement your own custom reporter. Takes an optional argument."
|
12
|
+
end
|
13
|
+
|
14
|
+
# Optional: Initialize with the argument string from the command line.
|
15
|
+
# The arg is passed as a single string, or nil if no arg is provided
|
16
|
+
# Arguments:
|
17
|
+
# * arg: The string argument passed after the colon in the reporter specification
|
18
|
+
# For example: `--reporter SampleReporter:api_key_123` -> `api_key_123`
|
19
|
+
def initialize(arg:)
|
20
|
+
# Process your argument here if needed
|
21
|
+
@config = arg || "default_config"
|
22
|
+
end
|
23
|
+
|
24
|
+
# Important: If you define an around_ hook, it must 'yield' to run the next step.
|
25
|
+
# A hook method's return value is not used.
|
26
|
+
|
27
|
+
# around_run: Fires around the entire run.
|
28
|
+
# This is a good place to set up resources, send summary emails, etc.
|
29
|
+
# Arguments:
|
30
|
+
# * checkers: Array of checker instances that will be run
|
31
|
+
def around_run(checkers: [])
|
32
|
+
# Setup before the run
|
33
|
+
start_time = Time.now
|
34
|
+
|
35
|
+
# yield returns: a CountStats about the entire run
|
36
|
+
# CountStats tracks counts of passes, failures, and other result types
|
37
|
+
# It provides methods like #all_pass?, #any_errors?, #summary, etc.
|
38
|
+
total_counts = yield
|
39
|
+
|
40
|
+
# Teardown/reporting after the run
|
41
|
+
duration = Time.now - start_time
|
42
|
+
|
43
|
+
# Example of how you might report results
|
44
|
+
if total_counts.any_errors?
|
45
|
+
puts "SampleReporter: Found errors in #{duration.round(2)}s: #{total_counts.summary}"
|
46
|
+
# In a real reporter, you might:
|
47
|
+
# - Send an email
|
48
|
+
# - Post to Slack
|
49
|
+
# - Create a ticket in your issue tracker
|
50
|
+
# - Log to a monitoring service
|
51
|
+
end
|
52
|
+
|
53
|
+
# Return the counts (optional)
|
54
|
+
total_counts
|
55
|
+
end
|
56
|
+
|
57
|
+
# around_checker: Fires around each checker.
|
58
|
+
# Arguments:
|
59
|
+
# * checker: The checker instance being run
|
60
|
+
# * queries: Array of query method names defined on the checker
|
61
|
+
# * checks: Array of check method names defined on the checker
|
62
|
+
def around_checker(checker:, queries: [], checks: [])
|
63
|
+
# Before running this checker
|
64
|
+
checker_name = checker.class.name
|
65
|
+
|
66
|
+
# yields returns a CountStats for the checker
|
67
|
+
counts = yield
|
68
|
+
|
69
|
+
# After running this checker
|
70
|
+
if counts.any_errors?
|
71
|
+
puts "SampleReporter: Checker #{checker_name} had issues: #{counts.summary}"
|
72
|
+
# In a real reporter, you might group errors by checker
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# around_query: Fires around each query
|
77
|
+
# This is useful for tracking which queries are slow or problematic
|
78
|
+
# Arguments:
|
79
|
+
# * checker: The checker instance being run
|
80
|
+
# * query: The name of the query method being executed
|
81
|
+
# * checks: Array of check method names that will be run against query results
|
82
|
+
def around_query(checker:, query:, checks: [])
|
83
|
+
# Before running this query
|
84
|
+
query_start = Time.now
|
85
|
+
|
86
|
+
# yield does not return anything for this hook
|
87
|
+
yield
|
88
|
+
|
89
|
+
# After running this query
|
90
|
+
query_duration = Time.now - query_start
|
91
|
+
if query_duration > 5 # seconds
|
92
|
+
puts "SampleReporter: Slow query #{checker.class.name}##{query} took #{query_duration.round(2)}s"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# The around_check and halt hooks both receive one of two result objects.
|
97
|
+
#
|
98
|
+
# Recheck::Pass: A successful check.
|
99
|
+
# Attributes:
|
100
|
+
# #type: always :pass
|
101
|
+
#
|
102
|
+
# Recheck::Error: A failed check.
|
103
|
+
# Attributes:
|
104
|
+
# #type: One of the following symbols
|
105
|
+
# fail: The check returned a falsey value
|
106
|
+
# exception: The check raised an exception
|
107
|
+
# blanket: The first 20 checks all failed or raised; the runner skips
|
108
|
+
# no_query_methods: The checker does not define a query_methods
|
109
|
+
# no_queries: The checker defines query_methods, but did not return any
|
110
|
+
# no_check_methods: The checker does not define a check_methods
|
111
|
+
# no_checks: The checker defines check_methods, but did not return any
|
112
|
+
# #checker: Checker instance
|
113
|
+
# #query: Query method name
|
114
|
+
# #check: Check method name
|
115
|
+
# #record: The record being checked
|
116
|
+
# #exception: rescued Exception
|
117
|
+
#
|
118
|
+
|
119
|
+
# around_check: Fires for each call to a check_ method on each record
|
120
|
+
# This is where you can collect detailed information about failures.
|
121
|
+
# Arguments:
|
122
|
+
# * checker: The checker instance being run
|
123
|
+
# * query: The name of the query method that produced this record
|
124
|
+
# * check: The name of the check method being executed
|
125
|
+
# * record: The individual record being checked
|
126
|
+
def around_check(checker:, query:, check:, record:)
|
127
|
+
# Returns a result object, see comment above
|
128
|
+
result = yield
|
129
|
+
|
130
|
+
# Process the result
|
131
|
+
if result.is_a?(Recheck::Error)
|
132
|
+
case result.type
|
133
|
+
when :fail
|
134
|
+
record_id = fetch_record_id(result.record)
|
135
|
+
puts "SampleReporter: #{checker.class.name}##{check} failed for record: #{record_id}"
|
136
|
+
when :exception
|
137
|
+
puts "SampleReporter: #{checker.class.name}##{check} raised exception: #{result.exception.message}"
|
138
|
+
end
|
139
|
+
|
140
|
+
# In a real reporter, you might:
|
141
|
+
# - Collect failures to report in a batch
|
142
|
+
# - Send immediate alerts for critical failures
|
143
|
+
# - Log to a monitoring system
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# halt: Called when a checker is halted due to an error.
|
148
|
+
# This is useful for reporting fatal errors that prevent checks from running
|
149
|
+
# Arguments:
|
150
|
+
# * checker: The checker instance that was halted
|
151
|
+
# * query: The name of the query method that was running (if any)
|
152
|
+
# * check: The name of the check method that was running (if any)
|
153
|
+
# * error: The error result, see comment above around_check
|
154
|
+
def halt(checker:, query:, error:, check: nil)
|
155
|
+
puts "SampleReporter: Halted #{checker.class.name}##{query} due to #{error.type}"
|
156
|
+
|
157
|
+
# In a real reporter, you might:
|
158
|
+
# - Send an urgent alert
|
159
|
+
# - Create a high-priority ticket
|
160
|
+
# - Log a critical error
|
161
|
+
end
|
162
|
+
|
163
|
+
# You can add any helper methods, include modules, etc.
|
164
|
+
|
165
|
+
# Example of a helper method to format data for reporting
|
166
|
+
def format_error_message(error)
|
167
|
+
case error.type
|
168
|
+
when :fail
|
169
|
+
"Record is invalid"
|
170
|
+
when :exception
|
171
|
+
"Exception occurred: #{error.exception.message}"
|
172
|
+
when :blanket
|
173
|
+
"Blanket failure - first 20 checks all failed"
|
174
|
+
else
|
175
|
+
"Other error: #{error.type}"
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require "resolv"
|
2
|
+
|
3
|
+
class EmailRecordsChecker < Recheck::Checker::Base
|
4
|
+
def query
|
5
|
+
# domains you send email from
|
6
|
+
[]
|
7
|
+
end
|
8
|
+
|
9
|
+
def check_mx_records(domain)
|
10
|
+
mx_records = Resolv::DNS.open do |dns|
|
11
|
+
dns.getresources(domain, Resolv::DNS::Resource::IN::MX)
|
12
|
+
end
|
13
|
+
!mx_records.empty?
|
14
|
+
end
|
15
|
+
|
16
|
+
def check_soa_record(domain)
|
17
|
+
soa_record = Resolv::DNS.open do |dns|
|
18
|
+
dns.getresource(domain, Resolv::DNS::Resource::IN::SOA)
|
19
|
+
end
|
20
|
+
!soa_record.nil?
|
21
|
+
rescue Resolv::ResolvError
|
22
|
+
false
|
23
|
+
end
|
24
|
+
|
25
|
+
def check_spf_record(domain)
|
26
|
+
txt_records = Resolv::DNS.open do |dns|
|
27
|
+
dns.getresources(domain, Resolv::DNS::Resource::IN::TXT)
|
28
|
+
end
|
29
|
+
txt_records.any? { |record| record.strings.first.start_with?("v=spf1") }
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require "openssl"
|
2
|
+
require "socket"
|
3
|
+
require "uri"
|
4
|
+
|
5
|
+
class TlsChecker < Recheck::Checker::Base
|
6
|
+
def query
|
7
|
+
# array of domains you host web servers on
|
8
|
+
[].map do |domain|
|
9
|
+
cert = fetch_certificate(domain)
|
10
|
+
{domain: domain, cert: cert}
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def check_not_expiring_soon(record)
|
15
|
+
expiration_date = record[:cert].not_after
|
16
|
+
days_until_expiration = (expiration_date - Time.now) / (24 * 60 * 60)
|
17
|
+
days_until_expiration > 30
|
18
|
+
end
|
19
|
+
|
20
|
+
def check_cert_matches_domain(record)
|
21
|
+
cert = record[:cert]
|
22
|
+
domain = record[:domain]
|
23
|
+
cert.subject.to_a.any? { |name, value| name == "CN" && (value == domain || value == "*.#{domain}") } ||
|
24
|
+
cert.extensions.any? { |ext| ext.oid == "subjectAltName" && ext.value.include?("DNS:#{domain}") }
|
25
|
+
end
|
26
|
+
|
27
|
+
def check_cert_suites_no_old(record)
|
28
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
29
|
+
ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_NONE)
|
30
|
+
socket = TCPSocket.new(record[:domain], 443)
|
31
|
+
ssl = OpenSSL::SSL::SSLSocket.new(socket, ctx)
|
32
|
+
ssl.connect
|
33
|
+
ciphers = ssl.cipher
|
34
|
+
ssl.close
|
35
|
+
socket.close
|
36
|
+
|
37
|
+
!["RC4", "MD5", "SHA1"].any? { |weak| ciphers.include?(weak) }
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def fetch_certificate(domain)
|
43
|
+
uri = URI::HTTPS.build(host: domain)
|
44
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
45
|
+
http.use_ssl = true
|
46
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
47
|
+
http.start do |h|
|
48
|
+
h.peer_cert
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require "whois"
|
2
|
+
require "whois-parser"
|
3
|
+
|
4
|
+
class WhoisChecker < Recheck::Checker::Base
|
5
|
+
def query
|
6
|
+
whois = Whois::Client.new
|
7
|
+
# array of your domains
|
8
|
+
[].map do |domain|
|
9
|
+
whois.lookup(domain).parser
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def check_not_expiring_soon(parser)
|
14
|
+
expiration_date = parser.expires_on
|
15
|
+
expiration_date > (Time.now + 180 * 24 * 60 * 60) # 180 days
|
16
|
+
end
|
17
|
+
|
18
|
+
def check_registrar_lock(domain)
|
19
|
+
domain_status.any? { |status| status.downcase.include?("clienttransferprohibited") }
|
20
|
+
end
|
21
|
+
|
22
|
+
def check_nameservers(domain)
|
23
|
+
parser.nameservers.length >= 2
|
24
|
+
end
|
25
|
+
end
|