mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
feat(hooks/migration): auto-disable legacy command hooks on Standard boot
Phase 07 runtime migration. Post-Wave-1 Standard deployments that pre-dated the edition gate may still carry enabled command-type hook rows in the DB; those keep firing via the dispatcher after the UI stops letting users create new ones. This helper flips them off at startup so live traffic never fires a disabled-by-policy handler again. - internal/hooks/migration_command_autodisable.go: DisableLegacyCommandHooks(ctx, hookStore, edition). Standard only; Lite returns early. Master-scope context + List(Enabled=true) → skip non-command + source='builtin' rows → Update enabled=false. Per-row WARN log with hook_id + tenant_id for audit; INFO summary when n>0. Idempotent: second boot finds nothing. - cmd/gateway_managed.go: invoke after builtin.Seed, BEFORE handler registration so no HTTP/WS traffic races the migration. - Tests (fake store, 5 cases): * Standard with mixed rows → only command+source=ui gets disabled * http + script/builtin + command/builtin rows untouched * Idempotent second run → n=0 * Lite edition → n=0, nothing touched * nil store → n=0, no error * List error → propagated up Note: Phase 04/05 never seed command-typed builtins, but the carve-out is kept defensively so a future accidental builtin command row can't be disabled by this migration.
This commit is contained in:
1 parent
a6592fd65f
commit
33d188f01f
3 files changed
+265
No files matched your search
@@ -164,6 +164,17 @@ func wireExtras(
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 07: runtime migration — auto-disable legacy command-type hooks
|
||||
// on Standard edition. No-op on Lite. Idempotent. Runs synchronously
|
||||
// before listeners so traffic never sees a command hook fire on a
|
||||
// post-Wave-1 Standard instance.
|
||||
if n, err := hooks.DisableLegacyCommandHooks(context.Background(), hs, edition.Current()); err != nil {
|
||||
slog.Warn("hooks.command_migration_failed", "err", err)
|
||||
} else if n > 0 {
|
||||
slog.Info("hooks.command_migration_ran",
|
||||
"disabled_count", n, "edition", edition.Current().Name)
|
||||
}
|
||||
|
||||
handlers := buildHookHandlers(stores, providerReg, appCfg.Hooks)
|
||||
stdOpts := hooks.StdDispatcherOpts{
|
||||
Store: hs,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// DisableLegacyCommandHooks turns off any enabled command-typed hook rows
|
||||
// when running on Standard edition. No-op on Lite. Idempotent: second call
|
||||
// finds nothing to disable.
|
||||
//
|
||||
// Context: Phase 01 removed the UI affordance for creating `command` hooks on
|
||||
// Standard edition; Phase 03's edition gate rejects new writes at the WS RPC
|
||||
// layer. But Standard deployments that existed BEFORE Wave 1 already have
|
||||
// command-hook rows in the DB, and those rows keep firing via the dispatcher.
|
||||
// This helper is the one-time-per-boot runtime migration that flips them off.
|
||||
//
|
||||
// Why not a SQL migration?
|
||||
// - Lite runs on SQLite and must not be affected even when sharing code
|
||||
// - Edition is a runtime flag, not a schema property
|
||||
// - Fresh Standard DBs that restore a Lite backup may reintroduce command
|
||||
// rows; re-running on each boot handles that safely
|
||||
//
|
||||
// Called once at startup from cmd/gateway_managed.go after migrations and the
|
||||
// builtin seed, BEFORE HTTP/WS listeners accept traffic.
|
||||
func DisableLegacyCommandHooks(ctx context.Context, hs HookStore, ed edition.Edition) (int, error) {
|
||||
if ed.Name != edition.Standard.Name {
|
||||
return 0, nil
|
||||
}
|
||||
if hs == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Reach all tenants' rows. store.WithOwnerRole does NOT exist — see
|
||||
// phase-04 red-team notes. Use WithRole explicitly.
|
||||
ctx = store.WithRole(ctx, store.RoleOwner)
|
||||
|
||||
enabled := true
|
||||
rows, err := hs.List(ctx, ListFilter{Enabled: &enabled})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, r := range rows {
|
||||
if r.HandlerType != HandlerCommand {
|
||||
continue
|
||||
}
|
||||
if r.Source == SourceBuiltin {
|
||||
// Defensive: Phase 04/05 only seed HandlerScript, but guard anyway
|
||||
// so a future accidental builtin command row wouldn't get disabled.
|
||||
continue
|
||||
}
|
||||
if err := hs.Update(ctx, r.ID, map[string]any{"enabled": false}); err != nil {
|
||||
slog.Warn("hooks.command_hook_auto_disable_failed",
|
||||
"hook_id", r.ID, "tenant_id", r.TenantID, "err", err)
|
||||
continue
|
||||
}
|
||||
slog.Warn("hooks.command_hook_auto_disabled",
|
||||
"hook_id", r.ID,
|
||||
"tenant_id", r.TenantID,
|
||||
"reason", "command_handler_disabled_on_standard_edition")
|
||||
n++
|
||||
}
|
||||
if n > 0 {
|
||||
slog.Info("hooks.command_migration_complete", "disabled_count", n)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package hooks_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
)
|
||||
|
||||
// migStore is a minimal HookStore backing the command-autodisable unit tests.
|
||||
// Only List + Update are exercised by the migration; Create/Delete/etc. are
|
||||
// stubbed to zero values.
|
||||
type migStore struct {
|
||||
rows []hooks.HookConfig
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (m *migStore) Create(context.Context, hooks.HookConfig) (uuid.UUID, error) {
|
||||
return uuid.Nil, nil
|
||||
}
|
||||
func (m *migStore) GetByID(_ context.Context, id uuid.UUID) (*hooks.HookConfig, error) {
|
||||
for i := range m.rows {
|
||||
if m.rows[i].ID == id {
|
||||
c := m.rows[i]
|
||||
return &c, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (m *migStore) List(_ context.Context, f hooks.ListFilter) ([]hooks.HookConfig, error) {
|
||||
if m.listErr != nil {
|
||||
return nil, m.listErr
|
||||
}
|
||||
out := []hooks.HookConfig{}
|
||||
for _, r := range m.rows {
|
||||
if f.Enabled != nil && r.Enabled != *f.Enabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *migStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
||||
for i := range m.rows {
|
||||
if m.rows[i].ID != id {
|
||||
continue
|
||||
}
|
||||
if v, ok := updates["enabled"].(bool); ok {
|
||||
m.rows[i].Enabled = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("row not found")
|
||||
}
|
||||
func (m *migStore) Delete(context.Context, uuid.UUID) error { return nil }
|
||||
func (m *migStore) ResolveForEvent(context.Context, hooks.Event) ([]hooks.HookConfig, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *migStore) WriteExecution(context.Context, hooks.HookExecution) error { return nil }
|
||||
|
||||
// fixtureRows covers every branch of the migration:
|
||||
//
|
||||
// A: enabled command + ui → should be disabled
|
||||
// B: already-disabled command + ui → skip (not in filter result)
|
||||
// C: enabled http + ui → skip (not command)
|
||||
// D: enabled script + builtin → skip (builtin carve-out)
|
||||
// E: enabled command + builtin → skip (defensive carve-out even though
|
||||
// Phase 04/05 never seed command builtins)
|
||||
func fixtureRows() []hooks.HookConfig {
|
||||
mk := func(ht hooks.HandlerType, src string, enabled bool) hooks.HookConfig {
|
||||
return hooks.HookConfig{
|
||||
ID: uuid.New(),
|
||||
TenantID: uuid.New(),
|
||||
Event: hooks.EventPreToolUse,
|
||||
HandlerType: ht,
|
||||
Scope: hooks.ScopeTenant,
|
||||
Source: src,
|
||||
Enabled: enabled,
|
||||
Version: 1,
|
||||
}
|
||||
}
|
||||
return []hooks.HookConfig{
|
||||
mk(hooks.HandlerCommand, "ui", true),
|
||||
mk(hooks.HandlerCommand, "ui", false),
|
||||
mk(hooks.HandlerHTTP, "ui", true),
|
||||
mk(hooks.HandlerScript, hooks.SourceBuiltin, true),
|
||||
mk(hooks.HandlerCommand, hooks.SourceBuiltin, true),
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableLegacyCommandHooks_StandardDisablesOnlyCommandUI(t *testing.T) {
|
||||
s := &migStore{rows: fixtureRows()}
|
||||
n, err := hooks.DisableLegacyCommandHooks(context.Background(), s, edition.Standard)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("disabled=%d, want 1 (only the enabled+command+ui row)", n)
|
||||
}
|
||||
|
||||
var cntCommandEnabled, cntCommandBuiltin, cntHTTP, cntScriptBuiltin int
|
||||
for _, r := range s.rows {
|
||||
switch {
|
||||
case r.HandlerType == hooks.HandlerCommand && r.Source == "ui" && r.Enabled:
|
||||
cntCommandEnabled++
|
||||
case r.HandlerType == hooks.HandlerCommand && r.Source == hooks.SourceBuiltin:
|
||||
if r.Enabled {
|
||||
cntCommandBuiltin++
|
||||
}
|
||||
case r.HandlerType == hooks.HandlerHTTP && r.Enabled:
|
||||
cntHTTP++
|
||||
case r.HandlerType == hooks.HandlerScript && r.Source == hooks.SourceBuiltin && r.Enabled:
|
||||
cntScriptBuiltin++
|
||||
}
|
||||
}
|
||||
if cntCommandEnabled != 0 {
|
||||
t.Errorf("command/ui rows still enabled: %d", cntCommandEnabled)
|
||||
}
|
||||
if cntCommandBuiltin != 1 {
|
||||
t.Errorf("command/builtin row should stay enabled: got %d", cntCommandBuiltin)
|
||||
}
|
||||
if cntHTTP != 1 {
|
||||
t.Errorf("http row touched: got %d enabled", cntHTTP)
|
||||
}
|
||||
if cntScriptBuiltin != 1 {
|
||||
t.Errorf("script/builtin row touched: got %d enabled", cntScriptBuiltin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableLegacyCommandHooks_Idempotent(t *testing.T) {
|
||||
s := &migStore{rows: fixtureRows()}
|
||||
if _, err := hooks.DisableLegacyCommandHooks(context.Background(), s, edition.Standard); err != nil {
|
||||
t.Fatalf("first run: %v", err)
|
||||
}
|
||||
n, err := hooks.DisableLegacyCommandHooks(context.Background(), s, edition.Standard)
|
||||
if err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("second run disabled=%d, want 0 (idempotent)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableLegacyCommandHooks_LiteNoOp(t *testing.T) {
|
||||
s := &migStore{rows: fixtureRows()}
|
||||
n, err := hooks.DisableLegacyCommandHooks(context.Background(), s, edition.Lite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("Lite returned disabled=%d, want 0", n)
|
||||
}
|
||||
// Nothing touched.
|
||||
for _, r := range s.rows {
|
||||
if r.HandlerType == hooks.HandlerCommand && r.Source == "ui" && !r.Enabled {
|
||||
// This row started disabled; fine.
|
||||
continue
|
||||
}
|
||||
if r.HandlerType == hooks.HandlerCommand && r.Source == "ui" && !r.Enabled {
|
||||
t.Errorf("Lite disabled a command row: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableLegacyCommandHooks_NilStore(t *testing.T) {
|
||||
n, err := hooks.DisableLegacyCommandHooks(context.Background(), nil, edition.Standard)
|
||||
if err != nil || n != 0 {
|
||||
t.Fatalf("nil store: n=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableLegacyCommandHooks_ListErrorPropagates(t *testing.T) {
|
||||
s := &migStore{listErr: errors.New("db down")}
|
||||
_, err := hooks.DisableLegacyCommandHooks(context.Background(), s, edition.Standard)
|
||||
if err == nil {
|
||||
t.Fatal("want error from List propagation")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user