mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-06-02 14:35:12 +02:00
initial: Syntrex extraction from sentinel-community (615 files)
This commit is contained in:
commit
2c50c993b1
175 changed files with 32396 additions and 0 deletions
94
internal/application/lifecycle/manager.go
Normal file
94
internal/application/lifecycle/manager.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// Package lifecycle manages graceful shutdown with auto-save of session state,
|
||||
// cache flush, and database closure.
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ShutdownFunc is a function called during graceful shutdown.
|
||||
// Name is used for logging. The function receives a context with a deadline.
|
||||
type ShutdownFunc struct {
|
||||
Name string
|
||||
Fn func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Manager orchestrates graceful shutdown of all resources.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
hooks []ShutdownFunc
|
||||
timeout time.Duration
|
||||
done bool
|
||||
}
|
||||
|
||||
// NewManager creates a new lifecycle Manager.
|
||||
// Timeout is the maximum time allowed for all shutdown hooks to complete.
|
||||
func NewManager(timeout time.Duration) *Manager {
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &Manager{
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// OnShutdown registers a shutdown hook. Hooks are called in LIFO order
|
||||
// (last registered = first called), matching defer semantics.
|
||||
func (m *Manager) OnShutdown(name string, fn func(ctx context.Context) error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.hooks = append(m.hooks, ShutdownFunc{Name: name, Fn: fn})
|
||||
}
|
||||
|
||||
// OnClose registers an io.Closer as a shutdown hook.
|
||||
func (m *Manager) OnClose(name string, c io.Closer) {
|
||||
m.OnShutdown(name, func(_ context.Context) error {
|
||||
return c.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// Shutdown executes all registered hooks in reverse order (LIFO).
|
||||
// It logs each step and any errors. Returns the first error encountered.
|
||||
func (m *Manager) Shutdown() error {
|
||||
m.mu.Lock()
|
||||
if m.done {
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
m.done = true
|
||||
hooks := make([]ShutdownFunc, len(m.hooks))
|
||||
copy(hooks, m.hooks)
|
||||
m.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
|
||||
defer cancel()
|
||||
|
||||
log.Printf("Graceful shutdown started (%d hooks, timeout %s)", len(hooks), m.timeout)
|
||||
|
||||
var firstErr error
|
||||
// Execute in reverse order (LIFO).
|
||||
for i := len(hooks) - 1; i >= 0; i-- {
|
||||
h := hooks[i]
|
||||
log.Printf(" shutdown: %s", h.Name)
|
||||
if err := h.Fn(ctx); err != nil {
|
||||
log.Printf(" shutdown %s: ERROR: %v", h.Name, err)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Graceful shutdown complete")
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// Done returns true if Shutdown has already been called.
|
||||
func (m *Manager) Done() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.done
|
||||
}
|
||||
125
internal/application/lifecycle/manager_test.go
Normal file
125
internal/application/lifecycle/manager_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewManager_Defaults(t *testing.T) {
|
||||
m := NewManager(0)
|
||||
require.NotNil(t, m)
|
||||
assert.Equal(t, 10*time.Second, m.timeout)
|
||||
assert.False(t, m.Done())
|
||||
}
|
||||
|
||||
func TestNewManager_CustomTimeout(t *testing.T) {
|
||||
m := NewManager(5 * time.Second)
|
||||
assert.Equal(t, 5*time.Second, m.timeout)
|
||||
}
|
||||
|
||||
func TestManager_Shutdown_LIFO(t *testing.T) {
|
||||
m := NewManager(5 * time.Second)
|
||||
order := []string{}
|
||||
|
||||
m.OnShutdown("first", func(_ context.Context) error {
|
||||
order = append(order, "first")
|
||||
return nil
|
||||
})
|
||||
m.OnShutdown("second", func(_ context.Context) error {
|
||||
order = append(order, "second")
|
||||
return nil
|
||||
})
|
||||
m.OnShutdown("third", func(_ context.Context) error {
|
||||
order = append(order, "third")
|
||||
return nil
|
||||
})
|
||||
|
||||
err := m.Shutdown()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"third", "second", "first"}, order)
|
||||
assert.True(t, m.Done())
|
||||
}
|
||||
|
||||
func TestManager_Shutdown_Idempotent(t *testing.T) {
|
||||
m := NewManager(5 * time.Second)
|
||||
count := 0
|
||||
m.OnShutdown("counter", func(_ context.Context) error {
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
|
||||
_ = m.Shutdown()
|
||||
_ = m.Shutdown()
|
||||
_ = m.Shutdown()
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestManager_Shutdown_ReturnsFirstError(t *testing.T) {
|
||||
m := NewManager(5 * time.Second)
|
||||
errFirst := errors.New("first error")
|
||||
errSecond := errors.New("second error")
|
||||
|
||||
m.OnShutdown("ok", func(_ context.Context) error { return nil })
|
||||
m.OnShutdown("fail1", func(_ context.Context) error { return errFirst })
|
||||
m.OnShutdown("fail2", func(_ context.Context) error { return errSecond })
|
||||
|
||||
// LIFO: fail2 runs first, then fail1, then ok.
|
||||
err := m.Shutdown()
|
||||
assert.Equal(t, errSecond, err)
|
||||
}
|
||||
|
||||
func TestManager_Shutdown_ContinuesOnError(t *testing.T) {
|
||||
m := NewManager(5 * time.Second)
|
||||
reached := false
|
||||
|
||||
m.OnShutdown("will-run", func(_ context.Context) error {
|
||||
reached = true
|
||||
return nil
|
||||
})
|
||||
m.OnShutdown("will-fail", func(_ context.Context) error {
|
||||
return errors.New("fail")
|
||||
})
|
||||
|
||||
_ = m.Shutdown()
|
||||
assert.True(t, reached, "hook after error should still run")
|
||||
}
|
||||
|
||||
type mockCloser struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (m *mockCloser) Close() error {
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestManager_OnClose(t *testing.T) {
|
||||
m := NewManager(5 * time.Second)
|
||||
mc := &mockCloser{}
|
||||
|
||||
m.OnClose("mock-closer", mc)
|
||||
_ = m.Shutdown()
|
||||
assert.True(t, mc.closed)
|
||||
}
|
||||
|
||||
func TestManager_OnClose_Interface(t *testing.T) {
|
||||
m := NewManager(5 * time.Second)
|
||||
// Verify OnClose accepts io.Closer interface.
|
||||
var c io.Closer = &mockCloser{}
|
||||
m.OnClose("io-closer", c)
|
||||
err := m.Shutdown()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestManager_EmptyShutdown(t *testing.T) {
|
||||
m := NewManager(5 * time.Second)
|
||||
err := m.Shutdown()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, m.Done())
|
||||
}
|
||||
75
internal/application/lifecycle/shredder.go
Normal file
75
internal/application/lifecycle/shredder.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package lifecycle
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ShredDatabase irreversibly destroys a database file by overwriting
|
||||
// its header with random bytes, making it unreadable without backup.
|
||||
//
|
||||
// For SQLite: overwrites first 100 bytes (header with magic bytes "SQLite format 3\000").
|
||||
// For BoltDB: overwrites first 4096 bytes (two 4KB meta pages).
|
||||
//
|
||||
// WARNING: This operation is IRREVERSIBLE. Data is only recoverable from peer backup.
|
||||
func ShredDatabase(dbPath string, headerSize int) error {
|
||||
f, err := os.OpenFile(dbPath, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shred: open %s: %w", dbPath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Overwrite header with random bytes.
|
||||
noise := make([]byte, headerSize)
|
||||
if _, err := rand.Read(noise); err != nil {
|
||||
return fmt.Errorf("shred: random: %w", err)
|
||||
}
|
||||
|
||||
if _, err := f.WriteAt(noise, 0); err != nil {
|
||||
return fmt.Errorf("shred: write %s: %w", dbPath, err)
|
||||
}
|
||||
|
||||
// Force flush to disk.
|
||||
if err := f.Sync(); err != nil {
|
||||
return fmt.Errorf("shred: sync %s: %w", dbPath, err)
|
||||
}
|
||||
|
||||
log.Printf("SHRED: %s header (%d bytes) destroyed", dbPath, headerSize)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShredSQLite shreds a SQLite database (100-byte header).
|
||||
func ShredSQLite(dbPath string) error {
|
||||
return ShredDatabase(dbPath, 100)
|
||||
}
|
||||
|
||||
// ShredBoltDB shreds a BoltDB database (4096-byte meta pages).
|
||||
func ShredBoltDB(dbPath string) error {
|
||||
return ShredDatabase(dbPath, 4096)
|
||||
}
|
||||
|
||||
// ShredAll shreds all known database files in the .rlm directory.
|
||||
func ShredAll(rlmDir string) []error {
|
||||
var errs []error
|
||||
|
||||
sqlitePath := rlmDir + "/memory/memory_bridge_v2.db"
|
||||
if _, err := os.Stat(sqlitePath); err == nil {
|
||||
if err := ShredSQLite(sqlitePath); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
boltPath := rlmDir + "/cache.db"
|
||||
if _, err := os.Stat(boltPath); err == nil {
|
||||
if err := ShredBoltDB(boltPath); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) == 0 {
|
||||
log.Printf("SHRED: All databases destroyed in %s", rlmDir)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
75
internal/application/lifecycle/shredder_test.go
Normal file
75
internal/application/lifecycle/shredder_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package lifecycle
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestShredSQLite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create fake SQLite file with magic header.
|
||||
header := []byte("SQLite format 3\x00")
|
||||
data := make([]byte, 4096)
|
||||
copy(data, header)
|
||||
|
||||
require.NoError(t, os.WriteFile(dbPath, data, 0644))
|
||||
|
||||
// Verify magic exists.
|
||||
content, _ := os.ReadFile(dbPath)
|
||||
assert.Equal(t, "SQLite format 3", string(content[:15]))
|
||||
|
||||
// Shred.
|
||||
err := ShredSQLite(dbPath)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify magic is destroyed.
|
||||
content, _ = os.ReadFile(dbPath)
|
||||
assert.NotEqual(t, "SQLite format 3", string(content[:15]),
|
||||
"SQLite header should be shredded")
|
||||
assert.Len(t, content, 4096, "file size should not change")
|
||||
}
|
||||
|
||||
func TestShredBoltDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "cache.db")
|
||||
|
||||
// Create fake BoltDB file.
|
||||
data := make([]byte, 8192) // 2 pages
|
||||
copy(data, []byte("BOLT\x00\x00"))
|
||||
require.NoError(t, os.WriteFile(dbPath, data, 0644))
|
||||
|
||||
err := ShredBoltDB(dbPath)
|
||||
assert.NoError(t, err)
|
||||
|
||||
content, _ := os.ReadFile(dbPath)
|
||||
assert.NotEqual(t, "BOLT", string(content[:4]),
|
||||
"BoltDB header should be shredded")
|
||||
}
|
||||
|
||||
func TestShredAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create directory structure.
|
||||
memDir := filepath.Join(dir, "memory")
|
||||
os.MkdirAll(memDir, 0755)
|
||||
|
||||
// Create fake databases.
|
||||
os.WriteFile(filepath.Join(memDir, "memory_bridge_v2.db"),
|
||||
make([]byte, 4096), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "cache.db"),
|
||||
make([]byte, 8192), 0644)
|
||||
|
||||
errs := ShredAll(dir)
|
||||
assert.Empty(t, errs, "should shred without errors")
|
||||
}
|
||||
|
||||
func TestShred_NonexistentFile(t *testing.T) {
|
||||
err := ShredSQLite("/nonexistent/path/db.sqlite")
|
||||
assert.Error(t, err, "should error on nonexistent file")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue