f55747a281
Lands the bottom three layers of Phase 1: P1-08 internal/api: protocol_version + envelope + every WS message shape from spec.md §6.2 (Hello, Heartbeat, Job*, Schedule*, etc). Wire-format tests pin the JSON shape so a rename here breaks tests instead of silently breaking the agent. P1-02 + P1-03 internal/store: SQLite via modernc.org/sqlite, embed.FS + a tiny version table for hand-rolled migrations. 0001_initial.sql covers every table from spec.md §5 plus enrollment_tokens and host_schedule_version. Typed accessors for users / sessions / enrollment / audit. WAL + foreign_keys + busy_timeout on by default. P1-06 internal/crypto: XChaCha20-Poly1305 AEAD wrapper with per-message random nonce. Key file lifecycle (generate + refuse-to-overwrite, load with size validation). Optional additionalData binds ciphertext to the row that owns it. P1-04 internal/auth (partial — passwords + tokens; sessions middleware lands with the HTTP handlers): argon2id following RFC 9106 (64 MiB / t=3 / p=4 / 32B), constant-time verify. HashToken stores SHA-256 of session/agent/enrollment tokens so a stolen DB doesn't hand over credentials. Build floor moves to Go 1.25 (modernc.org/sqlite v1.50+ requires it); CI + Dockerfile + README updated. Markdown lint diagnostics on tasks.md cleared. All packages tested. ~70 new tests pass in <1s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 lines
2.7 KiB
Go
85 lines
2.7 KiB
Go
// Package store is the SQLite persistence layer (modernc.org/sqlite,
|
|
// no CGo). It owns the schema, exposes typed accessors, and hides
|
|
// the database/sql plumbing from the rest of the server.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite" // register the "sqlite" driver
|
|
)
|
|
|
|
// ErrNotFound is returned by accessors when a lookup misses.
|
|
var ErrNotFound = errors.New("store: not found")
|
|
|
|
// Store is a thin wrapper around *sql.DB that exposes the typed
|
|
// accessors used by the rest of the server. Callers should use the
|
|
// provided methods rather than reaching into DB() directly.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// Open opens (or creates) the SQLite database at path, applies all
|
|
// pending migrations, and returns a ready-to-use Store.
|
|
//
|
|
// The DSN sets:
|
|
// - _pragma=foreign_keys(1) — referential integrity is on
|
|
// - _pragma=journal_mode(WAL) — concurrent reads vs writes
|
|
// - _pragma=busy_timeout(5000) — wait 5s on lock contention
|
|
// - _time_format=sqlite — RFC 3339 read/write of TEXT timestamps
|
|
//
|
|
// Empty path uses an in-memory DB (useful for tests).
|
|
func Open(ctx context.Context, path string) (*Store, error) {
|
|
dsn := buildDSN(path)
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open %q: %w", path, err)
|
|
}
|
|
// modernc.org/sqlite is not safe for arbitrary high parallelism on
|
|
// a single file. WAL helps, but 1 writer + multiple readers is the
|
|
// only safe shape. Cap connections to keep that property explicit.
|
|
db.SetMaxOpenConns(8)
|
|
db.SetMaxIdleConns(4)
|
|
db.SetConnMaxLifetime(time.Hour)
|
|
|
|
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
if err := db.PingContext(pingCtx); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("ping: %w", err)
|
|
}
|
|
|
|
if err := migrate(ctx, db); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
// Close releases the underlying DB handle.
|
|
func (s *Store) Close() error { return s.db.Close() }
|
|
|
|
// DB returns the underlying *sql.DB. Reserved for tests and migrations
|
|
// — production code should add a typed method to this package instead.
|
|
func (s *Store) DB() *sql.DB { return s.db }
|
|
|
|
func buildDSN(path string) string {
|
|
if path == "" {
|
|
// Shared cache + named in-memory db so multiple connections see
|
|
// the same data — needed because we cap MaxOpenConns above.
|
|
return "file::memory:?cache=shared&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
|
|
}
|
|
q := url.Values{}
|
|
q.Add("_pragma", "foreign_keys(1)")
|
|
q.Add("_pragma", "journal_mode(WAL)")
|
|
q.Add("_pragma", "busy_timeout(5000)")
|
|
q.Add("_pragma", "synchronous(NORMAL)")
|
|
return "file:" + path + "?" + q.Encode()
|
|
}
|