Files
synapbus/internal/plugin/migrator.go
T
Algis DumbrisandClaude Opus 4.7 494e8f83e9 feat(plugin): plugin framework + plugintest + demo plugin + integration tests
Implements the compile-in plugin system designed in spec 019:

- internal/plugin/ (~1,300 LOC):
  * Tiny Plugin interface + 10 optional HasX capability sub-interfaces
  * Host struct with Logger / DB / Messenger / Channels / Attachments /
    Search / Secrets / Events / Config / DataDir / Tracer / Metrics /
    DefaultOwner / BaseURL
  * Registry with panic-on-duplicate, stability-level tracking, capability
    indexing (MCP tools, actions, panels, channel types, routes, event
    subscribers, CLI commands)
  * Migrator: per-plugin SHA-256-checksum'd migration chain, namespaced
    plugin_<name>_* table enforcement, idempotent re-apply
  * Three-phase lifecycle (Migrate → Init → Start) with panic-safe
    wrappers around every plugin call; failure per plugin isolated,
    core continues
  * YAML config loader that preserves unknown top-level keys on round-trip
  * Status store exposing /api/plugins/status JSON
  * Restart helpers (Noop + SignalRestarter); graceful reload is
    in-process for the demo

- internal/plugin/plugintest/ (~345 LOC):
  * NopHost(t) with in-memory modernc.org/sqlite
  * Run(t, plugin) full-lifecycle smoke helper
  * Assertions: HasTool, HasAction, HasPanel, HasChannelType,
    HasMigration, PluginStarted, PluginFailed
  * ScopedSecrets that returns ErrSecretNotFound for cross-plugin
    reads (satisfies SC-006)

- internal/plugins/demo/ (canonical showcase):
  * Plugin that exercises every HasX capability (migrations, actions,
    HTTP routes, web panel, lifecycle, config schema, stability)
  * Own SQL migration creating plugin_demo_notes
  * Embedded HTML panel that fetches notes via JS
  * 4 unit tests covering smoke, full capability registration, action
    handlers, and config-driven max_notes limit

- cmd/plugindemo/ (~290 LOC):
  * Demo HTTP server wiring registry to chi
  * Mounts /api/plugins/status, /api/admin/plugins/{name}/{enable,disable},
    /api/actions/{name}, /api/plugins/<name>/* (per-plugin REST),
    /ui/plugins/<name>/ (per-plugin UI)
  * SIGHUP-triggered config reload + registry rebuild + mux swap
  * SIGTERM/SIGINT graceful shutdown

- test/integration/ (~357 LOC, build-tag "integration"):
  * 6 end-to-end tests against a spawned plugindemo binary
  * Enable/disable round-trip with data preservation
  * SIGHUP reload timing (measured 41 ms — SC-008 target is 2 s)
  * Action-404 on disabled plugin, panel-404 on disabled plugin
  * REST endpoints + UI panel reachable

Contract deviation: admin toggle endpoints moved from
/api/plugins/{name}/{enable,disable} to /api/admin/plugins/{name}/{...}
to avoid URL collision with chi per-plugin route mounts. rest.md updated.

Scope deferred to next session (mechanical follow-ups):
- Port internal/wiki/ to internal/plugins/wiki/
- Squash 26 migrations to schema/000_initial.sql
- Backup scripts for live kubic instance
- Remaining 9 plugin extractions
- Boundary-lint static analyzer
- Wire into cmd/synapbus/main.go

All unit + integration tests green. Chrome UI smoke test passes.
autonomous_summary.md carries the full verification record.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:14:27 +03:00

166 lines
5.0 KiB
Go

package plugin
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"regexp"
"strings"
)
const createPluginMigrationsTable = `
CREATE TABLE IF NOT EXISTS plugin_migrations (
plugin TEXT NOT NULL,
version INTEGER NOT NULL,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (plugin, version)
);`
// tablePrefixRE matches any CREATE TABLE or ALTER TABLE referring to a table
// not prefixed with plugin_<name>_ or the allow-listed plugin_migrations.
var createTableRE = regexp.MustCompile(`(?i)CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+([a-zA-Z_][a-zA-Z0-9_]*)`)
// MigrationResult is a record of what was applied for a plugin.
type MigrationResult struct {
Plugin string
Applied []int // versions newly applied this run
}
// ApplyMigrations runs all unapplied migrations for the given plugin.
// Migrations for a plugin are applied inside one transaction per version.
// A previously-applied migration whose SQL changed (checksum mismatch)
// is refused.
func ApplyMigrations(ctx context.Context, db *sql.DB, p Plugin) (MigrationResult, error) {
out := MigrationResult{Plugin: p.Name()}
hm, ok := p.(HasMigrations)
if !ok {
return out, nil
}
if _, err := db.ExecContext(ctx, createPluginMigrationsTable); err != nil {
return out, fmt.Errorf("create plugin_migrations table: %w", err)
}
// Collect already-applied versions + checksums.
applied := map[int]string{}
rows, err := db.QueryContext(ctx,
`SELECT version, checksum FROM plugin_migrations WHERE plugin = ?`, p.Name())
if err != nil {
return out, fmt.Errorf("load applied migrations: %w", err)
}
for rows.Next() {
var v int
var sum string
if err := rows.Scan(&v, &sum); err != nil {
rows.Close()
return out, err
}
applied[v] = sum
}
rows.Close()
migs := hm.Migrations()
// Guard: enforce table prefix. Also: allow FTS virtual tables and indexes;
// reject only CREATE TABLE referencing names outside plugin_<name>_*.
for _, m := range migs {
if err := validateMigrationSQL(p.Name(), m); err != nil {
return out, err
}
}
// Sort by version for deterministic apply order.
sortMigrations(migs)
for _, m := range migs {
sum := checksumSQL(m.SQL)
if priorSum, wasApplied := applied[m.Version]; wasApplied {
if priorSum != sum {
return out, fmt.Errorf(
"plugin %q migration %d (%s): checksum mismatch (was %s, now %s). "+
"Migrations are immutable once applied",
p.Name(), m.Version, m.Name, priorSum, sum)
}
continue // already applied, no-op
}
if err := applyOne(ctx, db, p.Name(), m, sum); err != nil {
return out, err
}
out.Applied = append(out.Applied, m.Version)
}
return out, nil
}
func applyOne(ctx context.Context, db *sql.DB, plugin string, m Migration, checksum string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx for %s:%d: %w", plugin, m.Version, err)
}
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply %s:%d (%s): %w", plugin, m.Version, m.Name, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO plugin_migrations (plugin, version, name, checksum) VALUES (?, ?, ?, ?)`,
plugin, m.Version, m.Name, checksum,
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record %s:%d: %w", plugin, m.Version, err)
}
return tx.Commit()
}
func checksumSQL(s string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(s)))
return hex.EncodeToString(sum[:])
}
func sortMigrations(ms []Migration) {
// tiny insertion sort; plugin chains are short
for i := 1; i < len(ms); i++ {
for j := i; j > 0 && ms[j-1].Version > ms[j].Version; j-- {
ms[j-1], ms[j] = ms[j], ms[j-1]
}
}
}
// validateMigrationSQL rejects CREATE TABLE statements referring to names
// not prefixed with plugin_<name>_. Indexes, views, triggers, and virtual-
// table clauses are not enforced here; they are expected to reference
// plugin-owned tables anyway.
func validateMigrationSQL(pluginName string, m Migration) error {
prefix := "plugin_" + pluginName + "_"
for _, match := range createTableRE.FindAllStringSubmatch(m.SQL, -1) {
tbl := strings.ToLower(match[1])
// Allow the core table; no plugin should try to create it but
// we tolerate being defensive.
if tbl == "plugin_migrations" {
continue
}
if !strings.HasPrefix(tbl, strings.ToLower(prefix)) {
return fmt.Errorf(
"plugin %q migration %d (%s): table %q must be prefixed %q",
pluginName, m.Version, m.Name, tbl, prefix)
}
}
return nil
}
// AppliedVersions returns the migration versions already recorded for the plugin.
func AppliedVersions(ctx context.Context, db *sql.DB, plugin string) ([]int, error) {
rows, err := db.QueryContext(ctx,
`SELECT version FROM plugin_migrations WHERE plugin = ? ORDER BY version`, plugin)
if err != nil {
return nil, err
}
defer rows.Close()
var out []int
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}