schema_reaper 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +3 -0
- data/.schema_reaper.yml.example +17 -0
- data/CHANGELOG.md +144 -0
- data/PRO.md +11 -6
- data/README.md +421 -71
- data/RELEASE_CHECKLIST.md +13 -2
- data/lib/generators/schema_reaper/install/install_generator.rb +11 -11
- data/lib/generators/schema_reaper/install/templates/initializer.rb.tt +8 -0
- data/lib/schema_reaper/alert_config.rb +21 -1
- data/lib/schema_reaper/alert_state.rb +35 -0
- data/lib/schema_reaper/analyzers/always_null_column.rb +12 -5
- data/lib/schema_reaper/analyzers/base.rb +66 -2
- data/lib/schema_reaper/analyzers/dead_column.rb +12 -6
- data/lib/schema_reaper/analyzers/dead_table.rb +10 -6
- data/lib/schema_reaper/analyzers/duplicate_foreign_key.rb +53 -0
- data/lib/schema_reaper/analyzers/duplicate_index.rb +1 -1
- data/lib/schema_reaper/analyzers/missing_fk_index.rb +10 -4
- data/lib/schema_reaper/analyzers/sequence_exhaustion.rb +55 -0
- data/lib/schema_reaper/analyzers/single_value_column.rb +17 -5
- data/lib/schema_reaper/analyzers/unused_index.rb +11 -6
- data/lib/schema_reaper/baseline.rb +95 -10
- data/lib/schema_reaper/cli/baseline_support.rb +45 -0
- data/lib/schema_reaper/cli.rb +69 -14
- data/lib/schema_reaper/config.rb +28 -0
- data/lib/schema_reaper/doctor.rb +151 -0
- data/lib/schema_reaper/explain.rb +102 -0
- data/lib/schema_reaper/gem_awareness.rb +31 -1
- data/lib/schema_reaper/inflect.rb +36 -0
- data/lib/schema_reaper/introspect/postgres.rb +258 -55
- data/lib/schema_reaper/migration_generator.rb +90 -4
- data/lib/schema_reaper/notifier.rb +69 -8
- data/lib/schema_reaper/railtie.rb +1 -1
- data/lib/schema_reaper/reporters/sarif.rb +15 -6
- data/lib/schema_reaper/reporters/slack.rb +67 -0
- data/lib/schema_reaper/runner.rb +58 -6
- data/lib/schema_reaper/runtime.rb +94 -11
- data/lib/schema_reaper/scan_job.rb +24 -2
- data/lib/schema_reaper/schema.rb +32 -3
- data/lib/schema_reaper/schema_locator.rb +75 -0
- data/lib/schema_reaper/static/cache.rb +66 -0
- data/lib/schema_reaper/static/scanner.rb +174 -6
- data/lib/schema_reaper/tasks/schema_reaper.rake +2 -2
- data/lib/schema_reaper/version.rb +1 -1
- data/lib/schema_reaper.rb +18 -1
- data/schema_reaper.gemspec +1 -2
- metadata +13 -4
|
@@ -4,6 +4,8 @@ require "net/http"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "stringio"
|
|
6
6
|
require_relative "reporters/markdown"
|
|
7
|
+
require_relative "reporters/slack"
|
|
8
|
+
require_relative "alert_state"
|
|
7
9
|
|
|
8
10
|
module SchemaReaper
|
|
9
11
|
# Delivers a completed scan's findings through whichever channels are
|
|
@@ -22,34 +24,81 @@ module SchemaReaper
|
|
|
22
24
|
# or because a caller genuinely wants email delivery skipped) is
|
|
23
25
|
# respected rather than silently falling back to the auto-detected
|
|
24
26
|
# SchemaReaper::Mailer via `||`.
|
|
25
|
-
|
|
27
|
+
#
|
|
28
|
+
# `mail_delivery:` is :later (enqueue via ActiveJob, the normal case) or
|
|
29
|
+
# :now -- for a caller that is itself running inline in a short-lived
|
|
30
|
+
# process, where an in-process queue would be torn down before the mail
|
|
31
|
+
# job ever ran (see the schema_reaper:alert rake task).
|
|
32
|
+
def initialize(findings, config: AlertConfig.instance, http: Net::HTTP, mailer: :auto, mail_delivery: :later,
|
|
33
|
+
state: AlertState.new)
|
|
26
34
|
@findings = findings
|
|
27
35
|
@config = config
|
|
28
36
|
@http = http
|
|
29
37
|
@mailer = mailer == :auto ? default_mailer : mailer
|
|
38
|
+
@mail_delivery = mail_delivery
|
|
39
|
+
@state = state
|
|
30
40
|
end
|
|
31
41
|
|
|
32
42
|
def deliver
|
|
33
43
|
return unless @config.configured?
|
|
44
|
+
return if @config.changes_only? && unchanged?
|
|
34
45
|
|
|
35
|
-
deliver_webhook if @config.webhook?
|
|
36
|
-
|
|
46
|
+
sent = [(deliver_webhook if @config.webhook?), (deliver_email if @config.emails?)]
|
|
47
|
+
# Only a report someone received becomes the baseline for "what
|
|
48
|
+
# changed" -- otherwise a failed delivery would make the next run
|
|
49
|
+
# think it was already reported, and alert_on: :changes would stay
|
|
50
|
+
# silent about it.
|
|
51
|
+
@state.remember(@findings.map(&:id)) if sent.any?
|
|
52
|
+
sent.any?
|
|
37
53
|
end
|
|
38
54
|
|
|
39
55
|
private
|
|
40
56
|
|
|
57
|
+
# {new:, resolved:} against the last delivered report, or nil when there
|
|
58
|
+
# is none to compare with.
|
|
59
|
+
def changes
|
|
60
|
+
return @changes if defined?(@changes)
|
|
61
|
+
|
|
62
|
+
prev = @state.previous_ids
|
|
63
|
+
@changes = prev && {
|
|
64
|
+
new: @findings.reject { |f| prev.include?(f.id) },
|
|
65
|
+
resolved: prev - @findings.map(&:id)
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A first report (nothing to compare with) always goes out.
|
|
70
|
+
def unchanged?
|
|
71
|
+
!changes.nil? && changes[:new].empty? && changes[:resolved].empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def webhook_body
|
|
75
|
+
return Reporters::Slack.new(@findings, changes: changes).payload if @config.slack_blocks?
|
|
76
|
+
|
|
77
|
+
{ text: report_text }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Mailer is autoloaded (see lib/schema_reaper.rb), and it can only be
|
|
81
|
+
# defined when ActionMailer is present -- check that, not Mailer itself.
|
|
41
82
|
def default_mailer
|
|
42
|
-
defined?(
|
|
83
|
+
defined?(::ActionMailer::Base) ? Mailer : nil
|
|
43
84
|
end
|
|
44
85
|
|
|
45
86
|
def report_text
|
|
46
87
|
@report_text ||= begin
|
|
47
88
|
io = StringIO.new
|
|
89
|
+
io.puts(changes_text) if changes
|
|
48
90
|
Reporters::Markdown.new(@findings, io: io).render
|
|
49
91
|
io.string
|
|
50
92
|
end
|
|
51
93
|
end
|
|
52
94
|
|
|
95
|
+
def changes_text
|
|
96
|
+
lines = ["**Since the last report:** #{changes[:new].size} new, #{changes[:resolved].size} resolved"]
|
|
97
|
+
lines += changes[:new].map { |f| "- new: `#{f.id}`" }
|
|
98
|
+
lines += changes[:resolved].map { |id| "- resolved: `#{id}`" }
|
|
99
|
+
"#{lines.join("\n")}\n"
|
|
100
|
+
end
|
|
101
|
+
|
|
53
102
|
# Slack's incoming-webhook shape ({"text": ...}) is the most common
|
|
54
103
|
# receiver in practice; Discord's legacy webhook path also accepts a
|
|
55
104
|
# plain "content" body but that's a different key, and structured-alert
|
|
@@ -60,7 +109,7 @@ module SchemaReaper
|
|
|
60
109
|
uri = URI.parse(@config.webhook_url)
|
|
61
110
|
request = Net::HTTP::Post.new(uri)
|
|
62
111
|
request["Content-Type"] = "application/json"
|
|
63
|
-
request.body = JSON.generate(
|
|
112
|
+
request.body = JSON.generate(webhook_body)
|
|
64
113
|
|
|
65
114
|
client = @http.new(uri.host, uri.port)
|
|
66
115
|
client.use_ssl = uri.scheme == "https"
|
|
@@ -69,20 +118,32 @@ module SchemaReaper
|
|
|
69
118
|
# Net::HTTP's own (version-dependent) defaults.
|
|
70
119
|
client.open_timeout = 10
|
|
71
120
|
client.read_timeout = 10
|
|
72
|
-
client.request(request)
|
|
121
|
+
accepted?(client.request(request))
|
|
73
122
|
rescue StandardError => e
|
|
74
123
|
log_error("webhook delivery failed", e)
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Slack answers a bad payload with 400 invalid_blocks, not an exception.
|
|
128
|
+
def accepted?(response)
|
|
129
|
+
return true unless response.respond_to?(:code) && !response.code.to_s.start_with?("2")
|
|
130
|
+
|
|
131
|
+
log_error("webhook delivery failed", "HTTP #{response.code}: #{response.body.to_s[0, 200]}")
|
|
132
|
+
false
|
|
75
133
|
end
|
|
76
134
|
|
|
77
135
|
def deliver_email
|
|
78
136
|
unless @mailer
|
|
79
137
|
log_error("email delivery skipped", "SchemaReaper::Mailer is not loaded (ActionMailer not present?)")
|
|
80
|
-
return
|
|
138
|
+
return false
|
|
81
139
|
end
|
|
82
140
|
|
|
83
|
-
@mailer.report_email(to: @config.emails, report: report_text)
|
|
141
|
+
message = @mailer.report_email(to: @config.emails, report: report_text)
|
|
142
|
+
@mail_delivery == :now ? message.deliver_now : message.deliver_later
|
|
143
|
+
true
|
|
84
144
|
rescue StandardError => e
|
|
85
145
|
log_error("email delivery failed", e)
|
|
146
|
+
false
|
|
86
147
|
end
|
|
87
148
|
|
|
88
149
|
def log_error(message, error)
|
|
@@ -14,7 +14,7 @@ module SchemaReaper
|
|
|
14
14
|
|
|
15
15
|
begin
|
|
16
16
|
config = SchemaReaper::Config.load
|
|
17
|
-
store = SchemaReaper::Runtime::Store.new(path: config.runtime_log)
|
|
17
|
+
store = SchemaReaper::Runtime::Store.new(path: config.runtime_log, max_bytes: config.runtime_log_max_bytes)
|
|
18
18
|
rate = (ENV["SCHEMA_REAPER_SAMPLE"] || "0.05").to_f
|
|
19
19
|
SchemaReaper::Runtime::Tracker.install!(store: store, sample_rate: rate)
|
|
20
20
|
rescue StandardError => e
|
|
@@ -8,9 +8,10 @@ module SchemaReaper
|
|
|
8
8
|
class Sarif
|
|
9
9
|
LEVEL = { high: "error", medium: "warning", low: "note" }.freeze
|
|
10
10
|
|
|
11
|
-
def initialize(findings, io: $stdout)
|
|
11
|
+
def initialize(findings, io: $stdout, locator: SchemaLocator.new)
|
|
12
12
|
@findings = findings
|
|
13
13
|
@io = io
|
|
14
|
+
@locator = locator
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def render
|
|
@@ -52,15 +53,23 @@ module SchemaReaper
|
|
|
52
53
|
"confidence" => f.confidence,
|
|
53
54
|
"reclaimableBytes" => f.reclaimable_bytes
|
|
54
55
|
},
|
|
55
|
-
"locations" =>
|
|
56
|
-
"physicalLocation" => {
|
|
57
|
-
"artifactLocation" => { "uri" => "db/schema.rb" }
|
|
58
|
-
}
|
|
59
|
-
}]
|
|
56
|
+
"locations" => locations_for(f)
|
|
60
57
|
}
|
|
61
58
|
end
|
|
62
59
|
end
|
|
63
60
|
|
|
61
|
+
# GitHub code scanning requires a location on every result, so with no
|
|
62
|
+
# schema file in the repo fall back to the conventional path, unlined.
|
|
63
|
+
def locations_for(finding)
|
|
64
|
+
loc = @locator.locate(finding)
|
|
65
|
+
return [{ "physicalLocation" => { "artifactLocation" => { "uri" => "db/schema.rb" } } }] unless loc
|
|
66
|
+
|
|
67
|
+
[{ "physicalLocation" => {
|
|
68
|
+
"artifactLocation" => { "uri" => loc.path },
|
|
69
|
+
"region" => { "startLine" => loc.line }
|
|
70
|
+
} }]
|
|
71
|
+
end
|
|
72
|
+
|
|
64
73
|
def camelize(str)
|
|
65
74
|
str.split("_").map(&:capitalize).join
|
|
66
75
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
module Reporters
|
|
5
|
+
# Slack Block Kit payload for the scheduled report. Slack renders neither
|
|
6
|
+
# Markdown tables nor messages past ~40k characters, so this is a summary
|
|
7
|
+
# with the most important findings, not the whole report.
|
|
8
|
+
class Slack
|
|
9
|
+
MAX_LISTED = 10
|
|
10
|
+
SEVERITY_ICON = { high: ":red_circle:", medium: ":large_orange_circle:", low: ":white_circle:" }.freeze
|
|
11
|
+
|
|
12
|
+
# changes - {new: [Finding], resolved: [String id]} or nil on a first report
|
|
13
|
+
def initialize(findings, changes: nil)
|
|
14
|
+
@findings = findings
|
|
15
|
+
@changes = changes
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def payload
|
|
19
|
+
{ "text" => summary, "blocks" => blocks }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Also the notification text, and what a client without Block Kit shows.
|
|
23
|
+
def summary
|
|
24
|
+
line = "schema_reaper: #{@findings.size} finding#{"s" unless @findings.size == 1}"
|
|
25
|
+
return line unless @changes
|
|
26
|
+
|
|
27
|
+
"#{line} (#{@changes[:new].size} new, #{@changes[:resolved].size} resolved since the last report)"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def blocks
|
|
33
|
+
out = [{ "type" => "section", "text" => mrkdwn("*#{summary}*") }]
|
|
34
|
+
listed = @changes ? @changes[:new] : @findings
|
|
35
|
+
out << list_block(@changes ? "New" : "Top findings", listed) if listed.any?
|
|
36
|
+
out << resolved_block if @changes && @changes[:resolved].any?
|
|
37
|
+
out << { "type" => "context",
|
|
38
|
+
"elements" => [mrkdwn("Full report: `bundle exec schema_reaper scan`")] }
|
|
39
|
+
out
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def list_block(title, findings)
|
|
43
|
+
lines = findings.first(MAX_LISTED).map do |f|
|
|
44
|
+
"#{SEVERITY_ICON.fetch(f.severity, "")} `#{f.id}` -- #{f.suggested_fix}"
|
|
45
|
+
end
|
|
46
|
+
lines << "_...and #{findings.size - MAX_LISTED} more_" if findings.size > MAX_LISTED
|
|
47
|
+
{ "type" => "section", "text" => mrkdwn(truncate("*#{title}*\n#{lines.join("\n")}")) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolved_block
|
|
51
|
+
ids = @changes[:resolved]
|
|
52
|
+
text = "*Resolved*\n#{ids.first(MAX_LISTED).map { |id| "~#{id}~" }.join("\n")}"
|
|
53
|
+
text += "\n_...and #{ids.size - MAX_LISTED} more_" if ids.size > MAX_LISTED
|
|
54
|
+
{ "type" => "section", "text" => mrkdwn(truncate(text)) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A section's text is capped at 3000 characters by Slack.
|
|
58
|
+
def truncate(text)
|
|
59
|
+
text.length > 3000 ? "#{text[0, 2990]}..." : text
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def mrkdwn(text)
|
|
63
|
+
{ "type" => "mrkdwn", "text" => text }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/schema_reaper/runner.rb
CHANGED
|
@@ -7,32 +7,84 @@ module SchemaReaper
|
|
|
7
7
|
class Runner
|
|
8
8
|
SEVERITY_RANK = { high: 0, medium: 1, low: 2 }.freeze
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# only / except - analyzer type names (strings or symbols) to restrict the
|
|
11
|
+
# run to, or to leave out. Unknown names raise Error.
|
|
12
|
+
# min_confidence - drop findings below this; defaults to the config key.
|
|
13
|
+
def initialize(config: Config.load, root: Dir.pwd, introspector: nil, runtime: nil,
|
|
14
|
+
only: nil, except: nil, min_confidence: nil)
|
|
11
15
|
@config = config
|
|
12
16
|
@root = root
|
|
13
17
|
@introspector = introspector
|
|
14
18
|
@runtime = runtime
|
|
19
|
+
@only = names(only)
|
|
20
|
+
@except = names(except)
|
|
21
|
+
@min_confidence = min_confidence || config.min_confidence
|
|
15
22
|
load_plugins
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
def run
|
|
26
|
+
selected = analyzers
|
|
19
27
|
db = schema
|
|
20
|
-
warn_missing_query_history(db)
|
|
28
|
+
warn_missing_query_history(db) if selected.include?(Analyzers::UnusedIndex)
|
|
29
|
+
scan = Static::Scanner.new(@config, root: @root).scan
|
|
21
30
|
ctx = Analyzers::Context.new(
|
|
22
31
|
schema: db,
|
|
23
|
-
used_tokens:
|
|
32
|
+
used_tokens: scan.tokens,
|
|
33
|
+
scan: scan,
|
|
24
34
|
runtime: runtime_report,
|
|
25
35
|
gem_columns: gem_columns(db),
|
|
26
36
|
gem_owned_tables: gem_owned_tables,
|
|
27
37
|
config: @config
|
|
28
38
|
)
|
|
29
39
|
|
|
30
|
-
findings =
|
|
31
|
-
dedupe(findings).
|
|
40
|
+
findings = selected.flat_map { |klass| klass.new(ctx).call }
|
|
41
|
+
dedupe(kept_in_code(findings, scan.keeps)).select { |f| f.confidence >= @min_confidence }
|
|
42
|
+
.sort_by do |f|
|
|
43
|
+
[
|
|
44
|
+
-f.confidence, f.id
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Analyzer types this run covers, as strings, or nil when every analyzer
|
|
50
|
+
# runs. Callers that persist results (the baseline) use it to leave the
|
|
51
|
+
# other analyzers' records alone.
|
|
52
|
+
def selected_types
|
|
53
|
+
return nil if @only.nil? && @except.nil?
|
|
54
|
+
|
|
55
|
+
analyzers.map { |k| k.type.to_s }
|
|
32
56
|
end
|
|
33
57
|
|
|
34
58
|
private
|
|
35
59
|
|
|
60
|
+
# `# schema_reaper:keep TARGET` in code silences a finding. TARGET is a
|
|
61
|
+
# column (any table), table.column, a table (every finding on it) or an
|
|
62
|
+
# index name.
|
|
63
|
+
def kept_in_code(findings, keeps)
|
|
64
|
+
return findings if keeps.empty?
|
|
65
|
+
|
|
66
|
+
findings.reject do |f|
|
|
67
|
+
targets = [f.table, f.column, f.index, f.column && "#{f.table}.#{f.column}"].compact.map(&:downcase)
|
|
68
|
+
targets.any? { |t| keeps.key?(t) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def names(list)
|
|
73
|
+
list && Array(list).flat_map { |n| n.to_s.split(",") }.map { |n| n.strip.to_sym }.reject(&:empty?)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Plugins load in #initialize, so the registry is complete by the time
|
|
77
|
+
# the names are checked -- a custom analyzer can be selected like a core one.
|
|
78
|
+
def analyzers
|
|
79
|
+
all = Analyzers::Registry.all
|
|
80
|
+
known = all.map(&:type)
|
|
81
|
+
unknown = ((@only || []) + (@except || [])) - known
|
|
82
|
+
raise Error, "unknown analyzer(s): #{unknown.join(", ")} (known: #{known.join(", ")})" if unknown.any?
|
|
83
|
+
|
|
84
|
+
all.select { |k| @only.nil? || @only.include?(k.type) }
|
|
85
|
+
.reject { |k| @except&.include?(k.type) }
|
|
86
|
+
end
|
|
87
|
+
|
|
36
88
|
# Silently reporting nothing would look like a clean bill of health, so say
|
|
37
89
|
# why the unused-index analyzer sat this run out.
|
|
38
90
|
def warn_missing_query_history(db)
|
|
@@ -72,7 +124,7 @@ module SchemaReaper
|
|
|
72
124
|
def schema
|
|
73
125
|
return @introspector.call if @introspector
|
|
74
126
|
|
|
75
|
-
Introspect::Postgres.new(@config.database_url).call
|
|
127
|
+
Introspect::Postgres.new(@config.database_url, schemas: @config.schemas).call
|
|
76
128
|
end
|
|
77
129
|
|
|
78
130
|
def runtime_report
|
|
@@ -15,19 +15,32 @@ module SchemaReaper
|
|
|
15
15
|
new(accessed: Set.new, observed_days: 0)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# Reads the rotated generation (path.1) too, so rotation never shortens
|
|
19
|
+
# the observed window. Several processes append to one log, so lines are
|
|
20
|
+
# not in time order -- the span is min..max, not first..last line.
|
|
18
21
|
def self.load(path)
|
|
19
|
-
return empty unless path
|
|
22
|
+
return empty unless path
|
|
23
|
+
|
|
24
|
+
files = [Store.rotated_path(path), path].select { |f| File.exist?(f) }
|
|
25
|
+
return empty if files.empty?
|
|
20
26
|
|
|
21
27
|
seen = Set.new
|
|
22
28
|
first = last = nil
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
files.each do |file|
|
|
30
|
+
File.foreach(file) do |line|
|
|
31
|
+
row = JSON.parse(line)
|
|
32
|
+
seen << row["key"]
|
|
33
|
+
# Store writes UTC ISO8601 ("2026-01-05T00:00:00Z"), which sorts
|
|
34
|
+
# correctly as a string -- far cheaper than parsing every line of
|
|
35
|
+
# a log that can hold millions. Only the extremes are parsed.
|
|
36
|
+
ts = row["at"]
|
|
37
|
+
next unless ts.is_a?(String)
|
|
38
|
+
|
|
39
|
+
first = ts if first.nil? || ts < first
|
|
40
|
+
last = ts if last.nil? || ts > last
|
|
41
|
+
rescue JSON::ParserError
|
|
42
|
+
next
|
|
43
|
+
end
|
|
31
44
|
end
|
|
32
45
|
new(accessed: seen, observed_days: day_span(first, last))
|
|
33
46
|
end
|
|
@@ -36,6 +49,14 @@ module SchemaReaper
|
|
|
36
49
|
return 0 unless first && last
|
|
37
50
|
|
|
38
51
|
((Time.parse(last) - Time.parse(first)) / 86_400).ceil
|
|
52
|
+
rescue ArgumentError
|
|
53
|
+
0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Raw-SQL access recorded by the tracker's SQL subscriber, keyed
|
|
57
|
+
# "table.*": the table was touched, but by no column we can name.
|
|
58
|
+
def table_accessed?(table)
|
|
59
|
+
accessed.any? { |k| k.start_with?("#{table}.") }
|
|
39
60
|
end
|
|
40
61
|
|
|
41
62
|
def empty?
|
|
@@ -52,10 +73,19 @@ module SchemaReaper
|
|
|
52
73
|
end
|
|
53
74
|
|
|
54
75
|
# Buffered writer for the usage log. Thread-safe append of JSON lines.
|
|
76
|
+
#
|
|
77
|
+
# Past max_bytes the log is renamed to `<path>.1` (replacing any older
|
|
78
|
+
# generation) and a fresh one started, so disk use stays under roughly
|
|
79
|
+
# 2 x max_bytes. nil disables rotation.
|
|
55
80
|
class Store
|
|
56
|
-
def
|
|
81
|
+
def self.rotated_path(path)
|
|
82
|
+
"#{path}.1"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def initialize(path:, flush_every: 200, max_bytes: Config::DEFAULTS["runtime_log_max_bytes"])
|
|
57
86
|
@path = path
|
|
58
87
|
@flush_every = flush_every
|
|
88
|
+
@max_bytes = max_bytes
|
|
59
89
|
@buffer = []
|
|
60
90
|
@mutex = Mutex.new
|
|
61
91
|
FileUtils.mkdir_p(File.dirname(path))
|
|
@@ -79,6 +109,23 @@ module SchemaReaper
|
|
|
79
109
|
|
|
80
110
|
File.open(@path, "a") { |f| f.write(@buffer.join) }
|
|
81
111
|
@buffer.clear
|
|
112
|
+
rotate_if_needed
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Several processes (Puma workers) share one log, so the size check and
|
|
116
|
+
# the rename happen under an exclusive flock and the size is checked
|
|
117
|
+
# again once it's held: otherwise two workers that both saw an
|
|
118
|
+
# oversized log would rotate twice, and the second rename would put a
|
|
119
|
+
# nearly empty log over the full generation in <path>.1.
|
|
120
|
+
def rotate_if_needed
|
|
121
|
+
return unless @max_bytes && File.size?(@path).to_i > @max_bytes
|
|
122
|
+
|
|
123
|
+
File.open("#{@path}.lock", File::RDWR | File::CREAT, 0o644) do |lock|
|
|
124
|
+
lock.flock(File::LOCK_EX)
|
|
125
|
+
File.rename(@path, self.class.rotated_path(@path)) if File.size?(@path).to_i > @max_bytes
|
|
126
|
+
end
|
|
127
|
+
rescue SystemCallError
|
|
128
|
+
nil
|
|
82
129
|
end
|
|
83
130
|
end
|
|
84
131
|
|
|
@@ -88,16 +135,52 @@ module SchemaReaper
|
|
|
88
135
|
class << self
|
|
89
136
|
attr_accessor :store, :sample_rate
|
|
90
137
|
|
|
91
|
-
|
|
138
|
+
# track_sql: also sample the tables named in every executed SQL
|
|
139
|
+
# statement, so a table read only through find_by_sql, a raw
|
|
140
|
+
# connection.execute or a view-backed report still counts as used.
|
|
141
|
+
def install!(store:, sample_rate: 0.05, track_sql: true)
|
|
92
142
|
return if @installed
|
|
93
143
|
|
|
94
144
|
self.store = store
|
|
95
145
|
self.sample_rate = sample_rate
|
|
96
146
|
require "active_record"
|
|
97
147
|
ActiveRecord::Base.prepend(Hook)
|
|
148
|
+
subscribe_sql if track_sql
|
|
98
149
|
at_exit { store.flush }
|
|
99
150
|
@installed = true
|
|
100
151
|
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def subscribe_sql
|
|
156
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
157
|
+
Tracker.note_sql(payload[:sql], payload[:name])
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The relation after FROM / JOIN / UPDATE / INTO, optionally
|
|
163
|
+
# schema-qualified and double-quoted: "billing"."invoices" -> billing.invoices.
|
|
164
|
+
#
|
|
165
|
+
# INSERT INTO targets are left out on purpose: a table that is only ever
|
|
166
|
+
# written (an audit log nobody reads) is exactly what dead_table should
|
|
167
|
+
# be able to find. INSERT ... SELECT still records its FROM tables.
|
|
168
|
+
SQL_TABLE_RE = /\b(?:FROM|JOIN|UPDATE)\s+((?:"?\w+"?\.)?"?\w+"?)/i.freeze
|
|
169
|
+
SKIP_SQL_NAMES = %w[SCHEMA TRANSACTION].freeze
|
|
170
|
+
|
|
171
|
+
def self.note_sql(sql, name = nil)
|
|
172
|
+
return unless store && sql
|
|
173
|
+
return if SKIP_SQL_NAMES.include?(name)
|
|
174
|
+
return unless rand < sample_rate
|
|
175
|
+
|
|
176
|
+
# "public"."users" is the same table the app (and the schema) calls users
|
|
177
|
+
sql.scan(SQL_TABLE_RE).flatten.map { |t| t.delete('"').delete_prefix("public.") }.uniq.each do |table|
|
|
178
|
+
next if table.start_with?("pg_", "information_schema")
|
|
179
|
+
|
|
180
|
+
store.record(table, "*")
|
|
181
|
+
end
|
|
182
|
+
rescue StandardError
|
|
183
|
+
nil
|
|
101
184
|
end
|
|
102
185
|
|
|
103
186
|
module Hook
|
|
@@ -9,9 +9,31 @@ module SchemaReaper
|
|
|
9
9
|
class ScanJob < ActiveJob::Base
|
|
10
10
|
queue_as :default
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# Rails' :async adapter (the default until Rails 8 unless an app sets up
|
|
13
|
+
# a real backend) runs jobs on a thread pool inside the enqueueing
|
|
14
|
+
# process. Fine in a web server; fatal in a one-shot `rake` process,
|
|
15
|
+
# which exits the moment the task returns and takes the queued job with
|
|
16
|
+
# it -- the scheduled scan would silently never run.
|
|
17
|
+
def self.in_process_queue?
|
|
18
|
+
queue_adapter.instance_of?(::ActiveJob::QueueAdapters::AsyncAdapter)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# What the scheduled cron entry (`rake schema_reaper:alert`) calls:
|
|
22
|
+
# enqueue normally, but on an in-process queue run inline instead --
|
|
23
|
+
# email included -- so the scan isn't lost when the process exits.
|
|
24
|
+
def self.run_scheduled
|
|
25
|
+
return perform_later unless in_process_queue?
|
|
26
|
+
|
|
27
|
+
puts "[schema_reaper] ActiveJob adapter is :async -- running the scan inline"
|
|
28
|
+
perform_now(deliver_mail_now: true)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# `deliver_mail_now:` is for running inline from a process about to exit
|
|
32
|
+
# (see the schema_reaper:alert rake task) -- a deliver_later there would
|
|
33
|
+
# be lost for the same reason as above.
|
|
34
|
+
def perform(deliver_mail_now: false)
|
|
13
35
|
findings = Runner.new.run
|
|
14
|
-
Notifier.new(findings).deliver
|
|
36
|
+
Notifier.new(findings, mail_delivery: deliver_mail_now ? :now : :later).deliver
|
|
15
37
|
end
|
|
16
38
|
end
|
|
17
39
|
end
|
data/lib/schema_reaper/schema.rb
CHANGED
|
@@ -19,7 +19,9 @@ module SchemaReaper
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
# partitioned: an index on a partitioned parent (relkind 'I'). PostgreSQL
|
|
23
|
+
# cannot DROP one CONCURRENTLY, so removal advice differs.
|
|
24
|
+
Index = Struct.new(:name, :columns, :unique, :primary, :scans, :partial, :partitioned, keyword_init: true) do
|
|
23
25
|
def covers?(other)
|
|
24
26
|
columns.first(other.columns.length) == other.columns
|
|
25
27
|
end
|
|
@@ -33,10 +35,33 @@ module SchemaReaper
|
|
|
33
35
|
end
|
|
34
36
|
end
|
|
35
37
|
|
|
38
|
+
# A declared FOREIGN KEY constraint. columns is in key order; references
|
|
39
|
+
# is the referenced table's display name.
|
|
40
|
+
# behaviour is an opaque signature of referenced key, ON UPDATE/DELETE,
|
|
41
|
+
# MATCH and deferrability: equal only when the constraints act alike.
|
|
42
|
+
ForeignKey = Struct.new(:name, :columns, :references, :behaviour, :validated, keyword_init: true)
|
|
43
|
+
|
|
44
|
+
# A column-owned sequence (serial / IDENTITY). max_value is the effective
|
|
45
|
+
# ceiling: the lower of the sequence's own limit and the column type's.
|
|
46
|
+
Sequence = Struct.new(:name, :table, :column, :column_type, :last_value, :max_value,
|
|
47
|
+
keyword_init: true) do
|
|
48
|
+
def used_fraction
|
|
49
|
+
return 0.0 unless max_value&.positive?
|
|
50
|
+
|
|
51
|
+
last_value.to_f / max_value
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
36
55
|
Table = Struct.new(
|
|
37
|
-
:name, :columns, :indexes, :primary_key, :foreign_keys, :row_count,
|
|
56
|
+
:name, :columns, :indexes, :primary_key, :foreign_keys, :foreign_key_constraints, :row_count,
|
|
38
57
|
keyword_init: true
|
|
39
58
|
) do
|
|
59
|
+
# Introspectors (and hand-built test schemas) that predate constraint
|
|
60
|
+
# details leave this nil.
|
|
61
|
+
def foreign_key_constraints
|
|
62
|
+
self[:foreign_key_constraints] || []
|
|
63
|
+
end
|
|
64
|
+
|
|
40
65
|
def column_names
|
|
41
66
|
columns.map(&:name)
|
|
42
67
|
end
|
|
@@ -61,7 +86,11 @@ module SchemaReaper
|
|
|
61
86
|
end
|
|
62
87
|
end
|
|
63
88
|
|
|
64
|
-
DatabaseSchema = Struct.new(:tables, :index_scan_total, keyword_init: true) do
|
|
89
|
+
DatabaseSchema = Struct.new(:tables, :index_scan_total, :sequences, keyword_init: true) do
|
|
90
|
+
def sequences
|
|
91
|
+
self[:sequences] || []
|
|
92
|
+
end
|
|
93
|
+
|
|
65
94
|
def table(name)
|
|
66
95
|
tables.find { |t| t.name == name }
|
|
67
96
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
# Finds the line in db/schema.rb (or db/structure.sql) that defines a
|
|
5
|
+
# finding's table, column or index, so SARIF annotations land on it instead
|
|
6
|
+
# of line 1 of a file that may not even exist.
|
|
7
|
+
class SchemaLocator
|
|
8
|
+
Location = Struct.new(:path, :line, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
CANDIDATES = %w[db/schema.rb db/structure.sql].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(root: Dir.pwd)
|
|
13
|
+
@root = root
|
|
14
|
+
@path = CANDIDATES.find { |p| File.exist?(File.join(root, p)) }
|
|
15
|
+
@lines = @path ? File.readlines(File.join(root, @path)) : []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Location, nil] nil when there is no schema file at all
|
|
19
|
+
def locate(finding)
|
|
20
|
+
return nil unless @path
|
|
21
|
+
|
|
22
|
+
Location.new(path: @path, line: line_for(finding) || 1)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def line_for(finding)
|
|
28
|
+
start = table_line(finding.table)
|
|
29
|
+
return nil unless start
|
|
30
|
+
|
|
31
|
+
block = block_after(start)
|
|
32
|
+
found = (finding.index && (find_in(block, quoted(finding.index)) || find_in(all_lines, quoted(finding.index)))) ||
|
|
33
|
+
(finding.column && find_in(block, column_pattern(finding.column.split(",").first)))
|
|
34
|
+
found || (start + 1)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# schema.rb: create_table "users", ... (schema-qualified: "billing.users")
|
|
38
|
+
# structure.sql: CREATE TABLE public.users (
|
|
39
|
+
def table_line(table)
|
|
40
|
+
bare = table.split(".").last
|
|
41
|
+
ns = table.include?(".") ? table.split(".").first : "public"
|
|
42
|
+
rb = /create_table\s+"#{Regexp.escape(table)}"/
|
|
43
|
+
sql = /CREATE TABLE (?:"?#{Regexp.escape(ns)}"?\.)?"?#{Regexp.escape(bare)}"?\s*\(/
|
|
44
|
+
@lines.index { |l| l.match?(rb) || l.match?(sql) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Lines from the table's definition to the next table, as [text, index]
|
|
48
|
+
# pairs. Indexes in schema.rb sit inside the block; in structure.sql they
|
|
49
|
+
# come later as CREATE INDEX name ON ..., hence the whole-file fallback
|
|
50
|
+
# for an index (index names are unique per schema, so it can't mismatch).
|
|
51
|
+
def block_after(start)
|
|
52
|
+
rest = all_lines.drop(start + 1)
|
|
53
|
+
stop = rest.index { |l, _| l.match?(/\A\s*create_table\s|\ACREATE TABLE /) }
|
|
54
|
+
stop ? rest.first(stop) : rest
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def all_lines
|
|
58
|
+
@all_lines ||= @lines.each_with_index.to_a
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def find_in(pairs, pattern)
|
|
62
|
+
hit = pairs.find { |l, _| l.match?(pattern) }
|
|
63
|
+
hit && (hit[1] + 1)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def quoted(name)
|
|
67
|
+
/["\s]#{Regexp.escape(name)}["\s]/
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# schema.rb: t.string "email" structure.sql: email character varying
|
|
71
|
+
def column_pattern(column)
|
|
72
|
+
/\A\s*(?:t\.\w+\s+"#{Regexp.escape(column)}"|"?#{Regexp.escape(column)}"?\s+\w)/
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|