Phase 1: Control Service skeleton (Go, SQLite, session login, Docker, Caddy)
- Go service with its own multi-stage Dockerfile and docker-compose.yml; runs as container webrestrict-control on port 8791. - SQLite schema for the full AGENTS.md §7 data model via versioned migrations (PRAGMA user_version); audit log is append-only. - Configuration from the process environment; the real env file is gitignored and a template is committed. No secrets in the repo. - Separate session login (signed HMAC cookie + CSRF) for Sam. - GET /health; HTMX + server-rendered dashboard styled with the Family Home Lab tokens.css (copied) and Inter fonts; dark mode; phone-friendly. - Caddy vhost for webrestrict.lab.audasmedia.com.au applied on .35 (Let's Encrypt cert issued); route recorded in deploy/caddy/webrestrict.caddy. - Unit tests for config, auth and store. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
132
internal/store/store.go
Normal file
132
internal/store/store.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Package store owns the SQLite database. It is the single source of truth for
|
||||
// the Control Service (SYSTEM_MAP.md).
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo)
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// Store wraps the database handle.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the SQLite database at path.
|
||||
func Open(path string) (*Store, error) {
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)",
|
||||
path,
|
||||
)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
// modernc/sqlite serialises writes; one connection avoids lock churn.
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// DB exposes the handle for read-only queries in later phases.
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// Ping verifies the database is reachable.
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.db.PingContext(ctx)
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// Migrate applies every embedded migration that has not run yet. It tracks the
|
||||
// applied version with PRAGMA user_version, so reruns are safe.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
files, err := migrationFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var version int
|
||||
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
|
||||
return fmt.Errorf("read user_version: %w", err)
|
||||
}
|
||||
|
||||
for i := version; i < len(files); i++ {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %d: %w", i+1, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, files[i].sql); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("migration %s: %w", files[i].name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("set user_version %d: %w", i+1, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", files[i].name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type migration struct {
|
||||
name string
|
||||
sql string
|
||||
}
|
||||
|
||||
func migrationFiles() ([]migration, error) {
|
||||
entries, err := fs.ReadDir(migrationFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names) // 001_, 002_, ... apply in order
|
||||
|
||||
out := make([]migration, 0, len(names))
|
||||
for _, n := range names {
|
||||
b, err := fs.ReadFile(migrationFS, "migrations/"+n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration %s: %w", n, err)
|
||||
}
|
||||
out = append(out, migration{name: n, sql: string(b)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Audit appends one row to the append-only audit log.
|
||||
func (s *Store) Audit(ctx context.Context, actor, action string, deviceID *int64, detail string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO audit (actor, action, device_id, detail) VALUES (?, ?, ?, ?)`,
|
||||
actor, action, deviceID, detail,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit insert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountDevices returns the number of registered devices (0 in Phase 1).
|
||||
func (s *Store) CountDevices(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM devices`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count devices: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
Reference in New Issue
Block a user