Skip to content
quarry Artifact [209fa5beeb]
Login

Artifact [209fa5beeb]

Artifact 209fa5beeb53d1681c1dc0900d707eccf471c7c3b936a912114520185ca3e34a:


// bucket.go is RFC 0016's binding: which bucket holds the files
// uploaded through the forge for one repository, and the key that
// writes there. Forge-local, like a mirror — the DAG carries stubs
// that name bytes by hash, and this row is where those bytes are
// today. Unbinding leaves every stub as it was; binding another
// bucket with the same objects makes them all live again.
//
// The secret is sealed before it reaches the table (secret.go) and
// unsealed only by Bucket, whose one caller is the ops layer signing
// a request. Nothing else reads it and no op returns it.
package oltp

import (
	"database/sql"
	"errors"
	"time"
)

const bucketSchema = `
CREATE TABLE IF NOT EXISTS bucket (
	repo        TEXT PRIMARY KEY,
	endpoint    TEXT NOT NULL,             -- https://<account>.r2.cloudflarestorage.com and the like
	bucket      TEXT NOT NULL,
	region      TEXT NOT NULL,             -- 'auto' for R2
	access_key  TEXT NOT NULL,
	secret_enc  TEXT NOT NULL,             -- sealed, never plaintext
	public_base TEXT NOT NULL DEFAULT '',  -- a public domain the bucket serves from; empty presigns every download
	limit_bytes INTEGER NOT NULL DEFAULT 104857600,
	probed_at   TEXT NOT NULL DEFAULT '',  -- last probe, success or not
	probe_err   TEXT NOT NULL DEFAULT ''   -- empty when the last probe passed
);`

// DefaultBucketLimit is the per-file cap when a binding names none:
// a screen recording fits, a disk image does not (RFC 0016).
const DefaultBucketLimit int64 = 100 << 20

// BucketBinding is one repository's bucket, secret included. It is
// handed to the ops layer and nowhere else.
type BucketBinding struct {
	Endpoint   string
	Bucket     string
	Region     string
	AccessKey  string
	Secret     string
	PublicBase string
	LimitBytes int64
	// ProbedAt is the last probe's stamp, ProbeErr what it said when
	// it failed; empty ProbeErr with a stamp is a binding that works.
	ProbedAt string
	ProbeErr string
	// Archives opens the type policy to zip, gzip and tar;
	// DeleteObjects makes a removal also delete the object. Both off
	// by default (attachment.go); a re-bind keeps them.
	Archives      bool
	DeleteObjects bool
}

// BindBucket stores (replacing) a repository's binding. The secret is
// required here: an op that lets a steward edit the public base
// without retyping the key reads the stored one first and passes it
// back, so this layer never has to guess whether an empty secret
// means "keep" or "clear". The probe columns are the caller's to set
// afterwards, since a binding is written only once a probe passed.
func (d *DB) BindBucket(repo string, b BucketBinding) error {
	if repo == "" || b.Endpoint == "" || b.Bucket == "" || b.AccessKey == "" || b.Secret == "" {
		return errors.New("oltp: a bucket binding needs a repository, an endpoint, a bucket, an access key and a secret")
	}
	if b.LimitBytes <= 0 {
		b.LimitBytes = DefaultBucketLimit
	}
	sealed, err := d.seal.seal(b.Secret)
	if err != nil {
		return err
	}
	_, err = d.db.Exec(`
		INSERT INTO bucket (repo, endpoint, bucket, region, access_key, secret_enc, public_base, limit_bytes)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?)
		ON CONFLICT(repo) DO UPDATE SET
			endpoint = excluded.endpoint, bucket = excluded.bucket, region = excluded.region,
			access_key = excluded.access_key, secret_enc = excluded.secret_enc,
			public_base = excluded.public_base, limit_bytes = excluded.limit_bytes`,
		repo, b.Endpoint, b.Bucket, b.Region, b.AccessKey, sealed, b.PublicBase, b.LimitBytes)
	return err
}

// Bucket answers a repository's binding with the secret unsealed.
func (d *DB) Bucket(repo string) (BucketBinding, bool, error) {
	var b BucketBinding
	var sealed string
	var archives, deleteObjects int
	err := d.db.QueryRow(`
		SELECT endpoint, bucket, region, access_key, secret_enc, public_base, limit_bytes, probed_at, probe_err, archives, delete_objects
		FROM bucket WHERE repo = ?`, repo).
		Scan(&b.Endpoint, &b.Bucket, &b.Region, &b.AccessKey, &sealed, &b.PublicBase, &b.LimitBytes, &b.ProbedAt, &b.ProbeErr, &archives, &deleteObjects)
	b.Archives, b.DeleteObjects = archives != 0, deleteObjects != 0
	if errors.Is(err, sql.ErrNoRows) {
		return BucketBinding{}, false, nil
	}
	if err != nil {
		return BucketBinding{}, false, err
	}
	if b.Secret, err = d.seal.open(sealed); err != nil {
		return BucketBinding{}, false, err
	}
	return b, true, nil
}

// UnbindBucket forgets the binding. The objects stay in the steward's
// bucket and the stubs stay in the DAG.
func (d *DB) UnbindBucket(repo string) error {
	_, err := d.db.Exec(`DELETE FROM bucket WHERE repo = ?`, repo)
	return err
}

// SetBucketProbe stamps a probe. An empty probeErr is a pass.
func (d *DB) SetBucketProbe(repo string, at time.Time, probeErr string) error {
	_, err := d.db.Exec(`UPDATE bucket SET probed_at = ?, probe_err = ? WHERE repo = ?`,
		at.UTC().Format(time.RFC3339), probeErr, repo)
	return err
}