schema_reaper 2.0.1 → 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 +102 -0
- data/README.md +64 -11
- data/RELEASE_CHECKLIST.md +13 -2
- 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 +2 -2
- 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 +236 -51
- data/lib/schema_reaper/migration_generator.rb +90 -4
- data/lib/schema_reaper/notifier.rb +58 -6
- 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/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/version.rb +1 -1
- data/lib/schema_reaper.rb +7 -0
- data/schema_reaper.gemspec +0 -1
- metadata +13 -4
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module SchemaReaper
|
|
6
|
+
# `schema_reaper doctor`: checks everything a scan's accuracy depends on and
|
|
7
|
+
# says what to fix, so a quiet report can be trusted to mean "nothing dead"
|
|
8
|
+
# rather than "nothing measured".
|
|
9
|
+
class Doctor
|
|
10
|
+
Check = Struct.new(:status, :label, :detail, keyword_init: true) # status: :ok | :warn | :fail
|
|
11
|
+
|
|
12
|
+
STATS_FRESH_DAYS = 7
|
|
13
|
+
ICON = { ok: "✓", warn: "!", fail: "✗" }.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(config:, root: Dir.pwd, introspector: nil, console: Reporters::Console.new)
|
|
16
|
+
@config = config
|
|
17
|
+
@root = root
|
|
18
|
+
@introspector = introspector
|
|
19
|
+
@console = console
|
|
20
|
+
@checks = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Boolean] true unless a check failed outright
|
|
24
|
+
def call
|
|
25
|
+
check_scan_paths
|
|
26
|
+
check_database
|
|
27
|
+
check_runtime_log
|
|
28
|
+
check_baseline
|
|
29
|
+
check_schema_file
|
|
30
|
+
check_alerts
|
|
31
|
+
render
|
|
32
|
+
@checks.none? { |c| c.status == :fail }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def ok(label, detail = nil)
|
|
38
|
+
@checks << Check.new(status: :ok, label: label, detail: detail)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def warn_check(label, detail)
|
|
42
|
+
@checks << Check.new(status: :warn, label: label, detail: detail)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fail_check(label, detail)
|
|
46
|
+
@checks << Check.new(status: :fail, label: label, detail: detail)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check_scan_paths
|
|
50
|
+
missing = @config.scan_paths.reject { |p| Dir.exist?(File.join(@root, p)) }
|
|
51
|
+
return ok("scan paths", @config.scan_paths.join(", ")) if missing.empty?
|
|
52
|
+
|
|
53
|
+
warn_check("scan paths", "not found: #{missing.join(", ")} -- code there can't mark anything as used")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def check_database
|
|
57
|
+
intro = @introspector || Introspect::Postgres.new(@config.database_url, schemas: @config.schemas)
|
|
58
|
+
diag = intro.diagnostics
|
|
59
|
+
ok("database", "connected, PostgreSQL #{diag[:server_version]}")
|
|
60
|
+
check_stats_reset(diag[:stats_reset])
|
|
61
|
+
check_analyze(diag[:never_analyzed])
|
|
62
|
+
check_query_history(intro.call)
|
|
63
|
+
if diag[:in_recovery]
|
|
64
|
+
warn_check("replica", "this is a read replica -- index usage here doesn't cover the primary")
|
|
65
|
+
end
|
|
66
|
+
rescue Error => e
|
|
67
|
+
fail_check("database", e.message)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check_stats_reset(reset_at)
|
|
71
|
+
return ok("statistics", "never reset") if reset_at.nil?
|
|
72
|
+
|
|
73
|
+
days = ((Time.now - Time.parse(reset_at.to_s)) / 86_400).floor
|
|
74
|
+
return ok("statistics", "collected for #{days} day(s)") if days >= STATS_FRESH_DAYS
|
|
75
|
+
|
|
76
|
+
warn_check("statistics", "reset #{days} day(s) ago -- unused_index needs a longer window to be trusted")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def check_analyze(tables)
|
|
80
|
+
return ok("ANALYZE", "every table has planner statistics") if tables.empty?
|
|
81
|
+
|
|
82
|
+
sample = tables.first(5).join(", ") + (tables.size > 5 ? ", ..." : "")
|
|
83
|
+
warn_check("ANALYZE", "#{tables.size} table(s) never analysed (#{sample}) -- " \
|
|
84
|
+
"row counts and null/distinct checks are blind there; run ANALYZE")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_query_history(schema)
|
|
88
|
+
return ok("query history", "#{schema.index_scan_total} index scans recorded") if schema.query_history?
|
|
89
|
+
|
|
90
|
+
warn_check("query history", "only #{schema.index_scan_total} index scan(s) -- unused_index will sit out; " \
|
|
91
|
+
"scan a database that serves real traffic")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def check_runtime_log
|
|
95
|
+
report = Runtime::Report.load(@config.runtime_log)
|
|
96
|
+
need = @config.min_age_days || Config::DEFAULTS["min_age_days"]
|
|
97
|
+
if report.empty?
|
|
98
|
+
warn_check("runtime log", "none at #{@config.runtime_log} -- dead_column confidence stays capped at " \
|
|
99
|
+
"#{Analyzers::DeadColumn::STATIC_ONLY_CAP}; see README > Runtime signal")
|
|
100
|
+
elsif report.observed_days < need
|
|
101
|
+
warn_check("runtime log", "#{report.observed_days} of #{need} day(s) observed -- not trusted yet")
|
|
102
|
+
else
|
|
103
|
+
ok("runtime log", "#{report.observed_days} day(s), #{report.accessed.size} column(s) seen")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def check_baseline
|
|
108
|
+
base = Baseline.new(@config.baseline_path)
|
|
109
|
+
return ok("baseline", "none (scan --ci would fail on every finding)") if base.entries.empty?
|
|
110
|
+
|
|
111
|
+
expired = base.expired
|
|
112
|
+
return ok("baseline", "#{base.entries.size} accepted finding(s)") if expired.empty?
|
|
113
|
+
|
|
114
|
+
warn_check("baseline", "#{expired.size} entr#{expired.size == 1 ? "y has" : "ies have"} expired: " \
|
|
115
|
+
"#{expired.map(&:id).first(3).join(", ")}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def check_schema_file
|
|
119
|
+
path = SchemaLocator::CANDIDATES.find { |p| File.exist?(File.join(@root, p)) }
|
|
120
|
+
return ok("schema file", path) if path
|
|
121
|
+
|
|
122
|
+
warn_check("schema file", "no db/schema.rb or db/structure.sql -- SARIF results can't point at a line")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Only meaningful inside a Rails app with the install generator's
|
|
126
|
+
# initializer loaded.
|
|
127
|
+
def check_alerts
|
|
128
|
+
return unless defined?(AlertConfig) && defined?(Rails)
|
|
129
|
+
|
|
130
|
+
cfg = AlertConfig.instance
|
|
131
|
+
unless cfg.configured?
|
|
132
|
+
return warn_check("alerts", "no emails or webhook_url configured -- scheduled scans report nowhere")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
channels = [("email (#{cfg.emails.size})" if cfg.emails?), ("webhook" if cfg.webhook?)].compact
|
|
136
|
+
ok("alerts", "#{channels.join(" + ")}, alert_on: #{cfg.alert_on}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def render
|
|
140
|
+
@console.title("doctor")
|
|
141
|
+
@checks.each do |c|
|
|
142
|
+
line = format("%<icon>s %<label>-14s %<detail>s", icon: ICON[c.status], label: c.label, detail: c.detail)
|
|
143
|
+
@console.info(line)
|
|
144
|
+
end
|
|
145
|
+
@console.blank
|
|
146
|
+
counts = @checks.group_by(&:status).transform_values(&:size)
|
|
147
|
+
@console.info("#{counts.fetch(:ok, 0)} ok, #{counts.fetch(:warn, 0)} warning(s), " \
|
|
148
|
+
"#{counts.fetch(:fail, 0)} failed")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
# `schema_reaper explain ID`: everything known about one finding, and the
|
|
5
|
+
# exact command to act on it.
|
|
6
|
+
class Explain
|
|
7
|
+
def initialize(findings, config:, root: Dir.pwd, console: Reporters::Console.new)
|
|
8
|
+
@findings = findings
|
|
9
|
+
@config = config
|
|
10
|
+
@root = root
|
|
11
|
+
@console = console
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Boolean] false when no finding matches
|
|
15
|
+
def call(query)
|
|
16
|
+
matches = match(query)
|
|
17
|
+
if matches.size == 1
|
|
18
|
+
render(matches.first)
|
|
19
|
+
elsif matches.empty?
|
|
20
|
+
report_not_found(query)
|
|
21
|
+
else
|
|
22
|
+
report_ambiguous(query, matches)
|
|
23
|
+
end
|
|
24
|
+
matches.size == 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# An exact id, else every finding whose id contains the query
|
|
30
|
+
# ("users/legacy_token" finds dead_column/users/legacy_token).
|
|
31
|
+
def match(query)
|
|
32
|
+
exact = @findings.select { |f| f.id == query }
|
|
33
|
+
exact.any? ? exact : @findings.select { |f| f.id.include?(query) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def report_not_found(query)
|
|
37
|
+
@console.problem("no current finding matches #{query.inspect}")
|
|
38
|
+
table_finding = @findings.find { |f| f.type == :dead_table && query.split("/").include?(f.table) }
|
|
39
|
+
if table_finding
|
|
40
|
+
@console.info("its table is reported whole as #{table_finding.id} -- per-column and per-index " \
|
|
41
|
+
"findings on a dead table are folded into that one")
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@console.info("list them with `schema_reaper scan --format json` -- ids look like dead_column/users/legacy_token")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def report_ambiguous(query, matches)
|
|
49
|
+
@console.problem("#{matches.size} findings match #{query.inspect} -- be more specific:",
|
|
50
|
+
items: matches.map(&:id))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render(finding)
|
|
54
|
+
@console.title("explain", finding.id)
|
|
55
|
+
@console.row("type", finding.type)
|
|
56
|
+
@console.row("severity", finding.severity)
|
|
57
|
+
@console.row("confidence", format("%.2f", finding.confidence))
|
|
58
|
+
@console.row("reclaimable", reclaim(finding))
|
|
59
|
+
@console.row("defined at", location(finding)) if location(finding)
|
|
60
|
+
@console.row("baseline", baseline_status(finding))
|
|
61
|
+
@console.blank
|
|
62
|
+
@console.list("evidence", finding.evidence, colour: :reset)
|
|
63
|
+
@console.blank
|
|
64
|
+
@console.list("fix", [finding.suggested_fix], colour: :reset)
|
|
65
|
+
next_step = next_command(finding)
|
|
66
|
+
@console.list("next", [next_step], colour: :reset) if next_step
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reclaim(finding)
|
|
70
|
+
return "unknown (row count unavailable -- run ANALYZE)" unless finding.reclaim_known?
|
|
71
|
+
|
|
72
|
+
Reporters::Bytes.human(finding.reclaimable_bytes)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def location(finding)
|
|
76
|
+
@location ||= {}
|
|
77
|
+
@location[finding.id] ||= begin
|
|
78
|
+
loc = SchemaLocator.new(root: @root).locate(finding)
|
|
79
|
+
loc && "#{loc.path}:#{loc.line}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def baseline_status(finding)
|
|
84
|
+
entry = Baseline.new(@config.baseline_path).entries.find { |e| e.id == finding.id }
|
|
85
|
+
return "not accepted (fails `scan --ci`)" unless entry
|
|
86
|
+
return "accepted, but expired #{entry.expires_on}" if entry.expired?
|
|
87
|
+
|
|
88
|
+
["accepted", entry.reason, entry.expires_on && "until #{entry.expires_on}"].compact.join(" -- ")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def next_command(finding)
|
|
92
|
+
case finding.type
|
|
93
|
+
when :dead_column, :always_null_column, :single_value_column
|
|
94
|
+
"bundle exec schema_reaper generate-migration #{finding.table} #{finding.column}"
|
|
95
|
+
when :unused_index, :duplicate_index
|
|
96
|
+
"bundle exec schema_reaper generate-migration #{finding.table} --index #{finding.index}"
|
|
97
|
+
when :dead_table
|
|
98
|
+
"bundle exec schema_reaper generate-migration #{finding.table}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -9,6 +9,18 @@ module SchemaReaper
|
|
|
9
9
|
module GemAwareness
|
|
10
10
|
# gem name => { table_glob => [column names] }. "*" table_glob matches any
|
|
11
11
|
# table; an exact name matches only that table.
|
|
12
|
+
def self.owned(tables)
|
|
13
|
+
tables.to_h { |t| [t, []] }
|
|
14
|
+
end
|
|
15
|
+
private_class_method :owned
|
|
16
|
+
|
|
17
|
+
SOLID_QUEUE_TABLES = %w[
|
|
18
|
+
solid_queue_jobs solid_queue_ready_executions solid_queue_scheduled_executions
|
|
19
|
+
solid_queue_claimed_executions solid_queue_blocked_executions solid_queue_failed_executions
|
|
20
|
+
solid_queue_recurring_executions solid_queue_recurring_tasks solid_queue_pauses
|
|
21
|
+
solid_queue_processes solid_queue_semaphores
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
12
24
|
MAP = {
|
|
13
25
|
"devise" => { "*" => %w[
|
|
14
26
|
encrypted_password reset_password_token reset_password_sent_at
|
|
@@ -42,7 +54,25 @@ module SchemaReaper
|
|
|
42
54
|
resource_owner_type resource_owner_id access_token refresh_token
|
|
43
55
|
expires_in revoked_at previous_refresh_token
|
|
44
56
|
]
|
|
45
|
-
}
|
|
57
|
+
},
|
|
58
|
+
# Gems below own their tables outright and touch every column from
|
|
59
|
+
# their own code, so the tables are listed with no columns: ownership
|
|
60
|
+
# alone keeps every analyzer off them (see Base#gem_reserved?).
|
|
61
|
+
"solid_queue" => owned(SOLID_QUEUE_TABLES),
|
|
62
|
+
"solid_cache" => owned(%w[solid_cache_entries]),
|
|
63
|
+
"solid_cable" => owned(%w[solid_cable_messages]),
|
|
64
|
+
"good_job" => owned(%w[good_jobs good_job_executions good_job_processes good_job_settings
|
|
65
|
+
good_job_batches]),
|
|
66
|
+
"delayed_job_active_record" => owned(%w[delayed_jobs]),
|
|
67
|
+
"flipper-active_record" => owned(%w[flipper_features flipper_gates]),
|
|
68
|
+
"acts-as-taggable-on" => owned(%w[tags taggings]),
|
|
69
|
+
"doorkeeper" => owned(%w[oauth_applications oauth_access_grants oauth_access_tokens]),
|
|
70
|
+
"noticed" => owned(%w[noticed_events noticed_notifications]),
|
|
71
|
+
"activerecord-session_store" => owned(%w[sessions]),
|
|
72
|
+
# Columns these gems add to app tables; the first is the anchor.
|
|
73
|
+
"discard" => { "*" => %w[discarded_at] },
|
|
74
|
+
"acts_as_list" => { "*" => %w[position] },
|
|
75
|
+
"ancestry" => { "*" => %w[ancestry ancestry_depth children_count] }
|
|
46
76
|
}.freeze
|
|
47
77
|
|
|
48
78
|
# Table names an installed gem creates and owns outright -- as opposed to
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
# Just enough of ActiveSupport's inflector to map model class names to
|
|
5
|
+
# table names without depending on it. A wrong guess only weakens a
|
|
6
|
+
# heuristic -- it never makes a finding more confident.
|
|
7
|
+
module Inflect
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def underscore(str)
|
|
11
|
+
str.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\\1_\\2').gsub(/([a-z\d])([A-Z])/, '\\1_\\2').downcase
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def pluralize(word)
|
|
15
|
+
case word
|
|
16
|
+
when /[^aeiou]y\z/ then word.sub(/y\z/, "ies")
|
|
17
|
+
when /(s|x|z|ch|sh)\z/ then "#{word}es"
|
|
18
|
+
else "#{word}s"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def singularize(word)
|
|
23
|
+
case word
|
|
24
|
+
when /ies\z/ then word.sub(/ies\z/, "y")
|
|
25
|
+
when /(ss|sh|ch|x|z)es\z/ then word.sub(/es\z/, "")
|
|
26
|
+
when /s\z/ then word.sub(/s\z/, "")
|
|
27
|
+
else word
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# User -> users, LineItem -> line_items, Address -> addresses
|
|
32
|
+
def tableize(class_name)
|
|
33
|
+
pluralize(underscore(class_name))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|