Diff -- /var/www/vhosts/3dshawn.com/site1/_schema_scan.pl
Diff

/var/www/vhosts/3dshawn.com/site1/_schema_scan.pl

modified on local at 2026-07-13 20:26:16

Added
+6
lines
Removed
-2
lines
Context
314
unchanged
Blobs
from d3c4437e322e
to 1d138e2a47b5
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11#!/usr/bin/perl
22#======================================================================
33# DriftSense -- schema scanner
44#
55# Cron entry point (NOT a CGI). Reads DATABASE_MONITOR_SETTINGS, connects
66# to each configured DB, snapshots current DDL for every non-ignored
77# table, diffs against previously stored DDL, writes new SCHEMA_CHANGE
88# rows referencing BLOB_STORE for the full DDL snapshot.
99#
1010# Optimizations baked in:
1111# * information_schema.TABLES.UPDATE_TIME early-exit -- skip tables
1212# that haven't been touched since our last scan of them.
1313# * Content-addressable BLOB_STORE dedup -- if the DDL is byte-for-byte
1414# identical to a prior snapshot (which almost always is if you
1515# re-scan an unchanged table), zero new bytes are stored.
1616# * gzip compression on stored snapshots.
1717# * Missing INT primary-key gap detection (per Shawn's own note file)
1818# -- captures when someone DELETEd rows without telling anyone.
1919#
2020# Config: /etc/drift_sense/drift_sense.conf (via MODS::Config).
2121# Log: /var/log/drift_sense/schema_scan.log (via cron redirect).
2222#======================================================================
2323use strict;
2424use warnings;
2525use lib '/var/www/vhosts/3dshawn.com/site1';
2626use POSIX ();
2727use DBI;
2828use Digest::SHA qw(sha256_hex);
2929use Compress::Zlib qw(compress);
3030use MODS::Config;
3131use MODS::DBConnect;
3232
3333$| = 1;
3434my $ts = POSIX::strftime('%Y-%m-%d %H:%M:%S', localtime);
3535
3636my $cfg = MODS::Config->new;
3737my $db = MODS::DBConnect->new;
3838my $dbh = $db->db_connect or die "[$ts] cannot connect to DriftSense storage DB\n";
3939
4040# ---- Fetch active database monitors ----
4141my @monitors = $db->db_readwrite_multiple($dbh, q~
4242 SELECT database_list_id, db_name, hostname, port, username, password,
4343 ignore_tables, track_row_count, notify_emails, server_id
4444 FROM DATABASE_MONITOR_SETTINGS
4545 WHERE status = 1
4646~, __FILE__, __LINE__);
4747
4848unless (@monitors) {
4949 print "[$ts] no active monitors, exiting\n";
5050 exit 0;
5151}
5252
5353my $total_changes = 0;
5454my $total_scanned = 0;
5555my $total_failed = 0;
5656
5757foreach my $m (@monitors) {
5858 my $dsn = "DBI:mysql:$m->{db_name}:$m->{hostname}:$m->{port}";
5959 my $target_dbh = eval {
6060 DBI->connect($dsn, $m->{username}, $m->{password}, {
6161 PrintError => 0, RaiseError => 0, mysql_connect_timeout => 5,
6262 });
6363 };
6464 unless ($target_dbh) {
6565 my $q_db = $dbh->quote($m->{db_name});
6666 $db->db_readwrite($dbh, qq~
6767 INSERT INTO FAILED_SCAN_LOGS (server_id, db_name, date_time)
6868 VALUES ($m->{server_id}, $q_db, NOW())
6969 ~, __FILE__, __LINE__);
7070 $total_failed++;
7171 print "[$ts] $m->{db_name}: connect failed\n";
7272 next;
7373 }
7474
7575 # ---- Ignore-list parse (comma or newline separated) ----
7676 my %ignored;
7777 if ($m->{ignore_tables}) {
7878 for my $t (split /[\s,]+/, $m->{ignore_tables}) {
7979 $t =~ s/^\s+|\s+$//g;
8080 $ignored{$t} = 1 if length $t;
8181 }
8282 }
8383
8484 # ---- Discover tables + UPDATE_TIME for early-exit ----
8585 my $tab_sth = $target_dbh->prepare(qq~
8686 SELECT TABLE_NAME, UPDATE_TIME
8787 FROM information_schema.TABLES
8888 WHERE TABLE_SCHEMA = ?
8989 ~);
9090 $tab_sth->execute($m->{db_name}) or do {
9191 $total_failed++;
9292 $target_dbh->disconnect;
9393 print "[$ts] $m->{db_name}: information_schema query failed\n";
9494 next;
9595 };
9696 my @tables;
9797 while (my $r = $tab_sth->fetchrow_hashref) {
9898 next if $ignored{$r->{TABLE_NAME}};
9999 push @tables, $r;
100100 }
101101 $tab_sth->finish;
102102
103103 foreach my $t (@tables) {
104104 $total_scanned++;
105105 my $tname = $t->{TABLE_NAME};
106106 my $update_time = $t->{UPDATE_TIME} || '';
107107
108108 # ---- Fetch current DDL ----
109109 my $ddl_row = eval {
110110 $target_dbh->selectrow_arrayref("SHOW CREATE TABLE `$m->{db_name}`.`$tname`");
111111 };
112112 unless ($ddl_row && $ddl_row->[1]) {
113113 print "[$ts] $m->{db_name}.$tname: SHOW CREATE TABLE failed\n";
114114 next;
115115 }
116116 my $ddl = $ddl_row->[1];
117117 # Normalize noise before hashing so churn-only DDL bumps don't
118118 # register as schema changes:
119119 # * AUTO_INCREMENT=N in the table options -- bumps every time a
120120 # row is inserted into the table (very noisy on log/audit tables)
121121 # * ROW_FORMAT / STATS_* / ENCRYPTION default noise some MySQL
122122 # builds emit inconsistently.
123123 # Keep the DDL structure otherwise fully intact.
124124 my $ddl_norm = $ddl;
125125 $ddl_norm =~ s/\s*AUTO_INCREMENT=\d+//g;
126126 my $sha = sha256_hex($ddl_norm);
127127
128128 # ---- Compare against previously stored DDL for this table ----
129129 my $q_dbname = $dbh->quote($m->{db_name});
130130 my $q_tname = $dbh->quote($tname);
131131 my $prev = $db->db_readwrite($dbh, qq~
132132 SELECT schema_blob_sha FROM SCHEMA_CHANGE
133133 WHERE server_id = $m->{server_id}
134134 AND database_name = $q_dbname
135135 AND table_name = $q_tname
136136 ORDER BY schema_change_id DESC LIMIT 1
137137 ~, __FILE__, __LINE__);
138138
139139 my $prev_sha = $prev && $prev->{schema_blob_sha} ? $prev->{schema_blob_sha} : '';
140140 next if $prev_sha eq $sha; # unchanged -- skip
141141
142142 # ---- Persist to BLOB_STORE (content-addressable, gzipped) ----
143143 _store_blob($db, $dbh, $sha, $ddl);
144144
145145 # ---- Compute a compact diff summary (best-effort) ----
146146 my $prev_ddl = '';
147147 if ($prev_sha) {
148148 $prev_ddl = _fetch_blob($db, $dbh, $prev_sha) || '';
149149 }
150150 my $diff_summary = _short_diff($prev_ddl, $ddl);
151151
152 # First-ever capture of this table = baseline. Once we have a prev
153 # SHA on record, any subsequent capture is a real DDL change.
154 my $is_baseline = $prev_sha ? 0 : 1;
152155 my $q_diff = $dbh->quote($diff_summary);
153156 my $q_sha = $dbh->quote($sha);
154157 $db->db_readwrite($dbh, qq~
155158 INSERT INTO SCHEMA_CHANGE
156 (server_id, database_name, table_name, changes,
159 (server_id, database_name, table_name, changes, is_baseline,
157160 schema_blob_sha, change_datetime)
158161 VALUES
159 ($m->{server_id}, $q_dbname, $q_tname, $q_diff, $q_sha, NOW())
162 ($m->{server_id}, $q_dbname, $q_tname, $q_diff, $is_baseline,
163 $q_sha, NOW())
160164 ~, __FILE__, __LINE__);
161165 $total_changes++;
162166 print "[$ts] $m->{db_name}.$tname changed\n";
163167 }
164168
165169 # ---- Row-count tracking + missing INT PK gap detection ----
166170 if ($m->{track_row_count}) {
167171 foreach my $t (@tables) {
168172 my $tname = $t->{TABLE_NAME};
169173 my $ct_row = eval {
170174 $target_dbh->selectrow_arrayref("SELECT COUNT(*) FROM `$m->{db_name}`.`$tname`");
171175 };
172176 my $count = ($ct_row && $ct_row->[0]) ? $ct_row->[0] : 0;
173177
174178 # Missing-INT-PK gap detection -- Shawn's own note:
175179 # "look for missing INT numbers in the primary key to know
176180 # if someone deleted data from the tables"
177181 my $gap_ct = _pk_gap_count($target_dbh, $m->{db_name}, $tname);
178182
179183 my $q_db = $dbh->quote($m->{db_name});
180184 my $q_t = $dbh->quote($tname);
181185 $db->db_readwrite($dbh, qq~
182186 INSERT INTO TABLE_DATA_CHANGES
183187 (server_id, db_name, table_name, record_count,
184188 missing_pk_gap_count, date_time)
185189 VALUES
186190 ($m->{server_id}, $q_db, $q_t, $count, ~
187191 . ($gap_ct // 'NULL') . qq~, NOW())
188192 ~, __FILE__, __LINE__);
189193 }
190194 }
191195
192196 # ---- Update last_connection stamp on the monitor ----
193197 $db->db_readwrite($dbh, qq~
194198 UPDATE DATABASE_MONITOR_SETTINGS
195199 SET connection_status = 1, last_connection = NOW()
196200 WHERE database_list_id = $m->{database_list_id}
197201 ~, __FILE__, __LINE__);
198202
199203 $target_dbh->disconnect;
200204}
201205
202206$db->db_disconnect($dbh);
203207
204208print "[$ts] schema scan complete: $total_scanned tables scanned, "
205209 . "$total_changes changes captured, $total_failed monitors failed\n"
206210 if $total_changes || $total_failed;
207211
208212exit 0;
209213
210214#---------------------------------------------------------------------
211215# _store_blob -- content-addressable insert into BLOB_STORE. If the SHA
212216# already exists, just bump ref_count. Compression is inline gzip via
213217# Compress::Zlib (core module, no CPAN dep).
214218#---------------------------------------------------------------------
215219sub _store_blob {
216220 my ($db, $dbh, $sha, $content) = @_;
217221 my $existing = $db->db_readwrite($dbh,
218222 "SELECT blob_sha FROM BLOB_STORE WHERE blob_sha = " . $dbh->quote($sha) . " LIMIT 1",
219223 __FILE__, __LINE__);
220224 if ($existing && $existing->{blob_sha}) {
221225 # already stored, just bump ref count
222226 $db->db_readwrite($dbh, "UPDATE BLOB_STORE SET ref_count = ref_count + 1 WHERE blob_sha = "
223227 . $dbh->quote($sha), __FILE__, __LINE__);
224228 return;
225229 }
226230 my $gz = compress($content) // '';
227231 my $sth = $dbh->prepare(qq~
228232 INSERT INTO BLOB_STORE (blob_sha, content_gz, size_uncompressed, size_compressed, ref_count, first_seen_at)
229233 VALUES (?, ?, ?, ?, 1, NOW())
230234 ~);
231235 if ($sth) {
232236 $sth->execute($sha, $gz, length($content), length($gz));
233237 $sth->finish;
234238 }
235239}
236240
237241sub _fetch_blob {
238242 my ($db, $dbh, $sha) = @_;
239243 my $sth = $dbh->prepare("SELECT content_gz FROM BLOB_STORE WHERE blob_sha = ?");
240244 return undef unless $sth && $sth->execute($sha);
241245 my $row = $sth->fetchrow_arrayref;
242246 $sth->finish;
243247 return undef unless $row && $row->[0];
244248 my $decompressed = eval { Compress::Zlib::uncompress($row->[0]) };
245249 return $decompressed;
246250}
247251
248252#---------------------------------------------------------------------
249253# _short_diff -- compact human-readable summary of what changed.
250254# Used for the SCHEMA_CHANGE.changes text column so lists render
251255# without decompressing the full BLOB_STORE snapshot.
252256#---------------------------------------------------------------------
253257sub _short_diff {
254258 my ($old, $new) = @_;
255259 $old //= ''; $new //= '';
256260 return 'first snapshot' unless length $old;
257261
258262 my @old_lines = split /\n/, $old;
259263 my @new_lines = split /\n/, $new;
260264 my %old_set = map { $_ => 1 } @old_lines;
261265 my %new_set = map { $_ => 1 } @new_lines;
262266
263267 my @added = grep { !$old_set{$_} } @new_lines;
264268 my @removed = grep { !$new_set{$_} } @old_lines;
265269
266270 my $summary = '';
267271 $summary .= (scalar @added) . ' line(s) added; ' if @added;
268272 $summary .= (scalar @removed) . ' line(s) removed; ' if @removed;
269273 $summary =~ s/;\s*$//;
270274 $summary ||= 'reordered / whitespace';
271275
272276 # Include a small preview of the first added line (safe truncation)
273277 if (@added) {
274278 my $first = $added[0]; $first =~ s/\s+/ /g;
275279 $first = substr($first, 0, 80) . '...' if length($first) > 80;
276280 $summary .= " -- e.g. +$first";
277281 } elsif (@removed) {
278282 my $first = $removed[0]; $first =~ s/\s+/ /g;
279283 $first = substr($first, 0, 80) . '...' if length($first) > 80;
280284 $summary .= " -- e.g. -$first";
281285 }
282286 return $summary;
283287}
284288
285289#---------------------------------------------------------------------
286290# _pk_gap_count -- if the table has an AUTO_INCREMENT INT primary key,
287291# count missing IDs between MIN(id) and MAX(id). Non-zero = someone
288292# deleted rows. Returns undef if not applicable (no INT PK).
289293#---------------------------------------------------------------------
290294sub _pk_gap_count {
291295 my ($tdbh, $db_name, $tname) = @_;
292296 my $pk_sth = eval {
293297 $tdbh->prepare(qq~
294298 SELECT COLUMN_NAME, DATA_TYPE FROM information_schema.COLUMNS
295299 WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
296300 AND COLUMN_KEY = 'PRI' AND EXTRA LIKE '%auto_increment%'
297301 LIMIT 1
298302 ~);
299303 };
300304 return undef unless $pk_sth && $pk_sth->execute($db_name, $tname);
301305 my $pk = $pk_sth->fetchrow_hashref;
302306 $pk_sth->finish;
303307 return undef unless $pk && $pk->{COLUMN_NAME};
304308 return undef if $pk->{DATA_TYPE} !~ /int/i;
305309
306310 my $col = $pk->{COLUMN_NAME};
307311 my $gap_row = eval {
308312 $tdbh->selectrow_arrayref(qq~
309313 SELECT MAX(`$col`) - MIN(`$col`) + 1 - COUNT(*) AS gaps
310314 FROM `$db_name`.`$tname`
311315 ~);
312316 };
313317 return undef unless $gap_row;
314318 my $g = $gap_row->[0];
315319 return (defined $g && $g > 0) ? $g : 0;
316320}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help