Files
2026-08-15 07:13:00 +00:00

312 lines
11 KiB
Go

package deploy
import (
"context"
"errors"
"io"
"log/slog"
"sync"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/site"
"github.com/iceBear67/simplepages/internal/store"
"github.com/iceBear67/simplepages/internal/webroot"
)
// Service runs the deployment lifecycle.
//
// Client-visible failures are returned as *api.Error so the HTTP layer stays a
// translation of shapes rather than a second copy of the rules; anything else
// is an internal error and is rendered as an opaque 500 by httpx.WriteError.
type Service struct {
DB *store.DB
CAS *cas.Store
Log *slog.Logger
// Dir is the root of the assembled deployment trees. Empty means the
// operator chose assemble_mode=none: content is served straight from the
// CAS and nothing is built on disk.
Dir string
// Sites is the in-memory state the HTTP site handler reads. Activation
// publishes into it; nil leaves the service usable without a serving layer,
// which is what the store-level tests want.
Sites *site.Registry
// Webroot maintains the $WEBROOT/~project symlinks. Nil when the operator
// configured no webroot. Nothing here depends on it succeeding.
Webroot *webroot.Webroot
// BlobGrace is how long content must have been unreferenced before the
// collector removes it. Zero means defaultBlobGrace. Negative collects
// immediately, which is what the tests want and what an operator reclaiming
// space on a server they know is idle might ask for.
BlobGrace time.Duration
locks projectLocks
}
// Create starts a deployment. Nothing touches the filesystem until a manifest
// arrives, so an abandoned create costs one row.
func (s *Service) Create(ctx context.Context, p *store.Project, keyID string, meta map[string]string) (*store.Deployment, error) {
dep := &store.Deployment{ProjectID: p.ID, CreatedByKey: keyID, Meta: meta}
if err := s.DB.CreateDeployment(ctx, dep); err != nil {
return nil, err
}
return dep, nil
}
// SetManifest records the file list and reports which blobs still have to be
// uploaded. files must already be validated: paths through pathutil and sizes
// against the project's limits.
func (s *Service) SetManifest(ctx context.Context, dep *store.Deployment, files []store.FileRow) (missing []cas.Digest, missingBytes int64, err error) {
missing, missingBytes, err = s.DB.SetManifest(ctx, dep.ID, files)
if err != nil {
switch {
case errors.Is(err, store.ErrConflict):
return nil, 0, api.Errorf(api.CodeConflict,
"this deployment can no longer accept a manifest; create a new one")
case errors.Is(err, cas.ErrSizeMismatch):
return nil, 0, api.Errorf(api.CodeSizeMismatch, "%s", err)
}
return nil, 0, err
}
return missing, missingBytes, nil
}
// Upload stores one blob's content.
//
// The digest must already be named by some manifest. That check is what keeps
// the endpoint from being general-purpose storage: content nobody declared can
// never be written, and the length it must have is the one the manifest agreed
// on rather than whatever Content-Length claims.
//
// Reports whether the content was newly stored; a blob that is already present
// is a success without reading the body, which is what makes a retried deploy
// cheap.
func (s *Service) Upload(ctx context.Context, digest cas.Digest, body io.Reader) (size int64, stored bool, err error) {
b, err := s.DB.Blob(ctx, digest)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return 0, false, api.Errorf(api.CodeNotFound,
"no manifest references this digest; send the manifest first")
}
return 0, false, err
}
if b.Present {
return b.Size, false, nil
}
// The blob's declared length is both the expectation and the ceiling: Put
// reads one byte past it and rejects anything longer, so a client cannot
// spend more of the disk than its manifest was accepted for. Put requires a
// positive ceiling, hence the floor of one byte for an empty blob.
limit := b.Size
if limit < 1 {
limit = 1
}
n, err := s.CAS.Put(ctx, digest, b.Size, limit, body)
if err != nil {
switch {
case errors.Is(err, cas.ErrDigestMismatch):
return 0, false, api.Errorf(api.CodeDigestMismatch, "%s", err)
case errors.Is(err, cas.ErrSizeMismatch):
return 0, false, api.Errorf(api.CodeSizeMismatch, "%s", err)
case errors.Is(err, cas.ErrTooLarge):
return 0, false, api.Errorf(api.CodeLimitExceeded, "%s", err)
}
return 0, false, err
}
if err := s.DB.MarkBlobPresent(ctx, digest, n); err != nil {
// The content is on disk and verified; only the row disagrees. A retry
// finds the blob already stored and updates the row then.
return 0, false, err
}
return n, true, nil
}
// Finalize checks that every blob arrived, assembles the tree, and marks the
// deployment ready. It does not activate it: a ready deployment is one that
// could be served, and choosing when to serve it is a separate decision.
//
// Retrying is safe. An assembled tree is left as it is, and a deployment that
// is already ready simply stays ready.
func (s *Service) Finalize(ctx context.Context, p *store.Project, dep *store.Deployment) (*store.Deployment, error) {
// One finalize per project at a time. Two concurrent CI jobs for one
// project are ordered rather than racing over the same directory.
unlock, err := s.locks.lock(ctx, p.ID)
if err != nil {
return nil, err
}
defer unlock()
// Re-read under the lock: the state may have moved since the handler
// resolved it.
dep, err = s.DB.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
if err != nil {
return nil, mapNotFound(err)
}
switch dep.State {
case store.StateUploading, store.StateReady:
default:
return nil, api.Errorf(api.CodeConflict, "cannot finalize a deployment that is %s", dep.State)
}
missing, err := s.DB.MissingBlobs(ctx, dep.ID)
if err != nil {
return nil, err
}
if len(missing) > 0 {
hex := make([]string, len(missing))
for i, d := range missing {
hex[i] = d.String()
}
return nil, api.Errorf(api.CodeBlobsMissing,
"%d blobs have not been uploaded", len(missing)).WithDetail("missing", hex)
}
if s.Dir != "" {
files, err := s.DB.DeploymentFiles(ctx, dep.ID)
if err != nil {
return nil, err
}
if err := Assemble(ctx, s.CAS, files, DeploymentDir(s.Dir, p.ID, dep.PublicID)); err != nil {
// A cancelled request is not a broken deployment: leave it uploading
// so the client can simply try again.
if ctx.Err() != nil {
return nil, err
}
s.Log.ErrorContext(ctx, "assembling deployment tree failed",
"project", p.Name, "deployment", dep.PublicID, "err", err)
if ferr := s.DB.MarkDeploymentFailed(context.WithoutCancel(ctx), dep.ID, err.Error()); ferr != nil {
s.Log.ErrorContext(ctx, "recording the failure failed too",
"deployment", dep.PublicID, "err", ferr)
}
return nil, api.Errorf(api.CodeInternal, "could not assemble the deployment tree")
}
}
if err := s.DB.MarkDeploymentReady(ctx, dep.ID); err != nil {
if errors.Is(err, store.ErrConflict) {
return nil, api.Errorf(api.CodeConflict, "%s", err)
}
return nil, err
}
return s.DB.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
}
// Activate makes a ready deployment the one the project serves. Activating an
// older deployment is how a rollback works, and costs exactly the same.
//
// The order of the steps is the entire correctness argument:
//
// 1. Take the project lock, so two activations of one project are ordered.
// 2. Re-read the deployment under it and require that it is ready.
// 3. Build the snapshot's index — the one step that reads the manifest and can
// fail — *before* anything has changed. A failure here leaves the currently
// served deployment exactly as it was.
// 4. Commit the database transaction. From here on SQLite is the truth.
// 5. Store the pointer. This single store is the switch: requests that started
// earlier finish on the old snapshot, later ones see the new one, and no
// request can ever observe a mixture of the two.
// 6. Repoint the symlink, best effort.
//
// Database before memory matters: a crash between 4 and 5 restarts into a
// process that serves what the database says. The reverse order would leave a
// process serving something the database disagrees with.
func (s *Service) Activate(ctx context.Context, p *store.Project, dep *store.Deployment) (*store.Deployment, error) {
unlock, err := s.locks.lock(ctx, p.ID)
if err != nil {
return nil, err
}
defer unlock()
dep, err = s.DB.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
if err != nil {
return nil, mapNotFound(err)
}
if dep.State != store.StateReady {
return nil, api.Errorf(api.CodeDeploymentNotReady,
"cannot activate a deployment that is %s", dep.State)
}
idx, err := s.index(ctx, dep)
if err != nil {
return nil, err
}
if err := s.DB.ActivateDeployment(ctx, p.ID, dep.ID); err != nil {
switch {
case errors.Is(err, store.ErrNotFound):
return nil, api.Errorf(api.CodeNotFound, "no such deployment")
case errors.Is(err, store.ErrConflict):
return nil, api.Errorf(api.CodeConflict, "%s", err)
}
return nil, err
}
// Re-read so the snapshot and the response carry the timestamps the
// transaction actually wrote.
dep, err = s.DB.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
if err != nil {
return nil, mapNotFound(err)
}
dir := s.deploymentDir(p, dep)
if s.Sites != nil {
s.Sites.Put(p).Activate(site.NewDeployment(dep, idx, dir))
}
if s.Webroot != nil && dir != "" {
if err := s.Webroot.Point(p.Name, dir); err != nil {
// The site is already being served from memory; the symlink is for
// everything else and the reconciler will fix it.
s.Log.ErrorContext(ctx, "could not repoint the webroot symlink",
"project", p.Name, "deployment", dep.PublicID, "err", err)
}
}
s.Log.InfoContext(ctx, "deployment activated",
"project", p.Name, "deployment", dep.PublicID,
"files", dep.FileCount, "bytes", dep.TotalBytes)
return dep, nil
}
func mapNotFound(err error) error {
if errors.Is(err, store.ErrNotFound) {
return api.Errorf(api.CodeNotFound, "no such deployment")
}
return err
}
// projectLocks serialises the mutating operations of one project against each
// other. Entries are keyed by row id and are never evicted: there is one per
// project that has ever been written to, which is bounded by the number of
// projects.
type projectLocks struct {
mu sync.Mutex
m map[int64]chan struct{}
}
// lock acquires the project's lock, or gives up if ctx is done first — a
// client that has already hung up should not keep a slow assembly waiting.
func (l *projectLocks) lock(ctx context.Context, id int64) (func(), error) {
l.mu.Lock()
if l.m == nil {
l.m = make(map[int64]chan struct{})
}
ch, ok := l.m[id]
if !ok {
ch = make(chan struct{}, 1)
l.m[id] = ch
}
l.mu.Unlock()
select {
case ch <- struct{}{}:
return func() { <-ch }, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}