init
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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] + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, ¬Found, &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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user