2026-06-11 23:40:26 -07:00
|
|
|
// Package remote abstracts the cloud object store a volume syncs through.
|
2026-07-07 15:02:40 -07:00
|
|
|
// beardrive is provider-agnostic: any backend that can put/get/list immutable
|
2026-06-11 23:40:26 -07:00
|
|
|
// objects works. Built-in schemes:
|
|
|
|
|
//
|
|
|
|
|
// file:///abs/path local or network-drive directory (also used in tests)
|
|
|
|
|
// s3://bucket/prefix Amazon S3 (or S3-compatible via AWS_ENDPOINT_URL)
|
|
|
|
|
// gs://bucket/prefix Google Cloud Storage
|
2026-07-08 07:13:00 -07:00
|
|
|
// https://host:4173 a bdrive web server brokering one of the above —
|
|
|
|
|
// the device needs no storage credentials at all
|
2026-06-11 23:40:26 -07:00
|
|
|
//
|
|
|
|
|
// Remote layout: blobs/<sha256> for content, journal/<device>.jsonl for op
|
|
|
|
|
// logs. Each device writes only its own journal, so there are no concurrent
|
|
|
|
|
// writers per object and no server-side coordination is needed.
|
|
|
|
|
package remote
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-07-27 10:41:57 +09:00
|
|
|
"errors"
|
2026-06-11 23:40:26 -07:00
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
2026-07-08 07:13:00 -07:00
|
|
|
"time"
|
2026-06-11 23:40:26 -07:00
|
|
|
)
|
|
|
|
|
|
2026-07-27 10:41:57 +09:00
|
|
|
// ErrForbidden marks a refusal by the hub's authorization — the device asked
|
|
|
|
|
// correctly and was told no, which is a different thing from being offline.
|
|
|
|
|
// The syncer keys its degraded states off it: a refused push means read-only
|
|
|
|
|
// (keep pulling), a refused pull means access is gone (pause, touch nothing).
|
|
|
|
|
var ErrForbidden = errors.New("forbidden")
|
|
|
|
|
|
2026-06-11 23:40:26 -07:00
|
|
|
type Object struct {
|
|
|
|
|
Key string
|
|
|
|
|
Size int64
|
2026-08-03 16:20:51 +09:00
|
|
|
// Modified is when the store last wrote this object, when the backend
|
|
|
|
|
// reports it (S3, GCS) and the zero time when it does not. It is how the
|
|
|
|
|
// hub decides an object can no longer change — see RemoteSource.verify.
|
|
|
|
|
Modified time.Time
|
2026-06-11 23:40:26 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-08 07:13:00 -07:00
|
|
|
// SignedPut is a presigned direct-upload request: whoever holds the URL can
|
|
|
|
|
// PUT that one object until Expires, without ever seeing storage credentials.
|
|
|
|
|
type SignedPut struct {
|
|
|
|
|
URL string // upload here
|
|
|
|
|
Method string // always "PUT"
|
|
|
|
|
Headers map[string]string // headers that must be sent verbatim (they are signed)
|
|
|
|
|
Expires time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PutSigner is implemented by backends that can mint presigned upload URLs
|
|
|
|
|
// so clients write to storage directly. Backends without that capability
|
|
|
|
|
// (file://) simply don't implement it, and callers fall back to uploading
|
|
|
|
|
// through the server.
|
|
|
|
|
type PutSigner interface {
|
|
|
|
|
SignPut(ctx context.Context, key string, size int64, ttl time.Duration) (*SignedPut, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 23:40:26 -07:00
|
|
|
type Backend interface {
|
|
|
|
|
Put(ctx context.Context, key string, r io.Reader, size int64) error
|
|
|
|
|
Get(ctx context.Context, key string) (io.ReadCloser, error)
|
|
|
|
|
List(ctx context.Context, prefix string) ([]Object, error)
|
|
|
|
|
Exists(ctx context.Context, key string) (bool, error)
|
|
|
|
|
Close() error
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 14:49:02 -07:00
|
|
|
// ReadEvent is one agent file read reported to the hub for its read heatmap.
|
|
|
|
|
type ReadEvent struct {
|
2026-08-11 04:18:53 +09:00
|
|
|
Path string `json:"path"`
|
|
|
|
|
// Session is the agent session the read happened in, so the hub can join
|
|
|
|
|
// a run's reads to the writes journal.Op.Session carries. A client string
|
|
|
|
|
// — the hub pins each recorded row to the device it validated, never to
|
|
|
|
|
// anything in this body (see handleReadReport).
|
|
|
|
|
Session string `json:"session,omitempty"`
|
|
|
|
|
Time time.Time `json:"time,omitzero"`
|
2026-07-11 14:49:02 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ReadReporter is the optional read-telemetry capability, in the PutSigner
|
|
|
|
|
// mold: backends that sync through a hub report the device's agent reads so
|
|
|
|
|
// the heat view can split human from agent traffic. Object-store backends
|
|
|
|
|
// simply don't implement it — there is no hub to tell.
|
|
|
|
|
type ReadReporter interface {
|
|
|
|
|
ReportReads(ctx context.Context, reads []ReadEvent) error
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 23:40:26 -07:00
|
|
|
// Open creates a backend from a remote URL.
|
|
|
|
|
func Open(ctx context.Context, raw string) (Backend, error) {
|
|
|
|
|
u, err := url.Parse(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid remote %q: %w", raw, err)
|
|
|
|
|
}
|
|
|
|
|
switch u.Scheme {
|
|
|
|
|
case "file":
|
|
|
|
|
return newLocal(u.Path)
|
|
|
|
|
case "s3":
|
|
|
|
|
return newS3(ctx, u.Host, strings.Trim(u.Path, "/"))
|
|
|
|
|
case "gs":
|
|
|
|
|
return newGCS(ctx, u.Host, strings.Trim(u.Path, "/"))
|
2026-07-08 07:13:00 -07:00
|
|
|
case "http", "https":
|
|
|
|
|
return newHTTPBackend(raw)
|
2026-06-11 23:40:26 -07:00
|
|
|
default:
|
2026-07-08 07:13:00 -07:00
|
|
|
return nil, fmt.Errorf("unsupported remote scheme %q (supported: file://, s3://, gs://, https://)", u.Scheme)
|
2026-06-11 23:40:26 -07:00
|
|
|
}
|
|
|
|
|
}
|