This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
package store
import (
"context"
"database/sql"
"fmt"
"github.com/iceBear67/simplepages/internal/cas"
)
// Blob is what the database believes about one piece of content: how long it
// is, and whether its bytes have been verified onto disk yet.
type Blob struct {
Digest cas.Digest
Size int64
Present bool
}
// Blob looks up one blob. ErrNotFound means no manifest has ever declared it,
// which is what lets the upload endpoint refuse content nobody asked for.
func (d *DB) Blob(ctx context.Context, digest cas.Digest) (*Blob, error) {
b := Blob{Digest: digest}
err := d.r.QueryRowContext(ctx,
`SELECT size, present FROM blobs WHERE digest = ?`, digest.Bytes()).
Scan(&b.Size, &b.Present)
if err != nil {
return nil, mapErr(err)
}
return &b, nil
}
// MarkBlobPresent records that a blob's content has been verified onto disk.
//
// Idempotent, because a retried `pages deploy` re-uploads blobs that in the
// meantime became present, and because the row may already say so if a previous
// attempt was interrupted between the rename and this update.
//
// The size is part of the WHERE clause rather than something to overwrite: a
// digest determines its content and therefore its length, so a row that
// disagrees means the manifest and the upload cannot both be describing the same
// bytes, and the safe move is to change nothing.
func (d *DB) MarkBlobPresent(ctx context.Context, digest cas.Digest, size int64) error {
return d.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE blobs SET present = 1 WHERE digest = ? AND size = ?`, digest.Bytes(), size)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n > 0 {
return nil
}
// Nothing was updated. Distinguish "no such blob" from "the row says a
// different size" so the caller can report something actionable.
var have int64
err = tx.QueryRowContext(ctx, `SELECT size FROM blobs WHERE digest = ?`, digest.Bytes()).Scan(&have)
if err != nil {
return mapErr(err)
}
return fmt.Errorf("%w: blob %s is %d bytes here, upload declared %d",
cas.ErrSizeMismatch, digest, have, size)
})
}
// MissingBlobs lists the distinct digests a deployment needs whose content has
// not been uploaded yet. It is both the manifest response and the check
// finalize runs before assembling anything.
func (d *DB) MissingBlobs(ctx context.Context, deploymentID int64) ([]cas.Digest, error) {
rows, err := d.r.QueryContext(ctx, `
SELECT DISTINCT f.digest
FROM deployment_files f JOIN blobs b ON b.digest = f.digest
WHERE f.deployment_id = ? AND b.present = 0
ORDER BY f.digest`, deploymentID)
if err != nil {
return nil, err
}
defer rows.Close()
var missing []cas.Digest
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return nil, err
}
dg, err := cas.FromBytes(raw)
if err != nil {
return nil, err
}
missing = append(missing, dg)
}
return missing, rows.Err()
}
+244
View File
@@ -0,0 +1,244 @@
// Package store owns the SQLite database: connection management, migrations and
// every query the server runs. It is server-side only — the CLI must never reach
// it, and cmd/pages/deps_test.go enforces that.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"net/url"
"runtime"
"time"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
const driverName = "sqlite"
// maxTxAttempts bounds the BEGIN-level retry loop. busy_timeout already handles
// contention inside a transaction; these retries exist for the cases it cannot
// cover (see Tx).
const maxTxAttempts = 5
// DB holds the two connection pools SQLite wants under concurrent load.
//
// SQLite permits exactly one writer at a time. Handing database/sql a single
// pool means readers and the writer compete for the same connections and every
// static request can end up queued behind a deployment commit. Two pools make
// the rule explicit instead: W is capped at one connection so writes serialise
// in Go (where waiting is cheap and fair) rather than in SQLite (where it
// surfaces as SQLITE_BUSY), and R holds the concurrent readers that WAL mode
// lets run undisturbed alongside the writer.
type DB struct {
w *sql.DB
r *sql.DB
path string
log *slog.Logger
}
// Open connects to the database at path, applying the pragmas both pools need,
// and runs any outstanding migrations.
func Open(ctx context.Context, path string, log *slog.Logger) (*DB, error) {
w, err := sql.Open(driverName, dsn(path, false))
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
// One writer, by construction. Idle == open and no lifetime cap: rebuilding a
// SQLite connection means replaying every pragma, and a server-side
// connection has nothing to go stale against.
w.SetMaxOpenConns(1)
w.SetMaxIdleConns(1)
w.SetConnMaxLifetime(0)
w.SetConnMaxIdleTime(0)
// The writer opens first so the database file, and its WAL, exist before any
// read-only connection tries to attach to them.
if err := w.PingContext(ctx); err != nil {
w.Close()
return nil, fmt.Errorf("open %s: %w", path, err)
}
readers := max(4, runtime.NumCPU())
r, err := sql.Open(driverName, dsn(path, true))
if err != nil {
w.Close()
return nil, fmt.Errorf("open %s (read pool): %w", path, err)
}
r.SetMaxOpenConns(readers)
r.SetMaxIdleConns(readers)
r.SetConnMaxLifetime(0)
r.SetConnMaxIdleTime(0)
if err := r.PingContext(ctx); err != nil {
w.Close()
r.Close()
return nil, fmt.Errorf("open %s (read pool): %w", path, err)
}
d := &DB{w: w, r: r, path: path, log: log}
if err := d.migrate(ctx); err != nil {
d.Close()
return nil, err
}
return d, nil
}
// dsn builds the connection string. The pragmas are per-connection state, so
// every connection in both pools must carry them.
func dsn(path string, readOnly bool) string {
q := url.Values{}
// Ordering inside the driver is fixed (busy_timeout first), so the list here
// is grouped by intent rather than by application order.
q.Add("_pragma", "busy_timeout(10000)") // wait for a lock instead of failing
q.Add("_pragma", "journal_mode(WAL)") // readers do not block the writer
q.Add("_pragma", "synchronous(NORMAL)") // the correct pairing with WAL
q.Add("_pragma", "foreign_keys(ON)") // off by default in SQLite
q.Add("_pragma", "recursive_triggers(ON)")
q.Add("_pragma", "temp_store(MEMORY)")
q.Add("_pragma", "wal_autocheckpoint(1000)")
if readOnly {
// A guard rail, not a security boundary: it turns "this query was meant to
// be a read" from a silent lock contention bug into an immediate error.
q.Set("_query_only", "true")
} else {
// Every transaction takes the write lock at BEGIN. Without this a
// transaction that starts with a SELECT and later writes must upgrade its
// lock, and an upgrade that finds another writer fails with SQLITE_BUSY
// immediately — busy_timeout does not apply to it, because waiting could
// only ever deadlock.
q.Set("_txlock", "immediate")
}
return "file:" + path + "?" + q.Encode()
}
// Reader returns the read-only pool. Queries that must observe uncommitted
// changes belong inside the writing transaction instead.
func (d *DB) Reader() *sql.DB { return d.r }
// Path is the database file's location.
func (d *DB) Path() string { return d.path }
// Tx runs fn inside a single write transaction and commits it, retrying from
// the top when SQLite reports contention.
//
// fn may be called more than once, so it must not have side effects outside the
// transaction — no file writes, no atomic pointer stores, no channel sends.
// Publishing a change to the rest of the process is the caller's job, after Tx
// returns nil.
func (d *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error {
var err error
for attempt := range maxTxAttempts {
if attempt > 0 {
// Exponential backoff with jitter, so two contending writers do not
// retry in lockstep.
backoff := time.Duration(1<<attempt) * time.Millisecond
select {
case <-time.After(backoff/2 + time.Duration(rand.Int64N(int64(backoff)))):
case <-ctx.Done():
return ctx.Err()
}
}
err = d.tx(ctx, fn)
if err == nil {
return nil
}
if !IsBusy(err) || ctx.Err() != nil {
return err
}
if d.log != nil {
d.log.Debug("write transaction contended, retrying",
"attempt", attempt+1, "err", err)
}
}
return fmt.Errorf("write transaction still contended after %d attempts: %w", maxTxAttempts, err)
}
func (d *DB) tx(ctx context.Context, fn func(*sql.Tx) error) (err error) {
tx, err := d.w.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p)
}
if err != nil {
tx.Rollback()
}
}()
if err = fn(tx); err != nil {
return err
}
return tx.Commit()
}
// Close checkpoints the WAL and shuts both pools down.
func (d *DB) Close() error {
var first error
if d.r != nil {
// Readers go first: a TRUNCATE checkpoint needs the WAL to itself.
if err := d.r.Close(); err != nil {
first = err
}
}
if d.w != nil {
// Fold the WAL back into the database file so a restart — or an operator
// copying the directory — sees one self-contained file.
if _, err := d.w.ExecContext(context.Background(), "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil && d.log != nil {
d.log.Warn("wal checkpoint on close failed", "err", err)
}
if err := d.w.Close(); err != nil && first == nil {
first = err
}
}
return first
}
// IsBusy reports whether err is SQLite telling us the database was locked.
func IsBusy(err error) bool {
var serr *sqlite.Error
if !errors.As(err, &serr) {
return false
}
switch serr.Code() {
case sqlite3.SQLITE_BUSY, sqlite3.SQLITE_BUSY_SNAPSHOT,
sqlite3.SQLITE_LOCKED, sqlite3.SQLITE_LOCKED_SHAREDCACHE:
return true
}
return false
}
// IsConstraint reports whether err is a constraint violation, optionally of a
// specific extended kind (sqlite3.SQLITE_CONSTRAINT_UNIQUE, ...).
func IsConstraint(err error, extended ...int) bool {
var serr *sqlite.Error
if !errors.As(err, &serr) {
return false
}
code := serr.Code()
if code&0xff != sqlite3.SQLITE_CONSTRAINT {
return false
}
if len(extended) == 0 {
return true
}
for _, e := range extended {
if code == e {
return true
}
}
return false
}
// nowFunc is the clock the store stamps rows with. Tests replace it.
var nowFunc = time.Now
// unixNow is the timestamp format every *_at column uses: whole seconds, which
// is what the SQL triggers' unixepoch() produces.
func unixNow() int64 { return nowFunc().Unix() }
+453
View File
@@ -0,0 +1,453 @@
package store
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/iceBear67/simplepages/internal/cas"
)
// State is where a deployment sits in its lifecycle. The values mirror the
// CHECK constraint in the schema; api carries the same set for the wire and
// TestStateConstantsMatchTheWire asserts the two never drift.
type State string
const (
// StatePending: the row exists and nothing else does.
StatePending State = "pending"
// StateUploading: a manifest has been accepted, so every blob it names is
// already refcounted and safe from GC while its content is still arriving.
StateUploading State = "uploading"
// StateReady: every blob is present and the directory tree is assembled.
// Only a ready deployment may be activated.
StateReady State = "ready"
// StateFailed: abandoned. Its manifest rows get dropped so refcounts fall
// and GC can reclaim whatever only it referenced.
StateFailed State = "failed"
// StateDeleting: GC has claimed it and is removing its tree.
StateDeleting State = "deleting"
)
// Deployment is a row of the deployments table.
//
// ID is internal and never leaves the server; PublicID is what the API and the
// CLI use. Keeping them separate means a URL cannot be walked to a neighbouring
// project's deployment by incrementing an integer.
type Deployment struct {
ID int64
PublicID string
ProjectID int64
State State
Active bool
FileCount int
TotalBytes int64
// CreatedByKey is empty once that key has been deleted; the schema sets it
// to NULL rather than losing the deployment.
CreatedByKey string
Meta map[string]string
Error string
CreatedAt time.Time
FinalizedAt *time.Time
ActivatedAt *time.Time
DeactivatedAt *time.Time
}
// FileRow is one manifest entry as stored.
//
// There is deliberately no mode: a client-supplied permission bit must never
// reach the filesystem, which is why the schema has no column for one either.
// The encoding column is likewise not represented — v1 only ever writes ” —
// but it stays in the primary key so pre-compressed variants can be added later
// without a painful migration.
type FileRow struct {
Path string
Digest cas.Digest
Size int64
}
const deploymentColumns = `id, public_id, project_id, state, active, file_count, total_bytes,
created_by_key, meta, error, created_at, finalized_at, activated_at, deactivated_at`
func scanDeployment(row rowScanner) (*Deployment, error) {
var d Deployment
var createdBy, meta, errText sql.NullString
var created int64
var finalized, activated, deactivated sql.NullInt64
err := row.Scan(&d.ID, &d.PublicID, &d.ProjectID, &d.State, &d.Active, &d.FileCount,
&d.TotalBytes, &createdBy, &meta, &errText, &created, &finalized, &activated, &deactivated)
if err != nil {
return nil, mapErr(err)
}
d.CreatedByKey = createdBy.String
d.Error = errText.String
d.CreatedAt = time.Unix(created, 0).UTC()
d.FinalizedAt = timePtr(finalized)
d.ActivatedAt = timePtr(activated)
d.DeactivatedAt = timePtr(deactivated)
if meta.String != "" && meta.String != "{}" {
// Metadata is whatever the CI job attached. A row that will not parse is
// not worth failing a rollback over, so it is dropped rather than
// returned as an error.
_ = json.Unmarshal([]byte(meta.String), &d.Meta)
}
return &d, nil
}
// NewDeploymentID mints a deployment's public identifier.
//
// 64 bits from crypto/rand, which is not a capability and is not treated as
// one: every endpoint taking a deployment id sits behind RequireProject, and
// DeploymentByPublicID is always scoped by project, so knowing an id grants
// nothing. The randomness is here so two concurrent CI jobs never collide and
// so ids carry no information about how many deployments the server has seen.
func NewDeploymentID() (string, error) {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
return "dpl_" + hex.EncodeToString(buf[:]), nil
}
// CreateDeployment inserts a pending deployment and fills in its ID, PublicID
// and CreatedAt. Nothing touches the filesystem until a manifest arrives.
func (d *DB) CreateDeployment(ctx context.Context, dep *Deployment) error {
if dep.PublicID == "" {
id, err := NewDeploymentID()
if err != nil {
return err
}
dep.PublicID = id
}
meta := "{}"
if len(dep.Meta) > 0 {
b, err := json.Marshal(dep.Meta)
if err != nil {
return err
}
meta = string(b)
}
now := unixNow()
return d.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
INSERT INTO deployments (public_id, project_id, state, created_by_key, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
dep.PublicID, dep.ProjectID, StatePending, nullString(dep.CreatedByKey), meta, now)
if err != nil {
return mapErr(err)
}
id, err := res.LastInsertId()
if err != nil {
return err
}
dep.ID = id
dep.State = StatePending
dep.CreatedAt = time.Unix(now, 0).UTC()
return nil
})
}
// DeploymentByPublicID looks up one deployment within one project.
//
// The project is part of the lookup rather than something to check afterwards.
// A handler holding a project-scoped identity therefore cannot name another
// project's deployment at all: the wrong project simply yields ErrNotFound,
// which is also the right answer to give — it reveals nothing about whether the
// id exists elsewhere.
func (d *DB) DeploymentByPublicID(ctx context.Context, projectID int64, publicID string) (*Deployment, error) {
return scanDeployment(d.r.QueryRowContext(ctx,
`SELECT `+deploymentColumns+` FROM deployments WHERE project_id = ? AND public_id = ?`,
projectID, publicID))
}
// ActiveDeployment returns the deployment a project is currently serving, or
// ErrNotFound when it has never activated one. The partial unique index makes
// "at most one" a property of the database rather than of this query.
func (d *DB) ActiveDeployment(ctx context.Context, projectID int64) (*Deployment, error) {
return scanDeployment(d.r.QueryRowContext(ctx,
`SELECT `+deploymentColumns+` FROM deployments WHERE project_id = ? AND active = 1`,
projectID))
}
// ListDeployments pages one project's deployments, newest first.
//
// The cursor is the public id of the last row of the previous page. An id that
// no longer exists — GC ran between pages — yields an empty page rather than an
// error, which is the behaviour a paging client can actually handle.
func (d *DB) ListDeployments(ctx context.Context, projectID int64, state State, limit int, cursor string) (deployments []*Deployment, next string, err error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := d.r.QueryContext(ctx, `
SELECT `+deploymentColumns+` FROM deployments
WHERE project_id = ?
AND (? = '' OR state = ?)
AND (? = '' OR id < (SELECT id FROM deployments WHERE public_id = ?))
ORDER BY id DESC LIMIT ?`,
projectID, string(state), string(state), cursor, cursor, limit+1)
if err != nil {
return nil, "", err
}
defer rows.Close()
for rows.Next() {
dep, err := scanDeployment(rows)
if err != nil {
return nil, "", err
}
deployments = append(deployments, dep)
}
if err := rows.Err(); err != nil {
return nil, "", err
}
if len(deployments) > limit {
deployments = deployments[:limit]
next = deployments[len(deployments)-1].PublicID
}
return deployments, next, nil
}
// SetManifest replaces a deployment's file list and reports which blobs the
// server does not have content for yet.
//
// The ordering here is the point of the whole upload protocol. The manifest
// rows land *before* any blob is uploaded, and the insert triggers bump each
// blob's refcount as they do, so from this transaction's commit onwards every
// blob the deployment needs is protected from GC. That closes the race the
// obvious design has: "the server told me it already had this blob, then
// deleted it while I was uploading the others".
//
// Re-sending a manifest is allowed while the deployment is pending or
// uploading, because a CLI that lost its connection mid-negotiation should be
// able to start over without creating a second deployment.
func (d *DB) SetManifest(ctx context.Context, deploymentID int64, files []FileRow) (missing []cas.Digest, missingBytes int64, err error) {
// One entry per distinct digest, in first-appearance order, so the missing
// list — and therefore the client's upload order — is deterministic.
type need struct {
digest cas.Digest
size int64
}
var needs []need
seen := make(map[cas.Digest]int64, len(files))
var totalBytes int64
for _, f := range files {
totalBytes += f.Size
if prev, ok := seen[f.Digest]; ok {
if prev != f.Size {
return nil, 0, fmt.Errorf("%w: the manifest gives digest %s both %d and %d bytes",
cas.ErrSizeMismatch, f.Digest, prev, f.Size)
}
continue
}
seen[f.Digest] = f.Size
needs = append(needs, need{f.Digest, f.Size})
}
now := unixNow()
err = d.Tx(ctx, func(tx *sql.Tx) error {
// Tx re-runs this function on SQLITE_BUSY, so the results are rebuilt
// from scratch each attempt rather than appended to.
missing, missingBytes = nil, 0
var state State
if err := tx.QueryRowContext(ctx,
`SELECT state FROM deployments WHERE id = ?`, deploymentID).Scan(&state); err != nil {
return mapErr(err)
}
if state != StatePending && state != StateUploading {
return fmt.Errorf("%w: deployment is %s; a manifest may only be set while pending or uploading",
ErrConflict, state)
}
// Discard any earlier attempt. A refcount that goes 1 -> 0 -> 1 inside
// this transaction is safe because GC needs the same single write
// connection and so can never observe the zero.
if _, err := tx.ExecContext(ctx,
`DELETE FROM deployment_files WHERE deployment_id = ?`, deploymentID); err != nil {
return err
}
// Prepared statements rather than batched multi-row VALUES: a 50,000
// file manifest is a few hundred milliseconds of Exec either way, and
// this version has no chunk arithmetic to get wrong and no bound on how
// many parameters one statement may carry.
insertBlob, err := tx.PrepareContext(ctx, `
INSERT INTO blobs (digest, size, present, created_at, last_ref_at)
VALUES (?, ?, 0, ?, ?) ON CONFLICT(digest) DO NOTHING`)
if err != nil {
return err
}
defer insertBlob.Close()
readBlob, err := tx.PrepareContext(ctx, `SELECT size, present FROM blobs WHERE digest = ?`)
if err != nil {
return err
}
defer readBlob.Close()
for _, n := range needs {
if _, err := insertBlob.ExecContext(ctx, n.digest.Bytes(), n.size, now, now); err != nil {
return err
}
var have int64
var present bool
if err := readBlob.QueryRowContext(ctx, n.digest.Bytes()).Scan(&have, &present); err != nil {
return mapErr(err)
}
if have != n.size {
// A digest fixes its content and therefore its length, so this
// is a client that computed one of the two wrong. Surfacing it
// here beats accepting a manifest whose byte totals are fiction.
return fmt.Errorf("%w: digest %s is %d bytes on this server, the manifest declares %d",
cas.ErrSizeMismatch, n.digest, have, n.size)
}
if !present {
missing = append(missing, n.digest)
missingBytes += n.size
}
}
insertFile, err := tx.PrepareContext(ctx, `
INSERT INTO deployment_files (deployment_id, path, digest, size) VALUES (?, ?, ?, ?)`)
if err != nil {
return err
}
defer insertFile.Close()
for _, f := range files {
if _, err := insertFile.ExecContext(ctx, deploymentID, f.Path, f.Digest.Bytes(), f.Size); err != nil {
return mapErr(err)
}
}
_, err = tx.ExecContext(ctx,
`UPDATE deployments SET state = ?, file_count = ?, total_bytes = ? WHERE id = ?`,
StateUploading, len(files), totalBytes, deploymentID)
return err
})
if err != nil {
return nil, 0, err
}
return missing, missingBytes, nil
}
// DeploymentFiles returns the manifest, ordered by path so assembly creates
// each directory once and in a predictable order.
func (d *DB) DeploymentFiles(ctx context.Context, deploymentID int64) ([]FileRow, error) {
rows, err := d.r.QueryContext(ctx,
`SELECT path, digest, size FROM deployment_files WHERE deployment_id = ? ORDER BY path`,
deploymentID)
if err != nil {
return nil, err
}
defer rows.Close()
var files []FileRow
for rows.Next() {
var f FileRow
var raw []byte
if err := rows.Scan(&f.Path, &raw, &f.Size); err != nil {
return nil, err
}
if f.Digest, err = cas.FromBytes(raw); err != nil {
return nil, err
}
files = append(files, f)
}
return files, rows.Err()
}
// MarkDeploymentReady records that assembly succeeded.
//
// Idempotent, and deliberately so: the CLI retries finalize after re-uploading
// blobs that went missing, and a retry must not turn a good deployment into an
// error. finalized_at keeps its original value so the timestamp reflects the
// first success rather than the last attempt.
func (d *DB) MarkDeploymentReady(ctx context.Context, id int64) error {
now := unixNow()
return d.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
UPDATE deployments SET state = ?, finalized_at = COALESCE(finalized_at, ?), error = NULL
WHERE id = ? AND state IN (?, ?)`,
StateReady, now, id, StateUploading, StateReady)
if err != nil {
return err
}
return requireOneRow(ctx, tx, res, id, "finalize")
})
}
// ActivateDeployment makes one ready deployment the project's active one and
// demotes whatever held that role before.
//
// Demotion comes first because deployments_one_active is a partial unique index,
// checked per statement: promoting before demoting would collide with the row
// still holding active = 1. The demotion deliberately excludes the target, so
// re-activating the deployment that is already active is a no-op that refreshes
// its timestamp rather than a transaction that briefly leaves the project with
// nothing active.
//
// The caller is expected to hold the project lock and to have already built
// whatever in-memory snapshot it means to publish: once this commits, SQLite is
// the truth and a crash before the pointer store is recovered from here.
func (d *DB) ActivateDeployment(ctx context.Context, projectID, deploymentID int64) error {
now := unixNow()
return d.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `
UPDATE deployments SET active = 0, deactivated_at = ?
WHERE project_id = ? AND active = 1 AND id <> ?`,
now, projectID, deploymentID); err != nil {
return err
}
res, err := tx.ExecContext(ctx, `
UPDATE deployments SET active = 1, activated_at = ?, deactivated_at = NULL
WHERE id = ? AND project_id = ? AND state = ?`,
now, deploymentID, projectID, StateReady)
if err != nil {
return mapErr(err)
}
return requireOneRow(ctx, tx, res, deploymentID, "activate")
})
}
// MarkDeploymentFailed records why a deployment was abandoned. The manifest
// rows stay for now; GC drops them, which is what lets its blobs be reclaimed.
func (d *DB) MarkDeploymentFailed(ctx context.Context, id int64, reason string) error {
return d.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
UPDATE deployments SET state = ?, error = ? WHERE id = ? AND state IN (?, ?, ?)`,
StateFailed, truncate(reason, 1024), id, StatePending, StateUploading, StateFailed)
if err != nil {
return err
}
return requireOneRow(ctx, tx, res, id, "fail")
})
}
// requireOneRow turns "the UPDATE matched nothing" into the reason it matched
// nothing, which is either a deployment that is gone or one in a state the
// operation does not apply to.
func requireOneRow(ctx context.Context, tx *sql.Tx, res sql.Result, id int64, op string) error {
n, err := res.RowsAffected()
if err != nil {
return err
}
if n > 0 {
return nil
}
var state State
if err := tx.QueryRowContext(ctx, `SELECT state FROM deployments WHERE id = ?`, id).Scan(&state); err != nil {
return mapErr(err)
}
return fmt.Errorf("%w: cannot %s a deployment that is %s", ErrConflict, op, state)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "…"
}
+703
View File
@@ -0,0 +1,703 @@
package store
import (
"context"
"errors"
"testing"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cas"
)
// The lifecycle is spelled out in three places that cannot import each other:
// this package, the CHECK constraint in the schema, and api for the wire.
// Renaming a state in one of them without the others would surface as a
// constraint violation in production rather than at compile time, so it is
// asserted here instead.
func TestStateConstantsMatchTheWire(t *testing.T) {
states := []struct {
store State
wire string
}{
{StatePending, api.StatePending},
{StateUploading, api.StateUploading},
{StateReady, api.StateReady},
{StateFailed, api.StateFailed},
{StateDeleting, api.StateDeleting},
}
db := testDB(t)
p := testProject(t, db, "demo")
for _, s := range states {
if string(s.store) != s.wire {
t.Errorf("store %q and wire %q disagree", s.store, s.wire)
}
// And the schema accepts it: a state this package can set but the CHECK
// constraint rejects would only fail once something reached that state.
dep := testDeployment(t, db, p.ID)
if _, err := db.w.ExecContext(context.Background(),
`UPDATE deployments SET state = ? WHERE id = ?`, s.store, dep.ID); err != nil {
t.Errorf("the schema rejects state %q: %v", s.store, err)
}
}
}
func testProject(t *testing.T, db *DB, name string) *Project {
t.Helper()
p := DefaultProject(name)
if err := db.CreateProject(context.Background(), p); err != nil {
t.Fatalf("CreateProject(%q): %v", name, err)
}
return p
}
func testDeployment(t *testing.T, db *DB, projectID int64) *Deployment {
t.Helper()
dep := &Deployment{ProjectID: projectID}
if err := db.CreateDeployment(context.Background(), dep); err != nil {
t.Fatalf("CreateDeployment: %v", err)
}
return dep
}
// file builds a manifest entry whose digest really is the digest of content, so
// tests never accidentally assert on an impossible pairing.
func file(path, content string) FileRow {
return FileRow{Path: path, Digest: cas.Sum([]byte(content)), Size: int64(len(content))}
}
func refcount(t *testing.T, db *DB, d cas.Digest) int {
t.Helper()
var n int
if err := db.Reader().QueryRow(`SELECT refcount FROM blobs WHERE digest = ?`, d.Bytes()).Scan(&n); err != nil {
t.Fatalf("refcount(%s): %v", d, err)
}
return n
}
func digests(ds []cas.Digest) []string {
out := make([]string, len(ds))
for i, d := range ds {
out[i] = d.String()
}
return out
}
func TestCreateDeployment(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
dep := &Deployment{ProjectID: p.ID, CreatedByKey: "k7m2qabcdefghijk", Meta: map[string]string{"git_sha": "abc"}}
// The key must exist: created_by_key is a foreign key.
if _, err := db.w.ExecContext(ctx,
`INSERT INTO api_keys (id, secret_hash, scope, project_id, created_at) VALUES (?, x'00', 'project', ?, 1)`,
dep.CreatedByKey, p.ID); err != nil {
t.Fatal(err)
}
if err := db.CreateDeployment(ctx, dep); err != nil {
t.Fatal(err)
}
if dep.ID == 0 || dep.PublicID == "" || dep.State != StatePending {
t.Fatalf("CreateDeployment left %+v", dep)
}
got, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
if err != nil {
t.Fatal(err)
}
if got.Meta["git_sha"] != "abc" {
t.Errorf("meta = %v, want git_sha=abc", got.Meta)
}
if got.CreatedByKey != dep.CreatedByKey || got.Active || got.CreatedAt.IsZero() {
t.Errorf("round-tripped as %+v", got)
}
}
func TestNewDeploymentIDsAreDistinct(t *testing.T) {
seen := make(map[string]bool, 256)
for range 256 {
id, err := NewDeploymentID()
if err != nil {
t.Fatal(err)
}
if len(id) != len("dpl_")+16 {
t.Fatalf("id %q has the wrong shape", id)
}
if seen[id] {
t.Fatalf("NewDeploymentID repeated %q", id)
}
seen[id] = true
}
}
// Security requirement: a deployment id is only ever resolvable inside its own
// project, so a project-scoped caller cannot name a neighbour's deployment even
// knowing its id exactly.
func TestDeploymentByPublicIDIsProjectScoped(t *testing.T) {
ctx := context.Background()
db := testDB(t)
mine := testProject(t, db, "mine")
theirs := testProject(t, db, "theirs")
dep := testDeployment(t, db, theirs.ID)
if _, err := db.DeploymentByPublicID(ctx, theirs.ID, dep.PublicID); err != nil {
t.Fatalf("the owning project cannot see its own deployment: %v", err)
}
_, err := db.DeploymentByPublicID(ctx, mine.ID, dep.PublicID)
if !errors.Is(err, ErrNotFound) {
t.Errorf("cross-project lookup = %v, want ErrNotFound", err)
}
}
// The ordering claim the upload protocol rests on: once SetManifest commits,
// every blob the deployment needs is already refcounted, so nothing GC does
// while the content is still being uploaded can take one away.
func TestSetManifestRefcountsBeforeUpload(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
dep := testDeployment(t, db, p.ID)
files := []FileRow{
file("index.html", "<h1>hi</h1>"),
file("assets/app.js", "console.log(1)"),
file("copy.html", "<h1>hi</h1>"), // same content as index.html
}
missing, missingBytes, err := db.SetManifest(ctx, dep.ID, files)
if err != nil {
t.Fatal(err)
}
// Two distinct digests, each reported once, in first-appearance order.
want := []string{files[0].Digest.String(), files[1].Digest.String()}
if got := digests(missing); len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Errorf("missing = %v, want %v", got, want)
}
if wantBytes := files[0].Size + files[1].Size; missingBytes != wantBytes {
t.Errorf("missingBytes = %d, want %d", missingBytes, wantBytes)
}
if got := refcount(t, db, files[0].Digest); got != 2 {
t.Errorf("shared blob refcount = %d, want 2 (two paths reference it)", got)
}
if got := refcount(t, db, files[1].Digest); got != 1 {
t.Errorf("refcount = %d, want 1", got)
}
got, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
if err != nil {
t.Fatal(err)
}
if got.State != StateUploading {
t.Errorf("state = %s, want uploading", got.State)
}
if got.FileCount != 3 {
t.Errorf("file_count = %d, want 3", got.FileCount)
}
if wantTotal := files[0].Size + files[1].Size + files[2].Size; got.TotalBytes != wantTotal {
t.Errorf("total_bytes = %d, want %d", got.TotalBytes, wantTotal)
}
}
// A CLI that lost its connection mid-negotiation re-sends the manifest. The
// second one must replace the first outright, refcounts included.
func TestSetManifestReplacesTheEarlierAttempt(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
dep := testDeployment(t, db, p.ID)
dropped := file("old.html", "version one")
kept := file("index.html", "shared")
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{dropped, kept}); err != nil {
t.Fatal(err)
}
added := file("assets/app.js", "version two")
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{kept, added}); err != nil {
t.Fatal(err)
}
if got := refcount(t, db, dropped.Digest); got != 0 {
t.Errorf("dropped blob refcount = %d, want 0", got)
}
if got := refcount(t, db, kept.Digest); got != 1 {
t.Errorf("kept blob refcount = %d, want 1", got)
}
if got := refcount(t, db, added.Digest); got != 1 {
t.Errorf("added blob refcount = %d, want 1", got)
}
files, err := db.DeploymentFiles(ctx, dep.ID)
if err != nil {
t.Fatal(err)
}
if len(files) != 2 || files[0].Path != "assets/app.js" || files[1].Path != "index.html" {
t.Errorf("manifest = %+v, want the second attempt ordered by path", files)
}
}
func TestSetManifestRejectsASizeDisagreement(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
f := file("index.html", "content")
first := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, first.ID, []FileRow{f}); err != nil {
t.Fatal(err)
}
// Same digest, a different declared length: one of the two numbers is a lie
// and the manifest cannot be accepted either way.
lying := f
lying.Size = f.Size + 1
second := testDeployment(t, db, p.ID)
_, _, err := db.SetManifest(ctx, second.ID, []FileRow{lying})
if !errors.Is(err, cas.ErrSizeMismatch) {
t.Errorf("err = %v, want ErrSizeMismatch", err)
}
// The same disagreement inside one manifest is caught before any write.
third := testDeployment(t, db, p.ID)
_, _, err = db.SetManifest(ctx, third.ID, []FileRow{f, {Path: "other.html", Digest: f.Digest, Size: 99}})
if !errors.Is(err, cas.ErrSizeMismatch) {
t.Errorf("err = %v, want ErrSizeMismatch", err)
}
if got := refcount(t, db, f.Digest); got != 1 {
t.Errorf("refcount = %d after two rejected manifests, want 1", got)
}
}
func TestSetManifestRejectsAFinishedDeployment(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
dep := testDeployment(t, db, p.ID)
f := file("index.html", "content")
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{f}); err != nil {
t.Fatal(err)
}
if err := db.MarkBlobPresent(ctx, f.Digest, f.Size); err != nil {
t.Fatal(err)
}
if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil {
t.Fatal(err)
}
_, _, err := db.SetManifest(ctx, dep.ID, []FileRow{file("other.html", "x")})
if !errors.Is(err, ErrConflict) {
t.Errorf("err = %v, want ErrConflict", err)
}
}
func TestSetManifestOnAMissingDeployment(t *testing.T) {
_, _, err := testDB(t).SetManifest(context.Background(), 424242, []FileRow{file("a", "b")})
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestMissingBlobsShrinksAsContentArrives(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
dep := testDeployment(t, db, p.ID)
a, b := file("a.html", "aaa"), file("b.html", "bbb")
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{a, b}); err != nil {
t.Fatal(err)
}
if got, err := db.MissingBlobs(ctx, dep.ID); err != nil || len(got) != 2 {
t.Fatalf("MissingBlobs = %v, %v; want 2 digests", digests(got), err)
}
if err := db.MarkBlobPresent(ctx, a.Digest, a.Size); err != nil {
t.Fatal(err)
}
got, err := db.MissingBlobs(ctx, dep.ID)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != b.Digest {
t.Fatalf("MissingBlobs = %v, want just %s", digests(got), b.Digest)
}
// A second deployment of overlapping content sees only what is genuinely
// new — this is the deduplication the CLI reports as its headline number.
next := testDeployment(t, db, p.ID)
c := file("c.html", "ccc")
missing, _, err := db.SetManifest(ctx, next.ID, []FileRow{a, c})
if err != nil {
t.Fatal(err)
}
if len(missing) != 1 || missing[0] != c.Digest {
t.Errorf("missing = %v, want just the new blob", digests(missing))
}
if err := db.MarkBlobPresent(ctx, b.Digest, b.Size); err != nil {
t.Fatal(err)
}
if got, err := db.MissingBlobs(ctx, dep.ID); err != nil || len(got) != 0 {
t.Errorf("MissingBlobs = %v, %v; want none", digests(got), err)
}
}
func TestMarkBlobPresent(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
dep := testDeployment(t, db, p.ID)
f := file("index.html", "content")
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{f}); err != nil {
t.Fatal(err)
}
if err := db.MarkBlobPresent(ctx, f.Digest, f.Size); err != nil {
t.Fatal(err)
}
// Idempotent: a retried upload of a blob that arrived meanwhile is a no-op.
if err := db.MarkBlobPresent(ctx, f.Digest, f.Size); err != nil {
t.Errorf("second MarkBlobPresent: %v", err)
}
b, err := db.Blob(ctx, f.Digest)
if err != nil {
t.Fatal(err)
}
if !b.Present || b.Size != f.Size {
t.Errorf("blob = %+v", b)
}
if err := db.MarkBlobPresent(ctx, f.Digest, f.Size+1); !errors.Is(err, cas.ErrSizeMismatch) {
t.Errorf("err = %v, want ErrSizeMismatch", err)
}
// Unknown digests are refused, which is what stops the upload endpoint from
// being used as arbitrary storage.
if _, err := db.Blob(ctx, cas.Sum([]byte("never declared"))); !errors.Is(err, ErrNotFound) {
t.Errorf("Blob = %v, want ErrNotFound", err)
}
if err := db.MarkBlobPresent(ctx, cas.Sum([]byte("never declared")), 1); !errors.Is(err, ErrNotFound) {
t.Errorf("MarkBlobPresent = %v, want ErrNotFound", err)
}
}
func TestMarkDeploymentReadyIsIdempotent(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
dep := testDeployment(t, db, p.ID)
// A pending deployment has no manifest, so there is nothing to finalize.
if err := db.MarkDeploymentReady(ctx, dep.ID); !errors.Is(err, ErrConflict) {
t.Errorf("finalize while pending = %v, want ErrConflict", err)
}
f := file("index.html", "content")
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{f}); err != nil {
t.Fatal(err)
}
if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil {
t.Fatal(err)
}
first, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
if err != nil {
t.Fatal(err)
}
if first.State != StateReady || first.FinalizedAt == nil {
t.Fatalf("deployment = %+v", first)
}
if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil {
t.Errorf("retried finalize: %v", err)
}
again, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
if err != nil {
t.Fatal(err)
}
if !again.FinalizedAt.Equal(*first.FinalizedAt) {
t.Errorf("finalized_at moved from %v to %v", first.FinalizedAt, again.FinalizedAt)
}
}
func TestMarkDeploymentFailed(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
dep := testDeployment(t, db, p.ID)
if err := db.MarkDeploymentFailed(ctx, dep.ID, "upload timed out"); err != nil {
t.Fatal(err)
}
got, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
if err != nil {
t.Fatal(err)
}
if got.State != StateFailed || got.Error != "upload timed out" {
t.Errorf("deployment = %+v", got)
}
// A failed deployment cannot be resurrected by a late manifest or finalize.
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{file("a", "b")}); !errors.Is(err, ErrConflict) {
t.Errorf("SetManifest = %v, want ErrConflict", err)
}
if err := db.MarkDeploymentReady(ctx, dep.ID); !errors.Is(err, ErrConflict) {
t.Errorf("MarkDeploymentReady = %v, want ErrConflict", err)
}
}
func TestListDeployments(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
other := testProject(t, db, "other")
testDeployment(t, db, other.ID)
var ids []string
for range 5 {
ids = append(ids, testDeployment(t, db, p.ID).PublicID)
}
// Newest first, so reverse creation order.
var want []string
for i := len(ids) - 1; i >= 0; i-- {
want = append(want, ids[i])
}
var seen []string
cursor := ""
for {
page, next, err := db.ListDeployments(ctx, p.ID, "", 2, cursor)
if err != nil {
t.Fatal(err)
}
for _, d := range page {
seen = append(seen, d.PublicID)
}
if next == "" {
break
}
cursor = next
}
if len(seen) != len(want) {
t.Fatalf("paged %v, want %v", seen, want)
}
for i := range want {
if seen[i] != want[i] {
t.Fatalf("paged %v, want %v", seen, want)
}
}
// Filtering by state.
fifth, err := db.DeploymentByPublicID(ctx, p.ID, ids[4])
if err != nil {
t.Fatal(err)
}
if err := db.MarkDeploymentFailed(ctx, fifth.ID, "abandoned"); err != nil {
t.Fatal(err)
}
failed, _, err := db.ListDeployments(ctx, p.ID, StateFailed, 10, "")
if err != nil {
t.Fatal(err)
}
if len(failed) != 1 || failed[0].PublicID != ids[4] {
t.Errorf("failed page = %v, want just %s", failed, ids[4])
}
// A cursor GC removed between pages yields an empty page, not an error.
gone, _, err := db.ListDeployments(ctx, p.ID, "", 10, "dpl_ffffffffffffffff")
if err != nil {
t.Fatalf("stale cursor: %v", err)
}
if len(gone) != 0 {
t.Errorf("stale cursor returned %d rows", len(gone))
}
}
// ready builds a deployment that has a manifest and has been finalized, which
// is the only state activation accepts.
func ready(t *testing.T, db *DB, projectID int64, content string) *Deployment {
t.Helper()
ctx := context.Background()
dep := testDeployment(t, db, projectID)
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{file("index.html", content)}); err != nil {
t.Fatalf("SetManifest: %v", err)
}
if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil {
t.Fatalf("MarkDeploymentReady: %v", err)
}
return dep
}
func TestActivateDeployment(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
if _, err := db.ActiveDeployment(ctx, p.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("ActiveDeployment on a fresh project = %v, want ErrNotFound", err)
}
first := ready(t, db, p.ID, "v1")
if err := db.ActivateDeployment(ctx, p.ID, first.ID); err != nil {
t.Fatal(err)
}
got, err := db.ActiveDeployment(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if got.ID != first.ID || !got.Active || got.ActivatedAt == nil || got.DeactivatedAt != nil {
t.Fatalf("after activation the row is %+v", got)
}
second := ready(t, db, p.ID, "v2")
if err := db.ActivateDeployment(ctx, p.ID, second.ID); err != nil {
t.Fatal(err)
}
got, err = db.ActiveDeployment(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if got.ID != second.ID {
t.Fatalf("active = %d, want the second deployment %d", got.ID, second.ID)
}
// The superseded one stays ready and on disk — that is what makes rollback
// a single activation rather than a redeploy — but it is stamped so GC's
// grace period can start counting.
old, err := db.DeploymentByPublicID(ctx, p.ID, first.PublicID)
if err != nil {
t.Fatal(err)
}
if old.Active || old.State != StateReady || old.DeactivatedAt == nil {
t.Errorf("the superseded deployment is %+v", old)
}
if old.ActivatedAt == nil {
t.Error("deactivation cleared activated_at")
}
// Rollback.
if err := db.ActivateDeployment(ctx, p.ID, first.ID); err != nil {
t.Fatal(err)
}
got, err = db.ActiveDeployment(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if got.ID != first.ID || got.DeactivatedAt != nil {
t.Errorf("after rollback the active row is %+v", got)
}
}
// Re-activating what is already active must not leave the project with nothing
// active in between, which is why the demotion excludes the target row.
func TestActivateDeploymentIsIdempotent(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
dep := ready(t, db, p.ID, "v1")
for range 3 {
if err := db.ActivateDeployment(ctx, p.ID, dep.ID); err != nil {
t.Fatal(err)
}
got, err := db.ActiveDeployment(ctx, p.ID)
if err != nil {
t.Fatalf("nothing is active after re-activating: %v", err)
}
if got.ID != dep.ID || got.DeactivatedAt != nil {
t.Fatalf("row = %+v", got)
}
}
}
func TestActivateDeploymentRequiresReady(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
pending := testDeployment(t, db, p.ID)
if err := db.ActivateDeployment(ctx, p.ID, pending.ID); !errors.Is(err, ErrConflict) {
t.Errorf("activating a pending deployment = %v, want ErrConflict", err)
}
failed := testDeployment(t, db, p.ID)
if err := db.MarkDeploymentFailed(ctx, failed.ID, "assembly failed"); err != nil {
t.Fatal(err)
}
if err := db.ActivateDeployment(ctx, p.ID, failed.ID); !errors.Is(err, ErrConflict) {
t.Errorf("activating a failed deployment = %v, want ErrConflict", err)
}
if err := db.ActivateDeployment(ctx, p.ID, 9999); !errors.Is(err, ErrNotFound) {
t.Errorf("activating a deployment that does not exist = %v, want ErrNotFound", err)
}
if _, err := db.ActiveDeployment(ctx, p.ID); !errors.Is(err, ErrNotFound) {
t.Error("a refused activation left something active")
}
}
// The ownership check the API's authorization rests on lives here too: naming
// another project's deployment id must not activate it, and must not disturb
// either project.
func TestActivateDeploymentIsProjectScoped(t *testing.T) {
ctx := context.Background()
db := testDB(t)
victim := testProject(t, db, "victim")
attacker := testProject(t, db, "attacker")
target := ready(t, db, victim.ID, "secret")
if err := db.ActivateDeployment(ctx, victim.ID, target.ID); err != nil {
t.Fatal(err)
}
mine := ready(t, db, attacker.ID, "mine")
if err := db.ActivateDeployment(ctx, attacker.ID, mine.ID); err != nil {
t.Fatal(err)
}
if err := db.ActivateDeployment(ctx, attacker.ID, target.ID); err == nil {
t.Fatal("a project activated another project's deployment")
}
got, err := db.ActiveDeployment(ctx, attacker.ID)
if err != nil {
t.Fatal(err)
}
if got.ID != mine.ID {
t.Errorf("the cross-project attempt changed the attacker's active deployment to %d", got.ID)
}
got, err = db.ActiveDeployment(ctx, victim.ID)
if err != nil {
t.Fatal(err)
}
if got.ID != target.ID || !got.Active {
t.Errorf("the cross-project attempt disturbed the victim: %+v", got)
}
}
// "At most one active deployment per project" is enforced by the database, not
// by the code above it. Writing the second active row by hand is the only way
// to check that: if this ever stops failing, every guarantee that rests on the
// invariant has quietly lost its foundation.
func TestOneActiveDeploymentPerProjectIsEnforcedBySchema(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
other := testProject(t, db, "other")
first := ready(t, db, p.ID, "v1")
second := ready(t, db, p.ID, "v2")
if err := db.ActivateDeployment(ctx, p.ID, first.ID); err != nil {
t.Fatal(err)
}
if _, err := db.w.ExecContext(ctx,
`UPDATE deployments SET active = 1 WHERE id = ?`, second.ID); err == nil {
t.Fatal("the schema allowed a project to have two active deployments")
}
// The index is partial, so it constrains only active rows: any number of
// inactive ones per project, and one active row per *other* project.
elsewhere := ready(t, db, other.ID, "v1")
if err := db.ActivateDeployment(ctx, other.ID, elsewhere.ID); err != nil {
t.Fatalf("the index leaked across projects: %v", err)
}
}
+73
View File
@@ -0,0 +1,73 @@
package store
import (
"database/sql"
"errors"
"time"
)
// Sentinels the HTTP layer maps to error codes. Callers use errors.Is; the
// store never constructs api.Error values itself, so that the mapping from
// storage failure to wire response lives in exactly one place (internal/adminapi).
var (
// ErrNotFound is returned instead of sql.ErrNoRows so callers do not have to
// know that the store is backed by database/sql.
ErrNotFound = errors.New("store: not found")
// ErrExists means a uniqueness constraint rejected the write.
ErrExists = errors.New("store: already exists")
// ErrConflict means the row was not in the state the operation required.
ErrConflict = errors.New("store: conflicting state")
)
// mapErr normalises the errors callers are expected to branch on.
func mapErr(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, sql.ErrNoRows):
return ErrNotFound
case IsConstraint(err):
return errors.Join(ErrExists, err)
default:
return err
}
}
// ---------------------------------------------------------- null conversions
func nullTime(t *time.Time) any {
if t == nil {
return nil
}
return t.Unix()
}
func timePtr(n sql.NullInt64) *time.Time {
if !n.Valid {
return nil
}
t := time.Unix(n.Int64, 0).UTC()
return &t
}
func nullInt(p *int64) any {
if p == nil {
return nil
}
return *p
}
func intPtr(n sql.NullInt64) *int64 {
if !n.Valid {
return nil
}
v := n.Int64
return &v
}
func nullString(s string) any {
if s == "" {
return nil
}
return s
}
+117
View File
@@ -0,0 +1,117 @@
package store
import (
"context"
"database/sql"
"github.com/iceBear67/simplepages/internal/cas"
)
// maxReportedDrift bounds what a report carries back. A repair fixes every row
// it finds; the list is for a human reading the output, and a human does not
// read ten thousand digests.
const maxReportedDrift = 100
// Drift is one blob whose recorded refcount disagrees with the manifests that
// actually name it.
type Drift struct {
Digest cas.Digest
// Stored is what the blobs row claims, Actual what counting the manifest
// rows gives. Stored above Actual wastes disk: the collector will never
// reclaim the blob. Stored below Actual is the dangerous direction — the
// collector may delete content a deployment still needs.
Stored int64
Actual int64
}
// FsckReport is what a consistency check found.
type FsckReport struct {
// Blobs is how many rows were examined.
Blobs int64
// DriftCount is how many disagreed; Drift lists the first few of them.
DriftCount int
Drift []Drift
// Repaired is how many rows were corrected, and is zero unless the check
// was asked to repair.
Repaired int
}
// Fsck recomputes every blob's refcount from the manifests and reports the rows
// that disagree, optionally correcting them.
//
// Refcounts are maintained by triggers on deployment_files, so under normal
// operation they cannot drift. This exists for the cases outside normal
// operation: a database restored from a backup taken mid-transaction, a schema
// touched by hand, or a bug in this program. Drift matters because the blob
// collector trusts the counter — a count that reads low is content that will be
// deleted while a deployment still references it, which is the one way this
// design can lose data.
//
// The recount is a single grouped join rather than a query per blob, so a store
// with a million blobs is one table scan and not a million index seeks.
func (d *DB) Fsck(ctx context.Context, repair bool) (FsckReport, error) {
var rep FsckReport
if err := d.r.QueryRowContext(ctx, `SELECT count(*) FROM blobs`).Scan(&rep.Blobs); err != nil {
return rep, err
}
rows, err := d.r.QueryContext(ctx, `
SELECT b.digest, b.refcount, coalesce(c.n, 0)
FROM blobs b
LEFT JOIN (SELECT digest, count(*) AS n FROM deployment_files GROUP BY digest) c
ON c.digest = b.digest
WHERE b.refcount <> coalesce(c.n, 0)
ORDER BY b.digest`)
if err != nil {
return rep, err
}
defer rows.Close()
var drift []Drift
for rows.Next() {
var dr Drift
var raw []byte
if err := rows.Scan(&raw, &dr.Stored, &dr.Actual); err != nil {
return rep, err
}
if dr.Digest, err = cas.FromBytes(raw); err != nil {
return rep, err
}
drift = append(drift, dr)
}
if err := rows.Err(); err != nil {
return rep, err
}
rep.DriftCount = len(drift)
rep.Drift = drift
if len(rep.Drift) > maxReportedDrift {
rep.Drift = rep.Drift[:maxReportedDrift]
}
if !repair || len(drift) == 0 {
return rep, nil
}
// Repair writes the recounted value rather than adjusting by the difference:
// the manifests are the definition of the refcount, so the correct value is
// the one just counted, whatever the column happened to say.
err = d.Tx(ctx, func(tx *sql.Tx) error {
rep.Repaired = 0
stmt, err := tx.PrepareContext(ctx, `UPDATE blobs SET refcount = ? WHERE digest = ?`)
if err != nil {
return err
}
defer stmt.Close()
for _, dr := range drift {
if _, err := stmt.ExecContext(ctx, dr.Actual, dr.Digest.Bytes()); err != nil {
return err
}
rep.Repaired++
}
return nil
})
if err != nil {
return rep, err
}
return rep, nil
}
+181
View File
@@ -0,0 +1,181 @@
package store
import (
"context"
"database/sql"
"time"
)
// Scope is what an API key is allowed to touch.
type Scope string
const (
// ScopeAdmin may manage every project, key and deployment.
ScopeAdmin Scope = "admin"
// ScopeProject may manage only the deployments of its own project.
ScopeProject Scope = "project"
)
// APIKey is a row of the api_keys table. The secret itself is never stored —
// only sha256 of it — and is shown to the operator exactly once, at creation.
type APIKey struct {
ID string // public half of the token; safe to display and log
SecretHash []byte // sha256(secret), 32 bytes
Scope Scope
ProjectID *int64 // nil for admin keys
Name string
CreatedAt time.Time
ExpiresAt *time.Time
LastUsedAt *time.Time
RevokedAt *time.Time
}
// Usable reports whether the key may authenticate a request at time t.
func (k *APIKey) Usable(t time.Time) bool {
if k.RevokedAt != nil {
return false
}
if k.ExpiresAt != nil && !t.Before(*k.ExpiresAt) {
return false
}
return true
}
const keyColumns = `id, secret_hash, scope, project_id, name, created_at, expires_at, last_used_at, revoked_at`
func scanKey(row rowScanner) (*APIKey, error) {
var k APIKey
var projectID, expires, lastUsed, revoked sql.NullInt64
var created int64
if err := row.Scan(&k.ID, &k.SecretHash, &k.Scope, &projectID, &k.Name,
&created, &expires, &lastUsed, &revoked); err != nil {
return nil, mapErr(err)
}
k.ProjectID = intPtr(projectID)
k.CreatedAt = time.Unix(created, 0).UTC()
k.ExpiresAt = timePtr(expires)
k.LastUsedAt = timePtr(lastUsed)
k.RevokedAt = timePtr(revoked)
return &k, nil
}
// CreateKey stores a freshly minted key.
func (d *DB) CreateKey(ctx context.Context, k *APIKey) error {
if k.CreatedAt.IsZero() {
k.CreatedAt = time.Unix(unixNow(), 0).UTC()
}
return d.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO api_keys (id, secret_hash, scope, project_id, name, created_at, expires_at)
VALUES (?,?,?,?,?,?,?)`,
k.ID, k.SecretHash, string(k.Scope), nullInt(k.ProjectID), k.Name,
k.CreatedAt.Unix(), nullTime(k.ExpiresAt))
return mapErr(err)
})
}
// KeyByID is the authentication lookup: a primary-key hit on a WITHOUT ROWID
// table, never a scan.
func (d *DB) KeyByID(ctx context.Context, id string) (*APIKey, error) {
return scanKey(d.r.QueryRowContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE id = ?`, id))
}
// ListKeys returns the keys for one project, or every key when projectID is nil.
// Revoked keys are included so an operator can see what was revoked and when.
func (d *DB) ListKeys(ctx context.Context, projectID *int64) ([]*APIKey, error) {
query := `SELECT ` + keyColumns + ` FROM api_keys`
var args []any
if projectID != nil {
query += ` WHERE project_id = ?`
args = append(args, *projectID)
}
query += ` ORDER BY created_at DESC, id`
rows, err := d.r.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*APIKey
for rows.Next() {
k, err := scanKey(rows)
if err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
// RevokeKey marks a key unusable. It is idempotent: revoking twice keeps the
// first timestamp, because that is when the key actually stopped working.
//
// The caller must invalidate the auth cache afterwards, or the key stays live
// for up to the cache TTL.
func (d *DB) RevokeKey(ctx context.Context, id string) error {
return d.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`,
unixNow(), id)
if err != nil {
return mapErr(err)
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
// Either it does not exist or it was already revoked; distinguish, so
// the API can answer 404 versus 204.
var exists int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM api_keys WHERE id = ?`, id).Scan(&exists); err != nil {
return err
}
if exists == 0 {
return ErrNotFound
}
}
return nil
})
}
// CountUsableAdminKeys counts admin keys that could authenticate right now. A
// zero result on startup is what triggers minting the bootstrap key.
func (d *DB) CountUsableAdminKeys(ctx context.Context) (int, error) {
var n int
err := d.r.QueryRowContext(ctx, `
SELECT count(*) FROM api_keys
WHERE scope = 'admin' AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
unixNow()).Scan(&n)
return n, err
}
// TouchKeys records last-use times in one transaction.
//
// This is deliberately a batch: updating last_used_at on every request would
// funnel every authenticated read through the single write connection, which is
// exactly the contention the two-pool design exists to avoid. The auth layer
// accumulates the timestamps in memory and flushes them periodically, so the
// column is approximate by design — it answers "is this key still in use?", not
// "when exactly was request N".
func (d *DB) TouchKeys(ctx context.Context, seen map[string]time.Time) error {
if len(seen) == 0 {
return nil
}
return d.Tx(ctx, func(tx *sql.Tx) error {
stmt, err := tx.PrepareContext(ctx,
`UPDATE api_keys SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`)
if err != nil {
return err
}
defer stmt.Close()
for id, t := range seen {
ts := t.Unix()
if _, err := stmt.ExecContext(ctx, ts, id, ts); err != nil {
return err
}
}
return nil
})
}
+329
View File
@@ -0,0 +1,329 @@
package store
import (
"bytes"
"context"
"errors"
"fmt"
"testing"
"time"
)
func mkKey(t *testing.T, db *DB, id string, scope Scope, projectID *int64) *APIKey {
t.Helper()
hash := bytes.Repeat([]byte{byte(len(id))}, 32)
k := &APIKey{ID: id, SecretHash: hash, Scope: scope, ProjectID: projectID, Name: "test " + id}
if err := db.CreateKey(context.Background(), k); err != nil {
t.Fatalf("CreateKey(%s): %v", id, err)
}
return k
}
func TestCreateAndReadKey(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := DefaultProject("demo")
if err := db.CreateProject(ctx, p); err != nil {
t.Fatal(err)
}
admin := mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil)
proj := mkKey(t, db, "projkeyid0000000", ScopeProject, &p.ID)
got, err := db.KeyByID(ctx, admin.ID)
if err != nil {
t.Fatalf("KeyByID: %v", err)
}
if got.Scope != ScopeAdmin {
t.Errorf("scope = %q, want admin", got.Scope)
}
if got.ProjectID != nil {
t.Errorf("admin key has project_id %v", *got.ProjectID)
}
if !bytes.Equal(got.SecretHash, admin.SecretHash) {
t.Error("secret hash did not round trip")
}
if got.CreatedAt.IsZero() {
t.Error("created_at not set")
}
if got.RevokedAt != nil || got.ExpiresAt != nil || got.LastUsedAt != nil {
t.Errorf("optional timestamps should be nil: %+v", got)
}
got, err = db.KeyByID(ctx, proj.ID)
if err != nil {
t.Fatal(err)
}
if got.ProjectID == nil || *got.ProjectID != p.ID {
t.Errorf("project key lost its project: %+v", got)
}
}
func TestKeyNotFound(t *testing.T) {
db := testDB(t)
if _, err := db.KeyByID(context.Background(), "missing000000000"); !errors.Is(err, ErrNotFound) {
t.Errorf("got %v, want ErrNotFound", err)
}
}
func TestCreateKeyDuplicateID(t *testing.T) {
ctx := context.Background()
db := testDB(t)
mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil)
err := db.CreateKey(ctx, &APIKey{ID: "adminkeyid000000", SecretHash: make([]byte, 32), Scope: ScopeAdmin})
if !errors.Is(err, ErrExists) {
t.Fatalf("got %v, want ErrExists", err)
}
}
func TestRevokeKey(t *testing.T) {
ctx := context.Background()
db := testDB(t)
k := mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil)
if err := db.RevokeKey(ctx, k.ID); err != nil {
t.Fatalf("RevokeKey: %v", err)
}
got, err := db.KeyByID(ctx, k.ID)
if err != nil {
t.Fatal(err)
}
if got.RevokedAt == nil {
t.Fatal("revoked_at not set")
}
first := *got.RevokedAt
if got.Usable(time.Now()) {
t.Error("a revoked key must not be usable")
}
// Revoking again must be a no-op, not a moved timestamp: the first time is
// when the key actually stopped working.
if err := db.RevokeKey(ctx, k.ID); err != nil {
t.Fatalf("second RevokeKey: %v", err)
}
got, err = db.KeyByID(ctx, k.ID)
if err != nil {
t.Fatal(err)
}
if !got.RevokedAt.Equal(first) {
t.Errorf("revoked_at moved from %v to %v", first, *got.RevokedAt)
}
if err := db.RevokeKey(ctx, "missing000000000"); !errors.Is(err, ErrNotFound) {
t.Errorf("revoking an unknown key: got %v, want ErrNotFound", err)
}
}
func TestKeyUsable(t *testing.T) {
now := time.Unix(1_000_000, 0).UTC()
past := now.Add(-time.Hour)
future := now.Add(time.Hour)
cases := []struct {
name string
key APIKey
wantUse bool
}{
{"fresh", APIKey{}, true},
{"revoked", APIKey{RevokedAt: &past}, false},
{"expired", APIKey{ExpiresAt: &past}, false},
{"expires later", APIKey{ExpiresAt: &future}, true},
{"expires exactly now", APIKey{ExpiresAt: &now}, false},
{"revoked and unexpired", APIKey{RevokedAt: &past, ExpiresAt: &future}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.key.Usable(now); got != tc.wantUse {
t.Errorf("Usable = %v, want %v", got, tc.wantUse)
}
})
}
}
func TestListKeys(t *testing.T) {
ctx := context.Background()
db := testDB(t)
a := DefaultProject("alpha")
b := DefaultProject("beta")
if err := db.CreateProject(ctx, a); err != nil {
t.Fatal(err)
}
if err := db.CreateProject(ctx, b); err != nil {
t.Fatal(err)
}
mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil)
mkKey(t, db, "alphakey00000001", ScopeProject, &a.ID)
mkKey(t, db, "alphakey00000002", ScopeProject, &a.ID)
mkKey(t, db, "betakey000000001", ScopeProject, &b.ID)
all, err := db.ListKeys(ctx, nil)
if err != nil {
t.Fatal(err)
}
if len(all) != 4 {
t.Errorf("ListKeys(nil) returned %d keys, want 4", len(all))
}
forA, err := db.ListKeys(ctx, &a.ID)
if err != nil {
t.Fatal(err)
}
if len(forA) != 2 {
t.Fatalf("ListKeys(alpha) returned %d keys, want 2", len(forA))
}
for _, k := range forA {
if k.ProjectID == nil || *k.ProjectID != a.ID {
t.Errorf("key %s leaked into alpha's list", k.ID)
}
}
// Revoked keys stay listed so an operator can see what was revoked and when.
if err := db.RevokeKey(ctx, "alphakey00000001"); err != nil {
t.Fatal(err)
}
forA, err = db.ListKeys(ctx, &a.ID)
if err != nil {
t.Fatal(err)
}
if len(forA) != 2 {
t.Errorf("after revoke, ListKeys(alpha) returned %d keys, want 2", len(forA))
}
}
// A zero result here is what makes the server mint a bootstrap token, so the
// "usable" definition has to match what the verifier will accept.
func TestCountUsableAdminKeys(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := DefaultProject("demo")
if err := db.CreateProject(ctx, p); err != nil {
t.Fatal(err)
}
if n, err := db.CountUsableAdminKeys(ctx); err != nil || n != 0 {
t.Fatalf("empty database: n=%d err=%v, want 0", n, err)
}
// A project key is not an admin key.
mkKey(t, db, "projkeyid0000000", ScopeProject, &p.ID)
if n, _ := db.CountUsableAdminKeys(ctx); n != 0 {
t.Errorf("project key counted as admin: n=%d", n)
}
mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil)
if n, _ := db.CountUsableAdminKeys(ctx); n != 1 {
t.Errorf("n=%d, want 1", n)
}
// An expired admin key must not keep the server from bootstrapping.
expired := time.Now().Add(-time.Hour)
if err := db.CreateKey(ctx, &APIKey{
ID: "expiredadmin0000", SecretHash: make([]byte, 32), Scope: ScopeAdmin, ExpiresAt: &expired,
}); err != nil {
t.Fatal(err)
}
if n, _ := db.CountUsableAdminKeys(ctx); n != 1 {
t.Errorf("expired admin key counted: n=%d", n)
}
if err := db.RevokeKey(ctx, "adminkeyid000000"); err != nil {
t.Fatal(err)
}
if n, _ := db.CountUsableAdminKeys(ctx); n != 0 {
t.Errorf("revoked admin key counted: n=%d", n)
}
}
func TestTouchKeys(t *testing.T) {
ctx := context.Background()
db := testDB(t)
mkKey(t, db, "key0000000000001", ScopeAdmin, nil)
mkKey(t, db, "key0000000000002", ScopeAdmin, nil)
if err := db.TouchKeys(ctx, nil); err != nil {
t.Errorf("empty batch should be a no-op: %v", err)
}
t1 := time.Unix(1_700_000_000, 0)
if err := db.TouchKeys(ctx, map[string]time.Time{
"key0000000000001": t1,
"key0000000000002": t1,
// A key that vanished between the request and the flush must not fail
// the whole batch, or one deleted key would stall the flusher forever.
"deletedkey000000": t1,
}); err != nil {
t.Fatalf("TouchKeys: %v", err)
}
lastUsed := func(id string) *time.Time {
t.Helper()
k, err := db.KeyByID(ctx, id)
if err != nil {
t.Fatal(err)
}
return k.LastUsedAt
}
if got := lastUsed("key0000000000001"); got == nil || !got.Equal(t1.UTC()) {
t.Errorf("last_used_at = %v, want %v", got, t1.UTC())
}
// Batches can arrive out of order once the flusher runs concurrently with a
// retry; an older timestamp must not walk the column backwards.
older := t1.Add(-time.Hour)
if err := db.TouchKeys(ctx, map[string]time.Time{"key0000000000001": older}); err != nil {
t.Fatal(err)
}
if got := lastUsed("key0000000000001"); !got.Equal(t1.UTC()) {
t.Errorf("last_used_at moved backwards to %v", got)
}
newer := t1.Add(time.Hour)
if err := db.TouchKeys(ctx, map[string]time.Time{"key0000000000001": newer}); err != nil {
t.Fatal(err)
}
if got := lastUsed("key0000000000001"); !got.Equal(newer.UTC()) {
t.Errorf("last_used_at = %v, want %v", got, newer.UTC())
}
}
// The write pool holds a single connection, so a batch flush must not need more
// than one; this would deadlock if TouchKeys opened a nested transaction.
func TestTouchKeysLargeBatch(t *testing.T) {
ctx := context.Background()
db := testDB(t)
seen := map[string]time.Time{}
now := time.Unix(1_700_000_000, 0)
for i := 0; i < 200; i++ {
id := fmt.Sprintf("key%013d", i)
mkKey(t, db, id, ScopeAdmin, nil)
seen[id] = now
}
if err := db.TouchKeys(ctx, seen); err != nil {
t.Fatalf("TouchKeys: %v", err)
}
var n int
if err := db.Reader().QueryRow(
`SELECT count(*) FROM api_keys WHERE last_used_at = ?`, now.Unix()).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 200 {
t.Errorf("%d keys touched, want 200", n)
}
}
// Keys must die with their project, or a project name could be recreated and
// inherit the old owner's credentials.
func TestKeysCascadeWithProject(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := DefaultProject("demo")
if err := db.CreateProject(ctx, p); err != nil {
t.Fatal(err)
}
mkKey(t, db, "projkeyid0000000", ScopeProject, &p.ID)
if err := db.DeleteProject(ctx, p.ID); err != nil {
t.Fatal(err)
}
if _, err := db.KeyByID(ctx, "projkeyid0000000"); !errors.Is(err, ErrNotFound) {
t.Errorf("key survived its project: %v", err)
}
}
+344
View File
@@ -0,0 +1,344 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/iceBear67/simplepages/internal/cas"
)
// This file holds the queries the background jobs use: startup recovery, the
// retention sweep and the blob collector.
//
// They are kept apart from the request-path queries because they answer a
// different question. A handler works on state a transaction has just
// established; these run against whatever a crash, a restored backup or a
// half-finished sweep left behind, so each one has to be safe to run against a
// world that already disagrees with itself, and safe to run again after being
// interrupted partway.
// DeploymentRef names a deployment by the two values its directory is built
// from, which is all an orphan sweep needs to know.
type DeploymentRef struct {
ProjectID int64
PublicID string
}
// AllDeploymentRefs lists every deployment the database knows about.
//
// Only the identifying pair, because the caller compares it against directory
// names: reading full rows for a sweep that will normally delete nothing would
// be a lot of scanning for no answer that changes.
func (d *DB) AllDeploymentRefs(ctx context.Context) ([]DeploymentRef, error) {
rows, err := d.r.QueryContext(ctx, `SELECT project_id, public_id FROM deployments`)
if err != nil {
return nil, err
}
defer rows.Close()
var refs []DeploymentRef
for rows.Next() {
var ref DeploymentRef
if err := rows.Scan(&ref.ProjectID, &ref.PublicID); err != nil {
return nil, err
}
refs = append(refs, ref)
}
return refs, rows.Err()
}
// DeploymentsInState lists deployments in one state across every project,
// oldest first. Recovery uses it to find the rows a previous sweep was in the
// middle of deleting.
func (d *DB) DeploymentsInState(ctx context.Context, state State, limit int) ([]*Deployment, error) {
if limit <= 0 {
limit = 1000
}
rows, err := d.r.QueryContext(ctx,
`SELECT `+deploymentColumns+` FROM deployments WHERE state = ? ORDER BY id LIMIT ?`,
string(state), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Deployment
for rows.Next() {
dep, err := scanDeployment(rows)
if err != nil {
return nil, err
}
out = append(out, dep)
}
return out, rows.Err()
}
// InactiveDeployments lists one project's deployments that are not the one it
// serves, newest first. This is the input to the retention decision, and the
// active deployment is excluded here rather than filtered later so that no
// arithmetic on the caller's side can ever select it.
func (d *DB) InactiveDeployments(ctx context.Context, projectID int64) ([]*Deployment, error) {
rows, err := d.r.QueryContext(ctx,
`SELECT `+deploymentColumns+` FROM deployments
WHERE project_id = ? AND active = 0 ORDER BY id DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Deployment
for rows.Next() {
dep, err := scanDeployment(rows)
if err != nil {
return nil, err
}
out = append(out, dep)
}
return out, rows.Err()
}
// ExpireStaleDeployments fails uploads that were started before cutoff and
// never finished, dropping their manifests so the blobs only they referenced
// become collectable.
//
// Nothing distinguishes a CI job that died from one that is merely slow except
// how long it has been, which is why the cutoff wants to be generous: expiring
// an upload that was still going to succeed turns a slow deploy into a failed
// one. Their blobs survive regardless — they are content-addressed, so the
// retry finds them already present and skips them.
func (d *DB) ExpireStaleDeployments(ctx context.Context, cutoff time.Time, reason string) (int, error) {
var n int
err := d.Tx(ctx, func(tx *sql.Tx) error {
// Tx re-runs this on a busy database, so the count is rebuilt from
// scratch on each attempt rather than added to.
n = 0
rows, err := tx.QueryContext(ctx,
`SELECT id FROM deployments WHERE state IN (?, ?) AND created_at < ?`,
StatePending, StateUploading, cutoff.Unix())
if err != nil {
return err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
for _, id := range ids {
// The manifest rows go first: their delete trigger is what takes the
// refcounts back down, and it is the only reason expiry frees
// anything.
if _, err := tx.ExecContext(ctx,
`DELETE FROM deployment_files WHERE deployment_id = ?`, id); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE deployments SET state = ?, error = ? WHERE id = ?`,
StateFailed, truncate(reason, 1024), id); err != nil {
return err
}
n++
}
return nil
})
if err != nil {
return 0, err
}
return n, nil
}
// MarkDeploymentDeleting claims a deployment for deletion.
//
// The state change is committed before any file is removed, so a crash midway
// through leaves a row that says what was happening and recovery can finish the
// job. The active deployment can never be claimed: that check is here, in the
// same statement, rather than in the caller.
func (d *DB) MarkDeploymentDeleting(ctx context.Context, id int64) error {
return d.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE deployments SET state = ? WHERE id = ? AND active = 0`, StateDeleting, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n > 0 {
return nil
}
// Nothing matched, so either the row is gone — mapErr turns that into
// ErrNotFound — or it is the active one, which is the only condition the
// statement excludes.
var active bool
if err := tx.QueryRowContext(ctx,
`SELECT active FROM deployments WHERE id = ?`, id).Scan(&active); err != nil {
return mapErr(err)
}
return fmt.Errorf("%w: this deployment is the one the project is serving", ErrConflict)
})
}
// DeleteDeployment removes a deployment's rows once its tree is gone.
//
// The manifest rows are deleted explicitly rather than left to ON DELETE
// CASCADE. SQLite does not fire a child table's triggers for cascaded deletes
// unless recursive_triggers is on, and relying on that pragma would make every
// blob's refcount depend on a connection setting; deleting the rows here makes
// the decrement unconditional.
func (d *DB) DeleteDeployment(ctx context.Context, id int64) error {
return d.Tx(ctx, func(tx *sql.Tx) error {
var active bool
if err := tx.QueryRowContext(ctx,
`SELECT active FROM deployments WHERE id = ?`, id).Scan(&active); err != nil {
return mapErr(err)
}
if active {
return fmt.Errorf("%w: this deployment is the one the project is serving", ErrConflict)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM deployment_files WHERE deployment_id = ?`, id); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `DELETE FROM deployments WHERE id = ?`, id)
return err
})
}
// EachPresentBlob calls fn for every digest the database believes is on disk.
//
// Streamed rather than returned as a slice because the caller wants to stat each
// one and a large store has a lot of them; a read here never blocks a write, so
// holding the cursor open across the filesystem calls costs nothing.
func (d *DB) EachPresentBlob(ctx context.Context, fn func(cas.Digest) error) error {
rows, err := d.r.QueryContext(ctx, `SELECT digest FROM blobs WHERE present = 1`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return err
}
dg, err := cas.FromBytes(raw)
if err != nil {
return err
}
if err := fn(dg); err != nil {
return err
}
}
return rows.Err()
}
// MarkBlobsAbsent records that content the database claimed is on disk is not.
//
// The rows stay. A manifest may still reference them, and the fix is not to
// forget the blob but to ask for it again: the next deploy that names one of
// these digests is told to upload it, and every deployment that referenced it
// becomes deployable again as soon as one does.
func (d *DB) MarkBlobsAbsent(ctx context.Context, digests []cas.Digest) error {
if len(digests) == 0 {
return nil
}
return d.Tx(ctx, func(tx *sql.Tx) error {
stmt, err := tx.PrepareContext(ctx, `UPDATE blobs SET present = 0 WHERE digest = ?`)
if err != nil {
return err
}
defer stmt.Close()
for _, dg := range digests {
if _, err := stmt.ExecContext(ctx, dg.Bytes()); err != nil {
return err
}
}
return nil
})
}
// UnreferencedBlobs lists blobs no manifest has referenced since before cutoff.
//
// Both halves of the condition matter. A refcount of zero says nothing points at
// the content now; the cutoff adds that nothing has pointed at it for a while,
// which is what gives a request that has already resolved a digest and is about
// to open it time to finish. Oldest first, so a backlog drains in a stable order
// rather than the collector revisiting the same rows every sweep.
func (d *DB) UnreferencedBlobs(ctx context.Context, cutoff time.Time, limit int) ([]Blob, error) {
if limit <= 0 {
limit = 5000
}
rows, err := d.r.QueryContext(ctx, `
SELECT digest, size, present FROM blobs
WHERE refcount = 0 AND last_ref_at < ?
ORDER BY last_ref_at LIMIT ?`, cutoff.Unix(), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Blob
for rows.Next() {
var b Blob
var raw []byte
if err := rows.Scan(&raw, &b.Size, &b.Present); err != nil {
return nil, err
}
if b.Digest, err = cas.FromBytes(raw); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// DeleteBlob removes one blob's row and its content together, reporting whether
// there was anything to remove.
//
// remove runs inside the write transaction, and that is the whole point of this
// signature. There is exactly one write connection, so no manifest can be
// accepted between the row disappearing and the file doing so — which closes the
// window where a deployment could come to reference content that was already on
// its way out. remove has to be idempotent, because Tx re-runs its function on a
// busy database; cas.Store.Remove is, deliberately.
//
// A blob that gained a reference since it was listed is left alone and reported
// as false. If remove fails the row survives with it, and the next sweep finds
// the pair again.
func (d *DB) DeleteBlob(ctx context.Context, digest cas.Digest, remove func() error) (bool, error) {
var deleted bool
err := d.Tx(ctx, func(tx *sql.Tx) error {
deleted = false
res, err := tx.ExecContext(ctx,
`DELETE FROM blobs WHERE digest = ? AND refcount = 0`, digest.Bytes())
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return nil
}
if err := remove(); err != nil {
return err
}
deleted = true
return nil
})
return deleted, err
}
+623
View File
@@ -0,0 +1,623 @@
package store
import (
"context"
"errors"
"strconv"
"testing"
"time"
"github.com/iceBear67/simplepages/internal/cas"
)
// blobRow reads a blob's bookkeeping directly, so a test can assert on the
// columns the collector reads rather than on what a helper reports.
func blobRow(t *testing.T, db *DB, d cas.Digest) (refcount int64, present bool, lastRef int64) {
t.Helper()
err := db.Reader().QueryRow(
`SELECT refcount, present, last_ref_at FROM blobs WHERE digest = ?`, d.Bytes()).
Scan(&refcount, &present, &lastRef)
if err != nil {
t.Fatalf("blob %s: %v", d, err)
}
return
}
func blobExists(t *testing.T, db *DB, d cas.Digest) bool {
t.Helper()
var n int
if err := db.Reader().QueryRow(
`SELECT count(*) FROM blobs WHERE digest = ?`, d.Bytes()).Scan(&n); err != nil {
t.Fatal(err)
}
return n == 1
}
func TestAllDeploymentRefs(t *testing.T) {
ctx := context.Background()
db := testDB(t)
a := testProject(t, db, "a")
b := testProject(t, db, "b")
want := map[string]int64{}
for range 3 {
dep := testDeployment(t, db, a.ID)
want[dep.PublicID] = a.ID
}
dep := testDeployment(t, db, b.ID)
want[dep.PublicID] = b.ID
refs, err := db.AllDeploymentRefs(ctx)
if err != nil {
t.Fatal(err)
}
if len(refs) != len(want) {
t.Fatalf("got %d refs, want %d", len(refs), len(want))
}
for _, ref := range refs {
pid, ok := want[ref.PublicID]
if !ok {
t.Errorf("unexpected public id %q", ref.PublicID)
continue
}
if ref.ProjectID != pid {
t.Errorf("%s belongs to project %d, want %d", ref.PublicID, ref.ProjectID, pid)
}
delete(want, ref.PublicID)
}
}
func TestDeploymentsInStateSpansProjects(t *testing.T) {
ctx := context.Background()
db := testDB(t)
a := testProject(t, db, "a")
b := testProject(t, db, "b")
// Recovery has to find interrupted deletions wherever they are, so this
// query is deliberately not project-scoped.
first := testDeployment(t, db, a.ID)
second := testDeployment(t, db, b.ID)
for _, dep := range []*Deployment{first, second} {
if _, err := db.w.ExecContext(ctx,
`UPDATE deployments SET state = ? WHERE id = ?`, StateDeleting, dep.ID); err != nil {
t.Fatal(err)
}
}
testDeployment(t, db, a.ID) // still pending; must not be listed
got, err := db.DeploymentsInState(ctx, StateDeleting, 0)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d deleting deployments, want 2", len(got))
}
if got[0].ID != first.ID || got[1].ID != second.ID {
t.Errorf("order is %d,%d; want oldest first (%d,%d)",
got[0].ID, got[1].ID, first.ID, second.ID)
}
}
// The active deployment is excluded in SQL rather than filtered by the caller,
// so no retention arithmetic can select the one being served.
func TestInactiveDeploymentsExcludesTheActiveOne(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
other := testProject(t, db, "other")
testDeployment(t, db, other.ID)
var deps []*Deployment
for i := range 3 {
deps = append(deps, ready(t, db, p.ID, string(rune('a'+i))))
}
if err := db.ActivateDeployment(ctx, p.ID, deps[1].ID); err != nil {
t.Fatal(err)
}
got, err := db.InactiveDeployments(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d inactive deployments, want 2", len(got))
}
if got[0].ID != deps[2].ID || got[1].ID != deps[0].ID {
t.Errorf("order is %d,%d; want newest first (%d,%d)",
got[0].ID, got[1].ID, deps[2].ID, deps[0].ID)
}
}
func TestExpireStaleDeployments(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
shared := file("shared.js", "shared")
only := file("only.html", "abandoned")
// One upload that stalled, one that is merely young, one that finished.
stale := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, stale.ID, []FileRow{shared, only}); err != nil {
t.Fatal(err)
}
young := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, young.ID, []FileRow{shared}); err != nil {
t.Fatal(err)
}
done := ready(t, db, p.ID, "finished")
// Age the stalled one past the cutoff. Backdating the row is the only way
// to test this without the test sleeping.
if _, err := db.w.ExecContext(ctx,
`UPDATE deployments SET created_at = ? WHERE id = ?`,
time.Now().Add(-48*time.Hour).Unix(), stale.ID); err != nil {
t.Fatal(err)
}
n, err := db.ExpireStaleDeployments(ctx, time.Now().Add(-24*time.Hour), "abandoned")
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("expired %d deployments, want 1", n)
}
got, err := db.DeploymentByPublicID(ctx, p.ID, stale.PublicID)
if err != nil {
t.Fatal(err)
}
if got.State != StateFailed {
t.Errorf("state = %q, want %q", got.State, StateFailed)
}
if got.Error != "abandoned" {
t.Errorf("error = %q, want the reason to be recorded", got.Error)
}
// Dropping the manifest is the point: it is what takes the refcounts back
// down so the collector can reach the content.
if rc, _, _ := blobRow(t, db, only.Digest); rc != 0 {
t.Errorf("refcount of the abandoned file = %d, want 0", rc)
}
// Content the surviving upload also names keeps its reference.
if rc, _, _ := blobRow(t, db, shared.Digest); rc != 1 {
t.Errorf("refcount of the shared file = %d, want 1 (the young upload still names it)", rc)
}
for _, dep := range []*Deployment{young, done} {
got, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
if err != nil {
t.Fatal(err)
}
if got.State == StateFailed {
t.Errorf("deployment %s was expired but should not have been", dep.PublicID)
}
}
// Running it again finds nothing left to do.
n, err = db.ExpireStaleDeployments(ctx, time.Now().Add(-24*time.Hour), "abandoned")
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("a second pass expired %d more, want 0", n)
}
}
func TestMarkDeploymentDeleting(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
active := ready(t, db, p.ID, "v1")
spare := ready(t, db, p.ID, "v2")
if err := db.ActivateDeployment(ctx, p.ID, active.ID); err != nil {
t.Fatal(err)
}
// The one being served is refused, and by the same statement that would
// have claimed it — there is no window between the check and the claim.
if err := db.MarkDeploymentDeleting(ctx, active.ID); !errors.Is(err, ErrConflict) {
t.Fatalf("claiming the active deployment = %v, want ErrConflict", err)
}
got, err := db.DeploymentByPublicID(ctx, p.ID, active.PublicID)
if err != nil {
t.Fatal(err)
}
if got.State != StateReady {
t.Errorf("the refused claim changed the state to %q", got.State)
}
if err := db.MarkDeploymentDeleting(ctx, spare.ID); err != nil {
t.Fatal(err)
}
got, err = db.DeploymentByPublicID(ctx, p.ID, spare.PublicID)
if err != nil {
t.Fatal(err)
}
if got.State != StateDeleting {
t.Errorf("state = %q, want %q", got.State, StateDeleting)
}
// Re-claiming is fine: a collector that was interrupted after the claim and
// before the removal has to be able to pick the row up again.
if err := db.MarkDeploymentDeleting(ctx, spare.ID); err != nil {
t.Errorf("re-claiming a deployment already being deleted: %v", err)
}
if err := db.MarkDeploymentDeleting(ctx, 99999); !errors.Is(err, ErrNotFound) {
t.Errorf("claiming a missing deployment = %v, want ErrNotFound", err)
}
}
func TestDeleteDeploymentDropsManifestRefsExplicitly(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
shared := file("shared.js", "shared")
only := file("index.html", "gone")
dep := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{shared, only}); err != nil {
t.Fatal(err)
}
keeper := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, keeper.ID, []FileRow{shared}); err != nil {
t.Fatal(err)
}
if err := db.DeleteDeployment(ctx, dep.ID); err != nil {
t.Fatal(err)
}
if _, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID); !errors.Is(err, ErrNotFound) {
t.Errorf("the row survived deletion: %v", err)
}
// The AFTER DELETE trigger has to have fired. A cascaded delete would not
// have fired it, which is why the manifest rows are deleted by hand first.
if rc, _, _ := blobRow(t, db, only.Digest); rc != 0 {
t.Errorf("refcount = %d after the only reference was deleted, want 0", rc)
}
if rc, _, _ := blobRow(t, db, shared.Digest); rc != 1 {
t.Errorf("refcount of shared content = %d, want 1", rc)
}
if err := db.DeleteDeployment(ctx, dep.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("deleting again = %v, want ErrNotFound", err)
}
}
func TestDeleteDeploymentRefusesTheActiveOne(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
dep := ready(t, db, p.ID, "v1")
if err := db.ActivateDeployment(ctx, p.ID, dep.ID); err != nil {
t.Fatal(err)
}
if err := db.DeleteDeployment(ctx, dep.ID); !errors.Is(err, ErrConflict) {
t.Fatalf("deleting the active deployment = %v, want ErrConflict", err)
}
if _, err := db.ActiveDeployment(ctx, p.ID); err != nil {
t.Errorf("the project stopped serving anything: %v", err)
}
}
func TestEachPresentBlobAndMarkBlobsAbsent(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
here := file("here.txt", "here")
gone := file("gone.txt", "gone")
pending := file("pending.txt", "pending")
dep := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{here, gone, pending}); err != nil {
t.Fatal(err)
}
for _, f := range []FileRow{here, gone} {
if err := db.MarkBlobPresent(ctx, f.Digest, f.Size); err != nil {
t.Fatal(err)
}
}
seen := map[string]bool{}
if err := db.EachPresentBlob(ctx, func(d cas.Digest) error {
seen[d.String()] = true
return nil
}); err != nil {
t.Fatal(err)
}
if len(seen) != 2 || !seen[here.Digest.String()] || !seen[gone.Digest.String()] {
t.Fatalf("present blobs = %v, want exactly the two uploaded ones", seen)
}
if seen[pending.Digest.String()] {
t.Error("a blob that was never uploaded was reported as present")
}
if err := db.MarkBlobsAbsent(ctx, []cas.Digest{gone.Digest}); err != nil {
t.Fatal(err)
}
// The row stays and keeps its reference: the fix for missing content is to
// ask for it again, not to forget the deployment needs it.
rc, present, _ := blobRow(t, db, gone.Digest)
if present {
t.Error("the blob is still marked present")
}
if rc != 1 {
t.Errorf("refcount = %d, want the manifest reference to survive", rc)
}
if _, present, _ := blobRow(t, db, here.Digest); !present {
t.Error("an unrelated blob was marked absent")
}
// An empty list is a no-op rather than a statement with no arguments.
if err := db.MarkBlobsAbsent(ctx, nil); err != nil {
t.Errorf("MarkBlobsAbsent(nil): %v", err)
}
// The callback's error stops the walk and reaches the caller.
stop := errors.New("stop")
if err := db.EachPresentBlob(ctx, func(cas.Digest) error { return stop }); !errors.Is(err, stop) {
t.Errorf("EachPresentBlob swallowed the callback error: %v", err)
}
}
func TestUnreferencedBlobs(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
kept := file("kept.txt", "kept")
dropped := file("dropped.txt", "dropped")
dep := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{kept, dropped}); err != nil {
t.Fatal(err)
}
if err := db.MarkBlobPresent(ctx, dropped.Digest, dropped.Size); err != nil {
t.Fatal(err)
}
// Referenced content is never listed, however old.
got, err := db.UnreferencedBlobs(ctx, time.Now().Add(time.Hour), 0)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("listed %d referenced blobs, want 0", len(got))
}
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{kept}); err != nil {
t.Fatal(err)
}
// Freshly unreferenced content is protected by the cutoff, which is what
// gives a request that has already resolved the digest time to open it.
got, err = db.UnreferencedBlobs(ctx, time.Now().Add(-time.Hour), 0)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("listed %d blobs inside the grace period, want 0", len(got))
}
got, err = db.UnreferencedBlobs(ctx, time.Now().Add(time.Hour), 0)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("listed %d blobs past the cutoff, want 1", len(got))
}
if got[0].Digest != dropped.Digest {
t.Errorf("listed %s, want the dereferenced %s", got[0].Digest, dropped.Digest)
}
if got[0].Size != dropped.Size || !got[0].Present {
t.Errorf("listed blob = %+v, want the size and presence the collector needs", got[0])
}
}
func TestDeleteBlobRemovesRowAndContentTogether(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
orphan := file("orphan.txt", "orphan")
held := file("held.txt", "held")
dep := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{orphan, held}); err != nil {
t.Fatal(err)
}
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{held}); err != nil {
t.Fatal(err)
}
removed := 0
deleted, err := db.DeleteBlob(ctx, orphan.Digest, func() error { removed++; return nil })
if err != nil {
t.Fatal(err)
}
if !deleted || removed != 1 {
t.Fatalf("deleted = %v, remove called %d times; want true and once", deleted, removed)
}
if blobExists(t, db, orphan.Digest) {
t.Error("the row survived")
}
// A blob that gained a reference since it was listed is left alone, and the
// content is not touched — this is the check that keeps a deploy racing the
// collector from losing its files.
removed = 0
deleted, err = db.DeleteBlob(ctx, held.Digest, func() error { removed++; return nil })
if err != nil {
t.Fatal(err)
}
if deleted || removed != 0 {
t.Errorf("a referenced blob was collected: deleted = %v, remove called %d times", deleted, removed)
}
if !blobExists(t, db, held.Digest) {
t.Error("a referenced blob's row was deleted")
}
// Nothing to delete is not an error; the previous sweep already did it.
deleted, err = db.DeleteBlob(ctx, orphan.Digest, func() error {
t.Error("remove was called for a row that no longer exists")
return nil
})
if err != nil || deleted {
t.Errorf("re-deleting = (%v, %v), want (false, nil)", deleted, err)
}
}
// If the content cannot be removed the row has to survive with it, so the next
// sweep finds the pair again rather than leaving a file nothing points at.
func TestDeleteBlobKeepsTheRowWhenRemovalFails(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
f := file("orphan.txt", "orphan")
dep := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{f}); err != nil {
t.Fatal(err)
}
if _, _, err := db.SetManifest(ctx, dep.ID, nil); err != nil {
t.Fatal(err)
}
boom := errors.New("disk is having a day")
deleted, err := db.DeleteBlob(ctx, f.Digest, func() error { return boom })
if !errors.Is(err, boom) {
t.Fatalf("DeleteBlob = %v, want the removal error", err)
}
if deleted {
t.Error("reported a deletion that was rolled back")
}
if !blobExists(t, db, f.Digest) {
t.Fatal("the row was deleted even though the content was not")
}
}
func TestFsckFindsAndRepairsDrift(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
shared := file("shared.js", "shared")
one := file("one.html", "one")
two := file("two.html", "two")
first := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, first.ID, []FileRow{shared, one}); err != nil {
t.Fatal(err)
}
second := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, second.ID, []FileRow{shared, two}); err != nil {
t.Fatal(err)
}
// Triggers maintain these counters, so a healthy store never drifts.
rep, err := db.Fsck(ctx, false)
if err != nil {
t.Fatal(err)
}
if rep.Blobs != 3 {
t.Errorf("examined %d blobs, want 3", rep.Blobs)
}
if rep.DriftCount != 0 {
t.Fatalf("a healthy store reported %d drifting blobs: %+v", rep.DriftCount, rep.Drift)
}
// Injecting drift in both directions. Low is the dangerous one: the
// collector trusts the counter, so a count that reads low is content it
// will delete while a deployment still names it.
if _, err := db.w.ExecContext(ctx,
`UPDATE blobs SET refcount = 0 WHERE digest = ?`, shared.Digest.Bytes()); err != nil {
t.Fatal(err)
}
if _, err := db.w.ExecContext(ctx,
`UPDATE blobs SET refcount = 7 WHERE digest = ?`, one.Digest.Bytes()); err != nil {
t.Fatal(err)
}
rep, err = db.Fsck(ctx, false)
if err != nil {
t.Fatal(err)
}
if rep.DriftCount != 2 {
t.Fatalf("found %d drifting blobs, want 2: %+v", rep.DriftCount, rep.Drift)
}
if rep.Repaired != 0 {
t.Errorf("a report-only check repaired %d rows", rep.Repaired)
}
found := map[string]Drift{}
for _, dr := range rep.Drift {
found[dr.Digest.String()] = dr
}
if dr := found[shared.Digest.String()]; dr.Stored != 0 || dr.Actual != 2 {
t.Errorf("shared drift = %+v, want stored 0 and actual 2", dr)
}
if dr := found[one.Digest.String()]; dr.Stored != 7 || dr.Actual != 1 {
t.Errorf("one.html drift = %+v, want stored 7 and actual 1", dr)
}
// Report-only means exactly that.
if rc, _, _ := blobRow(t, db, shared.Digest); rc != 0 {
t.Errorf("refcount = %d, want the injected value left alone", rc)
}
rep, err = db.Fsck(ctx, true)
if err != nil {
t.Fatal(err)
}
if rep.Repaired != 2 {
t.Errorf("repaired %d rows, want 2", rep.Repaired)
}
if rc, _, _ := blobRow(t, db, shared.Digest); rc != 2 {
t.Errorf("refcount after repair = %d, want the recounted 2", rc)
}
if rc, _, _ := blobRow(t, db, one.Digest); rc != 1 {
t.Errorf("refcount after repair = %d, want the recounted 1", rc)
}
rep, err = db.Fsck(ctx, true)
if err != nil {
t.Fatal(err)
}
if rep.DriftCount != 0 || rep.Repaired != 0 {
t.Errorf("a repaired store still reports %d drift, %d repaired", rep.DriftCount, rep.Repaired)
}
}
// The list is for a human to read; the repair is not bounded by it.
func TestFsckCapsTheReportedDriftButRepairsEverything(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := testProject(t, db, "demo")
const n = maxReportedDrift + 10
rows := make([]FileRow, 0, n)
for i := range n {
rows = append(rows, file("f"+strconv.Itoa(i)+".txt", "content-"+strconv.Itoa(i)))
}
dep := testDeployment(t, db, p.ID)
if _, _, err := db.SetManifest(ctx, dep.ID, rows); err != nil {
t.Fatal(err)
}
if _, err := db.w.ExecContext(ctx, `UPDATE blobs SET refcount = 42`); err != nil {
t.Fatal(err)
}
rep, err := db.Fsck(ctx, true)
if err != nil {
t.Fatal(err)
}
if rep.DriftCount != n {
t.Errorf("DriftCount = %d, want the full %d", rep.DriftCount, n)
}
if len(rep.Drift) != maxReportedDrift {
t.Errorf("listed %d drifting blobs, want the report capped at %d", len(rep.Drift), maxReportedDrift)
}
if rep.Repaired != n {
t.Errorf("repaired %d rows, want all %d", rep.Repaired, n)
}
if rep, err = db.Fsck(ctx, false); err != nil || rep.DriftCount != 0 {
t.Errorf("after repair: %d drift, err %v", rep.DriftCount, err)
}
}
+182
View File
@@ -0,0 +1,182 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"embed"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"path"
"sort"
"strconv"
"strings"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// migration is one numbered file from migrations/.
type migration struct {
version int
name string
body string
checksum string
}
// migrate applies every migration the database has not seen yet.
//
// SQLite runs DDL inside transactions, so each migration either lands whole or
// not at all — there is no dirty state for an operator to repair by hand, which
// is the main thing an external migration tool buys elsewhere. What is worth
// keeping is the checksum: it catches an already-applied migration file being
// edited afterwards, which otherwise produces two divergent schemas that both
// claim to be at the same version.
func (d *DB) migrate(ctx context.Context) error {
migrations, err := loadMigrations()
if err != nil {
return err
}
if err := d.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at INTEGER NOT NULL
)`)
return err
}); err != nil {
return fmt.Errorf("create schema_version: %w", err)
}
applied := map[int]string{}
rows, err := d.r.QueryContext(ctx, `SELECT version, checksum FROM schema_version`)
if err != nil {
return fmt.Errorf("read schema_version: %w", err)
}
defer rows.Close()
for rows.Next() {
var v int
var sum string
if err := rows.Scan(&v, &sum); err != nil {
return err
}
applied[v] = sum
}
if err := rows.Err(); err != nil {
return err
}
rows.Close()
for _, m := range migrations {
if sum, ok := applied[m.version]; ok {
if sum != m.checksum {
return fmt.Errorf(
"migration %s was modified after it was applied (recorded %s, now %s); "+
"add a new migration instead of editing an applied one",
m.name, sum, m.checksum)
}
continue
}
if d.log != nil {
d.log.Info("applying migration", "version", m.version, "name", m.name)
}
if err := d.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, m.body); err != nil {
return err
}
_, err := tx.ExecContext(ctx,
`INSERT INTO schema_version (version, checksum, applied_at) VALUES (?, ?, ?)`,
m.version, m.checksum, unixNow())
return err
}); err != nil {
return fmt.Errorf("migration %s: %w", m.name, err)
}
}
// A database from a newer build is not something this binary can serve
// safely: it may be missing columns the newer code added.
for v := range applied {
if !hasVersion(migrations, v) {
return fmt.Errorf(
"database is at schema version %d, which this build does not know about; "+
"it was probably written by a newer pages-server", v)
}
}
return nil
}
func hasVersion(ms []migration, v int) bool {
for _, m := range ms {
if m.version == v {
return true
}
}
return false
}
// loadMigrations reads the embedded files, ordered by version.
func loadMigrations() ([]migration, error) {
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return nil, err
}
var out []migration
seen := map[int]string{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
version, err := parseVersion(e.Name())
if err != nil {
return nil, err
}
if prev, dup := seen[version]; dup {
return nil, fmt.Errorf("migrations %s and %s share version %d", prev, e.Name(), version)
}
seen[version] = e.Name()
body, err := migrationsFS.ReadFile(path.Join("migrations", e.Name()))
if err != nil {
return nil, err
}
sum := sha256.Sum256(body)
out = append(out, migration{
version: version,
name: e.Name(),
body: string(body),
checksum: hex.EncodeToString(sum[:]),
})
}
if len(out) == 0 {
return nil, errors.New("no migrations embedded")
}
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
return out, nil
}
// parseVersion reads the leading number of "0001_init.sql".
func parseVersion(name string) (int, error) {
base, _, ok := strings.Cut(name, "_")
if !ok {
return 0, fmt.Errorf("migration %q: want NNNN_name.sql", name)
}
v, err := strconv.Atoi(base)
if err != nil || v <= 0 {
return 0, fmt.Errorf("migration %q: want a positive leading version number", name)
}
return v, nil
}
// SchemaVersion reports the highest applied migration version.
func (d *DB) SchemaVersion(ctx context.Context) (int, error) {
var v sql.NullInt64
err := d.r.QueryRowContext(ctx, `SELECT max(version) FROM schema_version`).Scan(&v)
if err != nil {
return 0, err
}
return int(v.Int64), nil
}
+109
View File
@@ -0,0 +1,109 @@
-- Initial schema.
--
-- Timestamps are unix seconds (INTEGER), matching what the triggers' unixepoch()
-- produces. Digests are stored as raw 32-byte BLOBs, not hex text: half the
-- index size and no conversion on the read path. Hex exists only at the API
-- boundary.
CREATE TABLE projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, -- ^[a-z0-9][a-z0-9._-]{0,62}$
display_name TEXT NOT NULL DEFAULT '',
index_file TEXT NOT NULL DEFAULT 'index.html',
not_found_file TEXT, -- NULL => bare 404
spa_fallback INTEGER NOT NULL DEFAULT 0 CHECK (spa_fallback IN (0,1)),
cache_control TEXT NOT NULL DEFAULT 'public, max-age=0, must-revalidate',
retention_count INTEGER NOT NULL DEFAULT 10,
retention_grace_s INTEGER NOT NULL DEFAULT 3600,
max_files INTEGER NOT NULL DEFAULT 50000,
max_file_bytes INTEGER NOT NULL DEFAULT 268435456, -- 256 MiB
max_total_bytes INTEGER NOT NULL DEFAULT 2147483648, -- 2 GiB
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- API keys. The id is the public lookup handle (safe to display); secret_hash is
-- sha256 of the secret half of the token. See internal/auth/token.go for why a
-- plain hash is the right choice for a 256-bit random secret.
CREATE TABLE api_keys (
id TEXT NOT NULL PRIMARY KEY, -- 16 chars of base32, no padding
secret_hash BLOB NOT NULL, -- sha256(secret), 32 raw bytes
scope TEXT NOT NULL CHECK (scope IN ('admin','project')),
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
expires_at INTEGER,
last_used_at INTEGER,
revoked_at INTEGER,
-- An admin key is not scoped to a project and a project key must be.
CHECK ((scope = 'admin' AND project_id IS NULL)
OR (scope = 'project' AND project_id IS NOT NULL))
) WITHOUT ROWID;
CREATE INDEX api_keys_project ON api_keys(project_id) WHERE project_id IS NOT NULL;
CREATE TABLE deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE, -- "dpl_" + 16 lowercase hex
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
state TEXT NOT NULL CHECK (state IN
('pending','uploading','ready','failed','deleting')),
active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0,1)),
file_count INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
created_by_key TEXT REFERENCES api_keys(id) ON DELETE SET NULL,
meta TEXT NOT NULL DEFAULT '{}', -- JSON: git_sha/branch/ci_url/actor
error TEXT,
created_at INTEGER NOT NULL,
finalized_at INTEGER,
activated_at INTEGER,
deactivated_at INTEGER
);
-- "At most one active deployment per project" is an invariant, so the database
-- enforces it rather than the application. A projects.active_deployment_id
-- column would have needed a circular foreign key and deferred constraints to
-- say the same thing.
CREATE UNIQUE INDEX deployments_one_active ON deployments(project_id) WHERE active = 1;
CREATE INDEX deployments_proj_created ON deployments(project_id, created_at DESC);
CREATE INDEX deployments_state_created ON deployments(state, created_at);
CREATE INDEX deployments_gc ON deployments(deactivated_at)
WHERE active = 0 AND deactivated_at IS NOT NULL;
CREATE TABLE blobs (
digest BLOB NOT NULL PRIMARY KEY, -- sha256, 32 raw bytes
size INTEGER NOT NULL,
present INTEGER NOT NULL DEFAULT 0 CHECK (present IN (0,1)),
refcount INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_ref_at INTEGER NOT NULL
) WITHOUT ROWID;
CREATE INDEX blobs_gc ON blobs(last_ref_at) WHERE refcount = 0;
CREATE INDEX blobs_pending ON blobs(created_at) WHERE present = 0;
-- The file manifest of each deployment.
--
-- encoding is always '' in v1. It is part of the primary key so that a
-- pre-compressed sibling (.br/.gz) can be added later without a table rewrite.
CREATE TABLE deployment_files (
deployment_id INTEGER NOT NULL REFERENCES deployments(id) ON DELETE CASCADE,
path TEXT NOT NULL, -- slash-separated, fs.ValidPath
encoding TEXT NOT NULL DEFAULT '', -- '' | 'gzip' | 'br'
digest BLOB NOT NULL REFERENCES blobs(digest) ON DELETE RESTRICT,
size INTEGER NOT NULL,
PRIMARY KEY (deployment_id, path, encoding)
) WITHOUT ROWID;
CREATE INDEX deployment_files_digest ON deployment_files(digest);
-- Refcounts are maintained by the database so that no code path can forget.
--
-- Caution: ON DELETE CASCADE does not fire these triggers unless
-- recursive_triggers is ON (it is, see internal/store/db.go), and relying on
-- that alone is fragile — delete the manifest rows explicitly before deleting a
-- deployment and let the cascade be the backstop. fsck recomputes every refcount
-- from deployment_files and reports drift.
CREATE TRIGGER deployment_files_ai AFTER INSERT ON deployment_files BEGIN
UPDATE blobs SET refcount = refcount + 1, last_ref_at = unixepoch() WHERE digest = NEW.digest;
END;
CREATE TRIGGER deployment_files_ad AFTER DELETE ON deployment_files BEGIN
UPDATE blobs SET refcount = refcount - 1, last_ref_at = unixepoch() WHERE digest = OLD.digest;
END;
+230
View File
@@ -0,0 +1,230 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
)
// Project is a row of the projects table.
//
// The serving-related fields (IndexFile, NotFoundFile, SPAFallback,
// CacheControl) are copied into the in-memory site registry; the limits are
// enforced when a manifest is accepted.
type Project struct {
ID int64
Name string
DisplayName string
IndexFile string
NotFoundFile string // "" means no custom 404 document
SPAFallback bool
CacheControl string
RetentionCount int
RetentionGraceS int
MaxFiles int
MaxFileBytes int64
MaxTotalBytes int64
CreatedAt time.Time
UpdatedAt time.Time
}
// DefaultProject returns a project carrying the same defaults the schema does,
// as the starting point for a create request.
func DefaultProject(name string) *Project {
return &Project{
Name: name,
IndexFile: "index.html",
CacheControl: "public, max-age=0, must-revalidate",
RetentionCount: 10,
RetentionGraceS: 3600,
MaxFiles: 50000,
MaxFileBytes: 256 << 20,
MaxTotalBytes: 2 << 30,
}
}
const projectColumns = `id, name, display_name, index_file, not_found_file, spa_fallback,
cache_control, retention_count, retention_grace_s, max_files, max_file_bytes,
max_total_bytes, created_at, updated_at`
type rowScanner interface {
Scan(dest ...any) error
}
func scanProject(row rowScanner) (*Project, error) {
var p Project
var notFound sql.NullString
var created, updated int64
err := row.Scan(&p.ID, &p.Name, &p.DisplayName, &p.IndexFile, &notFound, &p.SPAFallback,
&p.CacheControl, &p.RetentionCount, &p.RetentionGraceS, &p.MaxFiles, &p.MaxFileBytes,
&p.MaxTotalBytes, &created, &updated)
if err != nil {
return nil, mapErr(err)
}
p.NotFoundFile = notFound.String
p.CreatedAt = time.Unix(created, 0).UTC()
p.UpdatedAt = time.Unix(updated, 0).UTC()
return &p, nil
}
// CreateProject inserts p and fills in its ID and timestamps. Name uniqueness is
// enforced by the schema, so a duplicate returns ErrExists rather than racing.
func (d *DB) CreateProject(ctx context.Context, p *Project) error {
now := unixNow()
return d.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
INSERT INTO projects (name, display_name, index_file, not_found_file, spa_fallback,
cache_control, retention_count, retention_grace_s, max_files,
max_file_bytes, max_total_bytes, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.Name, p.DisplayName, p.IndexFile, nullString(p.NotFoundFile), p.SPAFallback,
p.CacheControl, p.RetentionCount, p.RetentionGraceS, p.MaxFiles,
p.MaxFileBytes, p.MaxTotalBytes, now, now)
if err != nil {
return mapErr(err)
}
id, err := res.LastInsertId()
if err != nil {
return err
}
p.ID = id
p.CreatedAt = time.Unix(now, 0).UTC()
p.UpdatedAt = p.CreatedAt
return nil
})
}
// ProjectByName looks a project up by its URL name.
func (d *DB) ProjectByName(ctx context.Context, name string) (*Project, error) {
return scanProject(d.r.QueryRowContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE name = ?`, name))
}
// ProjectByID looks a project up by its primary key.
func (d *DB) ProjectByID(ctx context.Context, id int64) (*Project, error) {
return scanProject(d.r.QueryRowContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE id = ?`, id))
}
// ListProjects returns up to limit projects ordered by name, starting after the
// cursor. The cursor is the last name returned, which is stable under
// concurrent inserts in a way that an offset is not.
func (d *DB) ListProjects(ctx context.Context, limit int, cursor string) (projects []*Project, next string, err error) {
if limit <= 0 || limit > 500 {
limit = 100
}
// One extra row tells us whether another page exists without a second query.
rows, err := d.r.QueryContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE name > ? ORDER BY name LIMIT ?`,
cursor, limit+1)
if err != nil {
return nil, "", err
}
defer rows.Close()
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, "", err
}
projects = append(projects, p)
}
if err := rows.Err(); err != nil {
return nil, "", err
}
if len(projects) > limit {
projects = projects[:limit]
next = projects[len(projects)-1].Name
}
return projects, next, nil
}
// AllProjects returns every project, for building the in-memory registry at
// startup. The registry holds them all anyway, so paging here would be theatre.
func (d *DB) AllProjects(ctx context.Context) ([]*Project, error) {
rows, err := d.r.QueryContext(ctx, `SELECT `+projectColumns+` FROM projects ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Project
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// UpdateProject writes p's mutable fields back. Name and ID are immutable: a
// rename would invalidate every deployed URL and every cached symlink, and the
// API offers delete-and-recreate instead.
func (d *DB) UpdateProject(ctx context.Context, p *Project) error {
now := unixNow()
return d.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
UPDATE projects SET display_name = ?, index_file = ?, not_found_file = ?,
spa_fallback = ?, cache_control = ?, retention_count = ?,
retention_grace_s = ?, max_files = ?, max_file_bytes = ?,
max_total_bytes = ?, updated_at = ?
WHERE id = ?`,
p.DisplayName, p.IndexFile, nullString(p.NotFoundFile), p.SPAFallback,
p.CacheControl, p.RetentionCount, p.RetentionGraceS, p.MaxFiles,
p.MaxFileBytes, p.MaxTotalBytes, now, p.ID)
if err != nil {
return mapErr(err)
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
p.UpdatedAt = time.Unix(now, 0).UTC()
return nil
})
}
// DeleteProject removes a project and, by cascade, its keys, deployments and
// manifest rows. Blob refcounts fall as the manifest rows go, so the next GC
// pass reclaims the content.
//
// The caller is responsible for the parts the database does not know about: the
// registry entry, the webroot symlink and the assembled directories.
func (d *DB) DeleteProject(ctx context.Context, id int64) error {
return d.Tx(ctx, func(tx *sql.Tx) error {
// Delete the manifest rows explicitly rather than trusting the cascade to
// fire the refcount triggers (see the schema comment).
if _, err := tx.ExecContext(ctx, `
DELETE FROM deployment_files
WHERE deployment_id IN (SELECT id FROM deployments WHERE project_id = ?)`, id); err != nil {
return err
}
res, err := tx.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return mapErr(err)
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
})
}
// CountProjects is used by /api/v1/system/info.
func (d *DB) CountProjects(ctx context.Context) (int64, error) {
var n int64
if err := d.r.QueryRowContext(ctx, `SELECT count(*) FROM projects`).Scan(&n); err != nil {
return 0, fmt.Errorf("count projects: %w", err)
}
return n, nil
}
+285
View File
@@ -0,0 +1,285 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"testing"
"time"
)
func TestCreateAndReadProject(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := DefaultProject("demo")
p.DisplayName = "Demo Site"
p.NotFoundFile = "404.html"
p.SPAFallback = true
if err := db.CreateProject(ctx, p); err != nil {
t.Fatalf("CreateProject: %v", err)
}
if p.ID == 0 {
t.Error("CreateProject must fill in the ID")
}
if p.CreatedAt.IsZero() || !p.UpdatedAt.Equal(p.CreatedAt) {
t.Errorf("timestamps not set: created=%v updated=%v", p.CreatedAt, p.UpdatedAt)
}
got, err := db.ProjectByName(ctx, "demo")
if err != nil {
t.Fatalf("ProjectByName: %v", err)
}
if got.ID != p.ID || got.DisplayName != "Demo Site" || got.NotFoundFile != "404.html" || !got.SPAFallback {
t.Errorf("round trip lost data: %+v", got)
}
if got.IndexFile != "index.html" || got.RetentionCount != 10 {
t.Errorf("defaults not persisted: %+v", got)
}
byID, err := db.ProjectByID(ctx, p.ID)
if err != nil {
t.Fatalf("ProjectByID: %v", err)
}
if byID.Name != "demo" {
t.Errorf("ProjectByID returned %q", byID.Name)
}
}
// An empty not_found_file must come back as "" rather than as a bogus "NULL"
// string, because the resolver branches on it being empty.
func TestProjectNullNotFoundFile(t *testing.T) {
ctx := context.Background()
db := testDB(t)
if err := db.CreateProject(ctx, DefaultProject("demo")); err != nil {
t.Fatal(err)
}
got, err := db.ProjectByName(ctx, "demo")
if err != nil {
t.Fatal(err)
}
if got.NotFoundFile != "" {
t.Errorf("NotFoundFile = %q, want empty", got.NotFoundFile)
}
}
func TestCreateProjectDuplicateName(t *testing.T) {
ctx := context.Background()
db := testDB(t)
if err := db.CreateProject(ctx, DefaultProject("demo")); err != nil {
t.Fatal(err)
}
err := db.CreateProject(ctx, DefaultProject("demo"))
if !errors.Is(err, ErrExists) {
t.Fatalf("second create: got %v, want ErrExists", err)
}
}
func TestProjectNotFound(t *testing.T) {
ctx := context.Background()
db := testDB(t)
if _, err := db.ProjectByName(ctx, "nope"); !errors.Is(err, ErrNotFound) {
t.Errorf("ProjectByName: got %v, want ErrNotFound", err)
}
if _, err := db.ProjectByID(ctx, 404); !errors.Is(err, ErrNotFound) {
t.Errorf("ProjectByID: got %v, want ErrNotFound", err)
}
if err := db.DeleteProject(ctx, 404); !errors.Is(err, ErrNotFound) {
t.Errorf("DeleteProject: got %v, want ErrNotFound", err)
}
if err := db.UpdateProject(ctx, &Project{ID: 404}); !errors.Is(err, ErrNotFound) {
t.Errorf("UpdateProject: got %v, want ErrNotFound", err)
}
}
func TestUpdateProject(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := DefaultProject("demo")
if err := db.CreateProject(ctx, p); err != nil {
t.Fatal(err)
}
p.SPAFallback = true
p.CacheControl = "public, max-age=31536000, immutable"
p.RetentionCount = 3
p.NotFoundFile = "404.html"
if err := db.UpdateProject(ctx, p); err != nil {
t.Fatalf("UpdateProject: %v", err)
}
got, err := db.ProjectByName(ctx, "demo")
if err != nil {
t.Fatal(err)
}
if !got.SPAFallback || got.RetentionCount != 3 || got.NotFoundFile != "404.html" {
t.Errorf("update did not stick: %+v", got)
}
if got.Name != "demo" {
t.Errorf("name must be immutable, got %q", got.Name)
}
if got.CreatedAt.After(got.UpdatedAt) {
t.Errorf("updated_at %v predates created_at %v", got.UpdatedAt, got.CreatedAt)
}
}
// Clearing not_found_file must write SQL NULL, not the empty string, so the
// column keeps a single representation of "unset".
func TestUpdateProjectClearsNotFoundFile(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := DefaultProject("demo")
p.NotFoundFile = "404.html"
if err := db.CreateProject(ctx, p); err != nil {
t.Fatal(err)
}
p.NotFoundFile = ""
if err := db.UpdateProject(ctx, p); err != nil {
t.Fatal(err)
}
var isNull bool
if err := db.Reader().QueryRow(
`SELECT not_found_file IS NULL FROM projects WHERE id = ?`, p.ID).Scan(&isNull); err != nil {
t.Fatal(err)
}
if !isNull {
t.Error("cleared not_found_file should be stored as NULL")
}
}
func TestListProjectsPaging(t *testing.T) {
ctx := context.Background()
db := testDB(t)
for i := 0; i < 7; i++ {
if err := db.CreateProject(ctx, DefaultProject(fmt.Sprintf("p%d", i))); err != nil {
t.Fatal(err)
}
}
var names []string
cursor := ""
for pages := 0; ; pages++ {
if pages > 10 {
t.Fatal("paging did not terminate")
}
batch, next, err := db.ListProjects(ctx, 3, cursor)
if err != nil {
t.Fatal(err)
}
for _, p := range batch {
names = append(names, p.Name)
}
if next == "" {
break
}
cursor = next
}
want := []string{"p0", "p1", "p2", "p3", "p4", "p5", "p6"}
if len(names) != len(want) {
t.Fatalf("paged names = %v, want %v", names, want)
}
for i := range want {
if names[i] != want[i] {
t.Fatalf("paged names = %v, want %v", names, want)
}
}
n, err := db.CountProjects(ctx)
if err != nil {
t.Fatal(err)
}
if n != 7 {
t.Errorf("CountProjects = %d, want 7", n)
}
all, err := db.AllProjects(ctx)
if err != nil {
t.Fatal(err)
}
if len(all) != 7 {
t.Errorf("AllProjects returned %d rows, want 7", len(all))
}
}
// Deleting a project must take its keys, deployments and manifest rows with it,
// and must drop the blob refcounts so the content becomes collectable.
func TestDeleteProjectCascades(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := DefaultProject("demo")
if err := db.CreateProject(ctx, p); err != nil {
t.Fatal(err)
}
digest := make([]byte, 32)
digest[0] = 0x7f
if err := db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.Exec(`INSERT INTO deployments (id, public_id, project_id, state, created_at)
VALUES (1, 'dpl_a', ?, 'ready', 1)`, p.ID); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO blobs (digest, size, present, created_at, last_ref_at)
VALUES (?, 5, 1, 1, 1)`, digest); err != nil {
return err
}
_, err := tx.Exec(`INSERT INTO deployment_files (deployment_id, path, digest, size)
VALUES (1, 'index.html', ?, 5)`, digest)
return err
}); err != nil {
t.Fatal(err)
}
pid := p.ID
if err := db.CreateKey(ctx, &APIKey{
ID: "keyaaaaaaaaaaaaa", SecretHash: make([]byte, 32), Scope: ScopeProject, ProjectID: &pid,
}); err != nil {
t.Fatal(err)
}
if err := db.DeleteProject(ctx, p.ID); err != nil {
t.Fatalf("DeleteProject: %v", err)
}
count := func(query string, args ...any) int {
t.Helper()
var n int
if err := db.Reader().QueryRow(query, args...).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
if n := count(`SELECT count(*) FROM deployments`); n != 0 {
t.Errorf("%d deployments survived", n)
}
if n := count(`SELECT count(*) FROM deployment_files`); n != 0 {
t.Errorf("%d manifest rows survived", n)
}
if n := count(`SELECT count(*) FROM api_keys`); n != 0 {
t.Errorf("%d keys survived", n)
}
if n := count(`SELECT refcount FROM blobs WHERE digest = ?`, digest); n != 0 {
t.Errorf("blob refcount = %d, want 0 (content would never be collected)", n)
}
// The blob row itself stays: it is now unreferenced, and reclaiming it is
// GC's job, not the delete path's.
if n := count(`SELECT count(*) FROM blobs`); n != 1 {
t.Errorf("blob row count = %d, want 1", n)
}
}
func TestProjectTimestampsAreUTC(t *testing.T) {
ctx := context.Background()
db := testDB(t)
p := DefaultProject("demo")
if err := db.CreateProject(ctx, p); err != nil {
t.Fatal(err)
}
got, err := db.ProjectByName(ctx, "demo")
if err != nil {
t.Fatal(err)
}
if got.CreatedAt.Location() != time.UTC {
t.Errorf("CreatedAt location = %v, want UTC", got.CreatedAt.Location())
}
}
+30
View File
@@ -0,0 +1,30 @@
package store
import "context"
// Counts is the summary behind GET /api/v1/system/info.
type Counts struct {
Projects int64
Deployments int64
Blobs int64
// CASBytes is the size of the blobs the store believes are on disk. It is
// the deduplicated total, so it is smaller — usually much smaller — than the
// sum of the deployments' sizes.
CASBytes int64
}
// Counts gathers the summary in one round trip.
//
// The subqueries are counted separately rather than joined: a join would have
// to fan out over deployment_files and then collapse again, which on a large
// manifest is thousands of times the work for the same four numbers.
func (d *DB) Counts(ctx context.Context) (Counts, error) {
var c Counts
err := d.r.QueryRowContext(ctx, `
SELECT (SELECT count(*) FROM projects),
(SELECT count(*) FROM deployments),
(SELECT count(*) FROM blobs WHERE present = 1),
(SELECT coalesce(sum(size), 0) FROM blobs WHERE present = 1)`).
Scan(&c.Projects, &c.Deployments, &c.Blobs, &c.CASBytes)
return c, err
}
+301
View File
@@ -0,0 +1,301 @@
package store
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
)
func testDB(t *testing.T) *DB {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
db, err := Open(context.Background(), filepath.Join(t.TempDir(), "pages.db"), log)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestOpenAppliesMigrations(t *testing.T) {
db := testDB(t)
v, err := db.SchemaVersion(context.Background())
if err != nil {
t.Fatal(err)
}
if v != 1 {
t.Errorf("schema version = %d, want 1", v)
}
for _, table := range []string{"projects", "api_keys", "deployments", "blobs", "deployment_files"} {
var n int
if err := db.Reader().QueryRow(
`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("table %s missing", table)
}
}
}
func TestMigrateIsIdempotent(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "pages.db")
log := slog.New(slog.NewTextHandler(io.Discard, nil))
db, err := Open(ctx, path, log)
if err != nil {
t.Fatal(err)
}
if _, err := db.w.ExecContext(ctx,
`INSERT INTO projects (name, created_at, updated_at) VALUES ('demo', 1, 1)`); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
// Reopening must not re-run migrations, and must not lose data.
db2, err := Open(ctx, path, log)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer db2.Close()
var name string
if err := db2.Reader().QueryRow(`SELECT name FROM projects`).Scan(&name); err != nil {
t.Fatal(err)
}
if name != "demo" {
t.Errorf("project name = %q", name)
}
}
func TestMigrateDetectsChecksumDrift(t *testing.T) {
ctx := context.Background()
db := testDB(t)
if _, err := db.w.ExecContext(ctx,
`UPDATE schema_version SET checksum = 'tampered' WHERE version = 1`); err != nil {
t.Fatal(err)
}
err := db.migrate(ctx)
if err == nil {
t.Fatal("editing an applied migration must be detected")
}
if !strings.Contains(err.Error(), "modified after it was applied") {
t.Errorf("error = %v", err)
}
}
func TestMigrateRejectsUnknownFutureVersion(t *testing.T) {
ctx := context.Background()
db := testDB(t)
if _, err := db.w.ExecContext(ctx,
`INSERT INTO schema_version (version, checksum, applied_at) VALUES (99, 'x', 1)`); err != nil {
t.Fatal(err)
}
if err := db.migrate(ctx); err == nil {
t.Fatal("a database from a newer build must be refused")
}
}
// The partial unique index is what actually guarantees "one active deployment
// per project"; the application layer only has to avoid fighting it.
func TestOneActiveDeploymentPerProject(t *testing.T) {
ctx := context.Background()
db := testDB(t)
if err := db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.Exec(`INSERT INTO projects (id, name, created_at, updated_at) VALUES (1, 'demo', 1, 1)`); err != nil {
return err
}
_, err := tx.Exec(`INSERT INTO deployments (public_id, project_id, state, active, created_at)
VALUES ('dpl_a', 1, 'ready', 1, 1)`)
return err
}); err != nil {
t.Fatal(err)
}
err := db.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.Exec(`INSERT INTO deployments (public_id, project_id, state, active, created_at)
VALUES ('dpl_b', 1, 'ready', 1, 2)`)
return err
})
if err == nil {
t.Fatal("a second active deployment must be rejected by the database")
}
if !IsConstraint(err) {
t.Errorf("want a constraint violation, got %v", err)
}
// Demoting the old one first is the supported path.
if err := db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.Exec(`UPDATE deployments SET active = 0 WHERE project_id = 1 AND active = 1`); err != nil {
return err
}
_, err := tx.Exec(`INSERT INTO deployments (public_id, project_id, state, active, created_at)
VALUES ('dpl_b', 1, 'ready', 1, 2)`)
return err
}); err != nil {
t.Fatalf("demote-then-promote must be allowed: %v", err)
}
}
func TestRefcountTriggers(t *testing.T) {
ctx := context.Background()
db := testDB(t)
digest := make([]byte, 32)
digest[0] = 0xab
setup := func(tx *sql.Tx) error {
if _, err := tx.Exec(`INSERT INTO projects (id, name, created_at, updated_at) VALUES (1, 'demo', 1, 1)`); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO deployments (id, public_id, project_id, state, created_at)
VALUES (1, 'dpl_a', 1, 'uploading', 1), (2, 'dpl_b', 1, 'uploading', 2)`); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO blobs (digest, size, present, created_at, last_ref_at)
VALUES (?, 10, 0, 1, 1)`, digest); err != nil {
return err
}
_, err := tx.Exec(`INSERT INTO deployment_files (deployment_id, path, digest, size)
VALUES (1, 'index.html', ?, 10), (2, 'index.html', ?, 10)`, digest, digest)
return err
}
if err := db.Tx(ctx, setup); err != nil {
t.Fatal(err)
}
refcount := func() int {
t.Helper()
var n int
if err := db.Reader().QueryRow(`SELECT refcount FROM blobs WHERE digest = ?`, digest).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
if got := refcount(); got != 2 {
t.Fatalf("refcount after 2 inserts = %d, want 2", got)
}
if err := db.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.Exec(`DELETE FROM deployment_files WHERE deployment_id = 1`)
return err
}); err != nil {
t.Fatal(err)
}
if got := refcount(); got != 1 {
t.Errorf("refcount after explicit delete = %d, want 1", got)
}
// The cascade path: deleting the deployment row must also decrement, which
// only holds because recursive_triggers is ON.
if err := db.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.Exec(`DELETE FROM deployments WHERE id = 2`)
return err
}); err != nil {
t.Fatal(err)
}
if got := refcount(); got != 0 {
t.Errorf("refcount after cascade = %d, want 0 (recursive_triggers not in effect?)", got)
}
}
// A blob may not be dropped while a manifest still points at it.
func TestBlobDeleteRestricted(t *testing.T) {
ctx := context.Background()
db := testDB(t)
digest := make([]byte, 32)
if err := db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.Exec(`INSERT INTO projects (id, name, created_at, updated_at) VALUES (1, 'demo', 1, 1)`); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO deployments (id, public_id, project_id, state, created_at)
VALUES (1, 'dpl_a', 1, 'ready', 1)`); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO blobs (digest, size, present, created_at, last_ref_at) VALUES (?, 1, 1, 1, 1)`, digest); err != nil {
return err
}
_, err := tx.Exec(`INSERT INTO deployment_files (deployment_id, path, digest, size) VALUES (1, 'a', ?, 1)`, digest)
return err
}); err != nil {
t.Fatal(err)
}
err := db.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.Exec(`DELETE FROM blobs WHERE digest = ?`, digest)
return err
})
if err == nil {
t.Fatal("deleting a referenced blob must fail")
}
}
func TestScopeCheckConstraint(t *testing.T) {
ctx := context.Background()
db := testDB(t)
cases := []struct {
name string
scope string
proj any
ok bool
}{
{"admin without project", "admin", nil, true},
{"admin with project", "admin", int64(1), false},
{"project without project", "project", nil, false},
{"project with project", "project", int64(1), true},
{"unknown scope", "root", nil, false},
}
if err := db.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.Exec(`INSERT INTO projects (id, name, created_at, updated_at) VALUES (1, 'demo', 1, 1)`)
return err
}); err != nil {
t.Fatal(err)
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := db.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.Exec(
`INSERT INTO api_keys (id, secret_hash, scope, project_id, created_at) VALUES (?, ?, ?, ?, 1)`,
"key"+string(rune('a'+i)), make([]byte, 32), tc.scope, tc.proj)
return err
})
if tc.ok && err != nil {
t.Errorf("insert should have been accepted: %v", err)
}
if !tc.ok && err == nil {
t.Error("insert should have been rejected")
}
})
}
}
// The write pool is capped at one connection, so a transaction that never
// returns would deadlock the server. This asserts that a plain read does not
// need the write pool.
func TestReadsDoNotBlockOnWriter(t *testing.T) {
ctx := context.Background()
db := testDB(t)
done := make(chan struct{})
go func() {
defer close(done)
_ = db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.Exec(`INSERT INTO projects (name, created_at, updated_at) VALUES ('slow', 1, 1)`); err != nil {
return err
}
var n int
// While this transaction is open, a reader must still make progress.
if err := db.Reader().QueryRow(`SELECT count(*) FROM projects`).Scan(&n); err != nil {
return err
}
return nil
})
}()
<-done
}