package api
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/s3"
)
// forgebucket.go is the storage the FORGE supplies.
//
// Attachments were bring-your-own and nothing else, so every repository
// had to be handed an endpoint and a key before a file could be put on
// a ticket. On a forge somebody runs for their friends that is the
// whole feature unavailable until each of them opens an account with a
// storage provider — the operator sets up quarry and dagnats, invites
// people, and watches the composer fail.
//
// So: the forge may bind one bucket, every repository without its own
// uses it, and binding your own stays a choice. The forge's binding
// lives under oltp.ForgeBucket, the empty name, which is the same
// convention the fleet default for capability categories already uses.
//
// Site admin only, and that is the correct scope rather than a
// concession: this is the operator's own storage and the operator's own
// bill, and it is the one thing here that is genuinely the forge's.
type forgeBucketIn struct {
Endpoint string `json:"endpoint" validate:"required,max=512"`
Bucket string `json:"bucket" validate:"required,max=200"`
Region string `json:"region" validate:"max=64"`
AccessKey string `json:"accessKey" validate:"required,max=200"`
Secret string `json:"secret" validate:"max=400"`
PublicBase string `json:"publicBase" validate:"max=512"`
Limit int64 `json:"limit"`
}
type forgeBucketOut struct {
Bound bool `json:"bound"`
// Info describes the binding without its secret, the same shape a
// repository's own binding is reported in.
Info BucketInfo `json:"info"`
}
func (d Deps) registerForgeBucket(r *ops.Registry) error {
if err := ops.Register(r, ops.Op[struct{}, forgeBucketOut]{
Name: "fleet.bucket.get",
Caps: "s",
Scope: ops.ScopeFleet,
Doc: "The storage the forge supplies to every repository that has not bound its own.",
Kind: ops.Query,
Auth: ops.Admin,
Route: "GET /api/v1/fleet/bucket",
Run: func(ctx context.Context, _ struct{}) (forgeBucketOut, error) {
if d.Buckets == nil {
return forgeBucketOut{}, fmt.Errorf("this deployment has no attachment storage: %w", ops.ErrUnavailable)
}
b, ok, err := d.Buckets.Bucket(oltp.ForgeBucket)
if err != nil {
return forgeBucketOut{}, err
}
if !ok {
return forgeBucketOut{}, nil
}
info := bucketInfo("", b)
info.Forge = true
return forgeBucketOut{Bound: true, Info: info}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[forgeBucketIn, forgeBucketOut]{
Name: "fleet.bucket.set",
Caps: "s",
Scope: ops.ScopeFleet,
Doc: "Bind the storage the forge supplies. Every repository without a bucket of its own uses it, so a forge can host attachments for everybody on it. " +
"Probed before it is written, like a repository's own binding; an empty secret keeps the stored one.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/fleet/bucket",
Run: func(ctx context.Context, in forgeBucketIn) (forgeBucketOut, error) {
if d.Buckets == nil {
return forgeBucketOut{}, fmt.Errorf("this deployment has no attachment storage: %w", ops.ErrUnavailable)
}
in.Endpoint = strings.TrimRight(strings.TrimSpace(in.Endpoint), "/")
if err := checkEndpoint(in.Endpoint); err != nil {
return forgeBucketOut{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
}
if in.PublicBase != "" {
in.PublicBase = strings.TrimRight(strings.TrimSpace(in.PublicBase), "/")
if u, perr := url.Parse(in.PublicBase); perr != nil || u.Host == "" ||
(u.Scheme != "https" && u.Scheme != "http") {
return forgeBucketOut{}, fmt.Errorf("the public base must be an http(s) url: %w", ops.ErrInvalid)
}
}
if in.Region == "" {
in.Region = "auto"
}
have, bound, err := d.Buckets.Bucket(oltp.ForgeBucket)
if err != nil {
return forgeBucketOut{}, err
}
if in.Secret == "" {
if !bound {
return forgeBucketOut{}, fmt.Errorf("a first binding needs the secret: %w", ops.ErrInvalid)
}
in.Secret = have.Secret
}
next := oltp.BucketBinding{Endpoint: in.Endpoint, Bucket: in.Bucket, Region: in.Region,
AccessKey: in.AccessKey, Secret: in.Secret, PublicBase: in.PublicBase, LimitBytes: in.Limit}
if next.LimitBytes <= 0 {
next.LimitBytes = oltp.DefaultBucketLimit
}
// Probed before it is written, for the reason a repository's
// own binding is: a binding that does not work is a form
// error, not a row that fails every upload on the forge.
probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
bucket := s3.Bucket{Endpoint: next.Endpoint, Name: next.Bucket, Region: next.Region,
AccessKey: next.AccessKey, Secret: next.Secret}
if perr := bucket.Probe(probeCtx, &http.Client{Timeout: probeTimeout}, time.Now()); perr != nil {
return forgeBucketOut{}, fmt.Errorf(
"the bucket refused a probe (%v); check the endpoint, the bucket and the key's permissions: %w",
perr, ops.ErrInvalid)
}
if err := d.Buckets.BindBucket(oltp.ForgeBucket, next); err != nil {
return forgeBucketOut{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
}
d.recordProbe(probeCtx, oltp.ForgeBucket, bucket, "")
info := bucketInfo("", next)
info.Forge = true
return forgeBucketOut{Bound: true, Info: info}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[struct{}, forgeBucketOut]{
Name: "fleet.bucket.unset",
Caps: "s",
Scope: ops.ScopeFleet,
Doc: "Stop supplying storage. Repositories with a bucket of their own are unaffected; the rest lose attachments until they bind one. " +
"Nothing is deleted: the objects stay where they are and the stubs stay in the DAG.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/fleet/bucket/unset",
Run: func(ctx context.Context, _ struct{}) (forgeBucketOut, error) {
if d.Buckets == nil {
return forgeBucketOut{}, fmt.Errorf("this deployment has no attachment storage: %w", ops.ErrUnavailable)
}
if err := d.Buckets.UnbindBucket(oltp.ForgeBucket); err != nil {
return forgeBucketOut{}, err
}
return forgeBucketOut{}, nil
},
})
}