hot-swap changed policy.star file
@@ -85,6 +85,7 @@ e, err := NewEngine(policyFile, posternDB)
if err != nil { t.Fatal(err) } + s := e.state.Load() tests := []struct { name string@@ -107,12 +108,12 @@ }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - hookVal, ok := e.globals[tt.fnName] + hookVal, ok := s.globals[tt.fnName] if !ok { t.Fatalf("%s not found in engine globals", tt.fnName) } - res, err := starlark.Call(e.thread, hookVal, nil, nil) + res, err := starlark.Call(s.thread, hookVal, nil, nil) if err != nil { t.Fatal(err) }@@ -130,11 +131,11 @@ })
} t.Run("not_found", func(t *testing.T) { - hookVal, ok := e.globals["test_not_found"] + hookVal, ok := s.globals["test_not_found"] if !ok { t.Fatal("test_not_found not found in engine globals") } - _, err := starlark.Call(e.thread, hookVal, nil, nil) + _, err := starlark.Call(s.thread, hookVal, nil, nil) if err == nil { t.Fatal("expected error for nonexistent binary") }
@@ -124,14 +124,15 @@ e, err := NewEngine(policyFile, posternDB)
if err != nil { t.Fatal(err) } + s := e.state.Load() // Test 1: Chained POST JSON - hookVal, ok := e.globals["test_post_json"] + hookVal, ok := s.globals["test_post_json"] if !ok { t.Fatal("test_post_json not found in engine globals") } - res, err := starlark.Call(e.thread, hookVal, nil, nil) + res, err := starlark.Call(s.thread, hookVal, nil, nil) if err != nil { t.Fatal(err) }@@ -141,12 +142,12 @@ t.Fatalf("got %s, want %s", res, want)
} // Test 2: Basic Auth + Custom Headers - hookVal, ok = e.globals["test_basic_auth"] + hookVal, ok = s.globals["test_basic_auth"] if !ok { t.Fatal("test_basic_auth not found in engine globals") } - res, err = starlark.Call(e.thread, hookVal, nil, nil) + res, err = starlark.Call(s.thread, hookVal, nil, nil) if err != nil { t.Fatal(err) }@@ -156,12 +157,12 @@ t.Fatalf("got %s, want %s", res, want)
} // Test 3: Error status handling - hookVal, ok = e.globals["test_error_status"] + hookVal, ok = s.globals["test_error_status"] if !ok { t.Fatal("test_error_status not found in engine globals") } - res, err = starlark.Call(e.thread, hookVal, nil, nil) + res, err = starlark.Call(s.thread, hookVal, nil, nil) if err != nil { t.Fatal(err) }@@ -171,12 +172,12 @@ t.Fatalf("got %s, want %s", res, want)
} // Test 4: Timeout (this should time out) - hookVal, ok = e.globals["test_timeout"] + hookVal, ok = s.globals["test_timeout"] if !ok { t.Fatal("test_timeout not found in engine globals") } - _, err = starlark.Call(e.thread, hookVal, nil, nil) + _, err = starlark.Call(s.thread, hookVal, nil, nil) if err == nil { t.Fatal("expected timeout to raise an error") }
@@ -58,33 +58,34 @@ e, err := NewEngine(policyFile, posternDB)
if err != nil { t.Fatal(err) } + s := e.state.Load() // KV Set initial value - hookVal, ok := e.globals["test_kv_set"] + hookVal, ok := s.globals["test_kv_set"] if !ok { t.Fatal("test_kv_set not found in engine globals") } k, v := starlark.String("key"), starlark.MakeInt(1) - _, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k, v}, nil) + _, err = starlark.Call(s.thread, hookVal, starlark.Tuple{k, v}, nil) if err != nil { t.Fatal(err) } // KV Set unsupported type k, unsupported := starlark.String("key"), &starlark.Dict{} - _, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k, unsupported}, nil) + _, err = starlark.Call(s.thread, hookVal, starlark.Tuple{k, unsupported}, nil) if err == nil { t.Fatal("expected unsupported type to throw an error") } // KV get initial value - hookVal, ok = e.globals["test_kv_get"] + hookVal, ok = s.globals["test_kv_get"] if !ok { t.Fatal("test_kv_get not found in engine globals") } - res, err := starlark.Call(e.thread, hookVal, starlark.Tuple{k}, nil) + res, err := starlark.Call(s.thread, hookVal, starlark.Tuple{k}, nil) if err != nil { t.Fatal(err) }@@ -93,11 +94,11 @@ t.Fatalf("got %s, want %s", res, v)
} // Get default value for non-existing key - hookVal, ok = e.globals["test_kv_get_default"] + hookVal, ok = s.globals["test_kv_get_default"] if !ok { t.Fatal("test_kv_get_default not found in engine globals") } - res, err = starlark.Call(e.thread, hookVal, starlark.Tuple{starlark.String("not-exist"), starlark.String("default")}, nil) + res, err = starlark.Call(s.thread, hookVal, starlark.Tuple{starlark.String("not-exist"), starlark.String("default")}, nil) if err != nil { t.Fatal(err) }@@ -106,12 +107,12 @@ t.Fatalf("got %s, want %s", res, want)
} // KV increment initial value by default delta=1 - hookVal, ok = e.globals["test_kv_inc"] + hookVal, ok = s.globals["test_kv_inc"] if !ok { t.Fatal("test_kv_inc not found in engine globals") } - res, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k}, nil) + res, err = starlark.Call(s.thread, hookVal, starlark.Tuple{k}, nil) if err != nil { t.Fatal(err) }@@ -120,12 +121,12 @@ t.Fatalf("got %s, want %s", res, want)
} // KV increment by delta=2 - hookVal, ok = e.globals["test_kv_inc_delta"] + hookVal, ok = s.globals["test_kv_inc_delta"] if !ok { t.Fatal("test_kv_inc not found in engine globals") } - res, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k, starlark.MakeInt(2)}, nil) + res, err = starlark.Call(s.thread, hookVal, starlark.Tuple{k, starlark.MakeInt(2)}, nil) if err != nil { t.Fatal(err) }@@ -134,12 +135,12 @@ t.Fatalf("got %s, want %s", res, want)
} // KV pop - hookVal, ok = e.globals["test_kv_pop"] + hookVal, ok = s.globals["test_kv_pop"] if !ok { t.Fatal("test_kv_pop not found in engine globals") } - res, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k}, nil) + res, err = starlark.Call(s.thread, hookVal, starlark.Tuple{k}, nil) if err != nil { t.Fatal(err) }@@ -148,7 +149,7 @@ t.Fatalf("got %s, want %s", res, want)
} // KV pop non-existing - res, err = starlark.Call(e.thread, hookVal, starlark.Tuple{k}, nil) + res, err = starlark.Call(s.thread, hookVal, starlark.Tuple{k}, nil) if err != nil { t.Fatal(err) }
@@ -69,8 +69,9 @@ e, err := NewEngine(policyFile, posternDB)
if err != nil { t.Fatal(err) } + s := e.state.Load() - hookVal, ok := e.globals["example"] + hookVal, ok := s.globals["example"] if !ok { t.Fatal("example not found in engine globals") }@@ -88,7 +89,7 @@ },
} k, v := starlark.String("key"), starlark.MakeInt(1) - result, err := starlark.Call(e.thread, hookVal, starlark.Tuple{k, &m, k, v}, nil) + result, err := starlark.Call(s.thread, hookVal, starlark.Tuple{k, &m, k, v}, nil) if err != nil { t.Fatal(err) }
@@ -2,10 +2,14 @@ package policy
import ( "context" + "crypto/sha256" "fmt" "log" + "log/slog" "net/netip" "os" + "sync/atomic" + "time" "postern/internal/db"@@ -23,11 +27,19 @@ Username string
Password string } +// engineState holds the reloadable, per-load Starlark environment for a +// single evaluation of policy.star. The active snapshot is swapped in +// atomically so in-flight hook calls keep a consistent view. +type engineState struct { + globals starlark.StringDict + thread *starlark.Thread +} + type Engine struct { - Listeners []ListenerConfig - UsersModule *UsersModule - globals starlark.StringDict - thread *starlark.Thread + Listeners []ListenerConfig + policyFile string + database *db.DB + state atomic.Pointer[engineState] } type ListenerConfig struct {@@ -37,8 +49,25 @@ TLSCertFile string
TLSKeyFile string } -func NewEngine(policyFile string, database *db.DB) (Engine, error) { - script, err := os.ReadFile(policyFile) +func NewEngine(policyFile string, database *db.DB) (*Engine, error) { + e := &Engine{ + policyFile: policyFile, + database: database, + } + + state, listeners, err := e.load() + if err != nil { + return nil, err + } + e.Listeners = listeners + e.state.Store(state) + return e, nil +} + +// load reads and evaluates policyFile, returning a fresh engineState along +// with the parsed listeners produced by this evaluation. +func (e *Engine) load() (*engineState, []ListenerConfig, error) { + script, err := os.ReadFile(e.policyFile) if err != nil { log.Fatal(err) }@@ -51,9 +80,6 @@ "getenv": starlark.NewBuiltin("getenv", starlarkGetenv),
}, } - // Create the users module - usersModule := NewUsersModule(database) - // Expose the "os", "mda", "users", and "http" modules to the Starlark global environment m := &MDA{ parsedListeners: make([]ListenerConfig, 0),@@ -63,11 +89,11 @@ predeclared := starlark.StringDict{
"exec": &ExecModule{}, "http": &HTTPModule{}, "json": json.Module, - "kv": NewKVModule(database), + "kv": NewKVModule(e.database), "mda": m, "os": osModule, "time": sltime.Module, - "users": usersModule, + "users": NewUsersModule(e.database), } // Create a Starlark thread. We customize the Print function@@ -83,36 +109,87 @@
// 1. Evaluate the script globals, err := starlark.ExecFileOptions(&syntax.FileOptions{}, thread, "policy.star", script, predeclared) if err != nil { - return Engine{}, err + return nil, nil, err } // 2. Call the init() function initVal, ok := globals["init"] if !ok { - return Engine{}, fmt.Errorf("init not found in policy") + return nil, nil, fmt.Errorf("init not found in policy") } _, err = starlark.Call(thread, initVal, nil, nil) if err != nil { - return Engine{}, err + return nil, nil, err } - engine := Engine{ - Listeners: m.parsedListeners, - UsersModule: usersModule, - globals: globals, - thread: thread, + state := &engineState{ + globals: globals, + thread: thread, + } + return state, m.parsedListeners, nil +} + +// reload re-evaluates policyFile and, on success, atomically swaps the new +// engineState into the engine. On any error the previous state is kept +// untouched so the last-known-good policy continues to serve. +func (e *Engine) reload() error { + state, _, err := e.load() + if err != nil { + return err } - return engine, err + e.state.Store(state) + return nil +} + +// StartHotReload spawns a goroutine that polls the policy file's content hash +// on the given interval and reloads whenever it changes. It is safe to call +// once at startup; the goroutine exits when ctx is canceled. +func (e *Engine) StartHotReload(ctx context.Context, interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + var lastHash [32]byte + if content, err := os.ReadFile(e.policyFile); err == nil { + lastHash = sha256.Sum256(content) + } + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + content, err := os.ReadFile(e.policyFile) + if err != nil { + slog.Error("policy file read failed", "file", e.policyFile, "err", err) + continue + } + currentHash := sha256.Sum256(content) + if currentHash == lastHash { + continue + } + lastHash = currentHash + slog.Info("policy file changed, reloading", "file", e.policyFile) + err = e.reload() + if err != nil { + slog.Error("failed to reload", "file", e.policyFile, "err", err) + } else { + slog.Info("policy reloaded", "file", e.policyFile) + } + } + } + }() } func (e *Engine) OnMessageSubmit(user User, msg *Message) (RelayConfig, error) { - hookVal, ok := e.globals["on_message_submit"] + s := e.state.Load() + hookVal, ok := s.globals["on_message_submit"] if !ok { return RelayConfig{}, nil } - result, err := starlark.Call(e.thread, hookVal, starlark.Tuple{user, msg}, nil) + result, err := starlark.Call(s.thread, hookVal, starlark.Tuple{user, msg}, nil) if err != nil { return RelayConfig{}, err }@@ -137,12 +214,13 @@ }
func (e *Engine) OnMessageDeliver(conn Conn, user User, msg *Message) (string, error) { defaultMailbox := "INBOX" - hookVal, ok := e.globals["on_message_deliver"] + s := e.state.Load() + hookVal, ok := s.globals["on_message_deliver"] if !ok { return defaultMailbox, nil } - result, err := starlark.Call(e.thread, hookVal, starlark.Tuple{conn, user, msg}, nil) + result, err := starlark.Call(s.thread, hookVal, starlark.Tuple{conn, user, msg}, nil) if err != nil { return defaultMailbox, err }@@ -158,12 +236,13 @@ return mailbox, nil
} func (e *Engine) OnMessageMoved(user User, msg *Message, sourceFolder, destFolder string) error { - hookVal, ok := e.globals["on_message_moved"] + s := e.state.Load() + hookVal, ok := s.globals["on_message_moved"] if !ok { return nil } - _, err := starlark.Call(e.thread, hookVal, starlark.Tuple{user, msg, starlark.String(sourceFolder), starlark.String(destFolder)}, nil) + _, err := starlark.Call(s.thread, hookVal, starlark.Tuple{user, msg, starlark.String(sourceFolder), starlark.String(destFolder)}, nil) return err }
@@ -2,6 +2,7 @@ package main
import ( "cmp" + "context" "crypto/tls" "fmt" "log"@@ -10,6 +11,7 @@ "os"
"path" "path/filepath" "runtime" + "time" "postern/internal/cli" "postern/internal/db"@@ -60,6 +62,7 @@ engine, err := policy.NewEngine(path.Join(os.Getenv("CONFIGURATION_DIRECTORY"), "policy.star"), posternDB)
if err != nil { return nil, fmt.Errorf("failed to initialize policy engine: %w", err) } + engine.StartHotReload(context.Background(), 10*time.Second) dkimSvc := dkim.New(&dkim.Config{ DB: posternDB,@@ -88,7 +91,7 @@ DB: posternDB,
Listen: listener.Address.String(), Persistence: p, TLSConfig: tlsConfig, - PolicyEngine: &engine, + PolicyEngine: engine, }) if err != nil { return nil, fmt.Errorf("failed to create IMAP server: %w", err)@@ -111,7 +114,7 @@ DB: posternDB,
Deliverer: services["imap"].(inbound.MessageDeliverer), Listen: listener.Address.String(), TLS: tlsConfig, - PolicyEngine: &engine, + PolicyEngine: engine, }) if err != nil { return nil, fmt.Errorf("failed to create SMTP inbound server: %w", err)@@ -134,7 +137,7 @@ DB: posternDB,
DKIM: dkimSvc, Listen: listener.Address.String(), TLS: tlsConfig, - PolicyEngine: &engine, + PolicyEngine: engine, }) if err != nil { return nil, fmt.Errorf("failed to create SMTP submission server: %w", err)@@ -152,7 +155,7 @@ submissionServer, err := submission.NewServer(&submission.Config{
DB: posternDB, DKIM: dkimSvc, Listen: listener.Address.String(), - PolicyEngine: &engine, + PolicyEngine: engine, IsLocalSubmission: true, }) if err != nil {