oficina / home Public

Report only known schema inventory metadata at startup

Paul Campbell pushed by paul@keymail.dev 8969a42ee516ea316ce66e72ce11e6b278758696 parent c1f26bb
3 files changed, +108 −0
  • cmd/home/main.go +1 −0
  • internal/home/schema_inventory.go +64 −0
  • internal/home/schema_inventory_test.go +43 −0

CI passed — run details

diff --git a/cmd/home/main.go b/cmd/home/main.go
index 0ea67a3..e74c411 100644
--- a/cmd/home/main.go
+++ b/cmd/home/main.go
@@ -101,6 +101,7 @@ func main() {
os.Exit(1)
}
+ home.LogSchemaInventory(context.Background(), d, runtime, logger)
app, err := home.NewRuntime(runtime, d, logger)
if err != nil {
logger.Error("wire app", "err", err)
diff --git a/internal/home/schema_inventory.go b/internal/home/schema_inventory.go
new file mode 100644
index 0000000..c1a5c65
--- /dev/null
+++ b/internal/home/schema_inventory.go
@@ -0,0 +1,64 @@
+package home
+
+import (
+ "context"
+ "database/sql"
+ "log/slog"
+ "sort"
+ "time"
+
+ "github.com/carlosframework/rastrillo/db"
+)
+
+// LogSchemaInventory reports only compiled migration names and counts. Unknown
+// database strings never enter logs, and the report grants no operator authority.
+func LogSchemaInventory(parent context.Context, d *db.DB, cfg Runtime, logger *slog.Logger) {
+ if parent == nil || d == nil || logger == nil || (cfg.role != "credential" && cfg.role != "directory") {
+ return
+ }
+ ctx, cancel := context.WithTimeout(parent, 2*time.Second)
+ defer cancel()
+ expected := map[string]bool{}
+ known := map[string]bool{}
+ for _, m := range cfg.Schema().All() {
+ expected[m.ID] = true
+ }
+ for _, role := range []string{"credential", "directory"} {
+ for _, m := range (Runtime{role: role}).Schema().All() {
+ known[m.ID] = true
+ }
+ }
+ rows, err := d.Writer().QueryContext(ctx, "SELECT CASE WHEN typeof(id)='text' AND length(id)<=256 THEN id END FROM rastrillo_migrations LIMIT 1001")
+ if err != nil {
+ logger.Info("schema inventory unavailable")
+ return
+ }
+ var extra []string
+ unknown, count := 0, 0
+ for rows.Next() {
+ var id sql.NullString
+ if rows.Scan(&id) != nil {
+ rows.Close()
+ logger.Info("schema inventory unavailable")
+ return
+ }
+ count++
+ if count > 1000 {
+ break
+ }
+ if id.Valid && expected[id.String] {
+ continue
+ }
+ if id.Valid && known[id.String] {
+ extra = append(extra, id.String)
+ } else {
+ unknown++
+ }
+ }
+ if rows.Err() != nil || rows.Close() != nil {
+ logger.Info("schema inventory unavailable")
+ return
+ }
+ sort.Strings(extra)
+ logger.Info("schema inventory", "role", cfg.role, "known_extra", extra, "unclassified", unknown, "truncated", count > 1000)
+}
diff --git a/internal/home/schema_inventory_test.go b/internal/home/schema_inventory_test.go
new file mode 100644
index 0000000..33805b0
--- /dev/null
+++ b/internal/home/schema_inventory_test.go
@@ -0,0 +1,43 @@
+package home
+
+import (
+ "bytes"
+ "log/slog"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/home/internal/home/sealtest"
+)
+
+func TestSchemaInventoryEmitsOnlyCompiledNames(t *testing.T) {
+ pair := newExchangePair(t)
+ cfg := *pair.directory.runtime
+ expected := map[string]bool{}
+ for _, m := range cfg.Schema().All() {
+ expected[m.ID] = true
+ }
+ extra := ""
+ for _, m := range (Runtime{role: "credential"}).Schema().All() {
+ if !expected[m.ID] {
+ extra = m.ID
+ break
+ }
+ }
+ if extra == "" {
+ t.Fatal("no cross-role fixture")
+ }
+ for _, id := range []string{extra, sealtest.CanaryAddress, strings.Repeat("x", 300)} {
+ if _, err := pair.directory.db.Writer().Exec("INSERT INTO rastrillo_migrations(id,applied_at,checksum) VALUES(?,'2026-01-01T00:00:00Z','fixture')", id); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var out bytes.Buffer
+ logger := slog.New(slog.NewJSONHandler(&out, nil))
+ LogSchemaInventory(t.Context(), pair.directory.db, cfg, logger)
+ if !bytes.Contains(out.Bytes(), []byte(extra)) || !bytes.Contains(out.Bytes(), []byte(`"unclassified":2`)) {
+ t.Fatal("inventory missed known migration or count")
+ }
+ if bytes.Contains(out.Bytes(), []byte(sealtest.CanaryAddress)) || bytes.Contains(out.Bytes(), []byte(strings.Repeat("x", 300))) || bytes.Contains(out.Bytes(), []byte("fixture")) {
+ t.Fatal("inventory leaked database strings")
+ }
+}