// Package predastore is an S3-compatible object store: a Raft-replicated // metadata plane, erasure-coded shard storage, and an S3 gate in front of // both. It is the module's whole public surface — Config and LoadConfig for // the on-disk configuration, Options and Run to serve a host until the context // is cancelled. // // One process runs one host: the cluster nodes pinned to it in the // configuration, the S3 gate among them. Those nodes talk over an // in-process pipe; nodes on other hosts are reached over QUIC. A cluster whose // nodes all sit on one host is therefore a single-process deployment, with no // code path of its own. package predastore import ( "context" "errors" "fmt" "io" "net" "os" "slices" "strconv" "sync" "sync/atomic" "time" "github.com/mulgadc/bluebottle/pkg/masterkey" "github.com/mulgadc/predastore/internal/admin" "github.com/mulgadc/predastore/internal/blob" "github.com/mulgadc/predastore/internal/config" "github.com/mulgadc/predastore/internal/gate" "github.com/mulgadc/predastore/internal/gate/repair" "github.com/mulgadc/predastore/internal/meta" "github.com/mulgadc/predastore/internal/rpc" "github.com/mulgadc/predastore/internal/transport" "golang.org/x/sync/errgroup" ) // Options is everything a predastore process needs that does not come from the // configuration file: which host to run and the key that protects data at // rest. type Options struct { // Config is the parsed configuration file. Required. Config *Config // HostID is the [[host]] this process runs. It selects the nodes pinned to // that host and the addresses they are reached on. Required. HostID HostID // MasterKey is the AES-256 key shards are encrypted with at rest. // Required: predastore never writes plaintext shards. MasterKey *masterkey.Key } // leaderBarrier holds the gate off until local consensus settles: serving // before it does would fail writes that would have succeeded a moment later. // Local meta replicas open it, and a host running none starts it open. type leaderBarrier struct { open func() wait <-chan struct{} } func newLeaderBarrier() leaderBarrier { open := make(chan struct{}) return leaderBarrier{open: sync.OnceFunc(func() { close(open) }), wait: open} } // Run serves one host until ctx is cancelled or one of its nodes fails, then // drains everything it started. Every node shares the one context, so a single // signal stops the lot; there is nothing else to stop it with. func Run(ctx context.Context, opts Options) error { if opts.Config == nil { return fmt.Errorf("Options.Config is required") } if opts.MasterKey == nil { return fmt.Errorf("Options.MasterKey is required") } cfg := opts.Config host, ok := hostOf(cfg, opts.HostID) if !ok { return fmt.Errorf("host %d is not in the configuration", opts.HostID) } if len(host.Nodes) == 0 { return fmt.Errorf("host %d runs no nodes", opts.HostID) } // A node that names no directory of its own derives one under the host // root, and an empty root derives a relative path under whatever directory // the process was started in. if host.DataDir == "" && slices.ContainsFunc(host.Nodes, func(n NodeConfig) bool { return n.Role != RoleGate && n.DataDir == "" }) { return fmt.Errorf("host %d has no data directory", opts.HostID) } if err := checkDataDirs(host); err != nil { return err } // A host with no replica of its own has no local consensus to wait on, so // its gate serves immediately. barrier := newLeaderBarrier() if !slices.ContainsFunc(host.Nodes, func(n NodeConfig) bool { return n.Role == RoleMeta }) { barrier.open() } // Every node is built before any of them starts. A node that dialed a // colocated peer first would otherwise find no listener registered for it. runs := make([]func(context.Context) error, 0, len(host.Nodes)+1) cleanups := make([]func(), 0, len(host.Nodes)) var checks []admin.Check var endpoints []admin.Endpoint defer func() { for _, cleanup := range cleanups { cleanup() } }() for _, n := range host.Nodes { built, err := buildNode(cfg, host, n, opts, barrier) if err != nil { return err } runs = append(runs, built.run) cleanups = append(cleanups, built.cleanup) checks = append(checks, built.checks...) endpoints = append(endpoints, built.endpoints...) } // The admin listener binds the cluster plane, never the gate's S3 address: // health is operator traffic and that address is public by design. if host.AdminPort != 0 { addr := net.JoinHostPort(config.HostBindAddr(host), strconv.Itoa(host.AdminPort)) runs = append(runs, admin.New(addr, checks, endpoints...).Run) } g, gctx := errgroup.WithContext(ctx) for _, run := range runs { g.Go(func() error { return run(gctx) }) } return g.Wait() } // checkDataDirs proves every local node can write its directory before any // node starts, and names all that cannot. Found later, an unwritable drive // surfaces behind the readiness noise of the nodes already running. func checkDataDirs(host HostConfig) error { var errs []error for _, n := range host.Nodes { if n.Role == RoleGate { continue } dir := config.NodeDataDir(host, n) if err := probeWritable(dir); err != nil { errs = append(errs, fmt.Errorf("%s node %d data directory %s is not writable by uid %d: %w", n.Role, n.ID, dir, os.Geteuid(), err)) } } return errors.Join(errs...) } // probeWritable creates dir as its node would and writes a file in it. Mode // bits alone would miss ACLs, read-only mounts and a sandbox's view of them. func probeWritable(dir string) error { if err := os.MkdirAll(dir, 0750); err != nil { return err } f, err := os.CreateTemp(dir, ".write-probe-*") if err != nil { return err } return errors.Join(f.Close(), os.Remove(f.Name())) } // probeKey is a key nothing writes. A probe wants an answer rather than data, // and "no such object" is an answer only a working node can give. const probeKey = "predastore/readyz-probe" var probeShardKey [32]byte // writeShardFloor is how many shards a write has to place before it is // acknowledged, which is every shard unless degraded writes are on. func writeShardFloor(cfg *Config) int { if cfg.RS.DegradedWritesEnabled() { return cfg.RS.Data } return cfg.RS.Data + cfg.RS.Parity } // probeReader is the meta read a readiness probe needs, and shardProber the // blob one. Both are narrowed to what the check calls so a probe can be tested // without a cluster behind it. type probeReader interface { Get(ctx context.Context, key string) ([]byte, error) } type leaderReporter interface { LeaderKnown() bool } var _ leaderReporter = (*meta.Server)(nil) type shardProber interface { Get(ctx context.Context, node NodeID, req blob.GetRequest) (io.ReadCloser, error) } // leaderObserved fails while this replica sees no leader. Writes cannot commit // without one, so a gate in front of it would accept requests it cannot serve. // A missing replica is unready too: a probe must never take its process down. func leaderObserved(svc leaderReporter) admin.Check { return admin.Check{ Name: "meta_leader", Probe: func(context.Context) error { if svc == nil || !svc.LeaderKnown() { return errors.New("no raft leader observed") } return nil }, } } // metaReachable asks the meta plane for a key that is not there. A missing key // is the healthy answer: the read reached a replica and came back. func metaReachable(mc probeReader) admin.Check { return admin.Check{ Name: "meta_reachable", Probe: func(ctx context.Context) error { if _, err := mc.Get(ctx, probeKey); err != nil && !errors.Is(err, meta.ErrNotFound) { return err } return nil }, } } // blobNodesReachable fails when fewer blob nodes answer than a write needs // shards durable. // // The write floor, not the read one. Reads reconstruct from DataShards and so // survive losing parity, which is why a gate that cannot write at all still // answers every read it is asked for. Gating readiness on the read floor made // that gate report ready, and the only cluster-wide symptom of a store that // had stopped accepting writes was in its log. func blobNodesReachable(bc shardProber, nodes []NodeID, need int) admin.Check { return admin.Check{ Name: "blob_nodes", Probe: func(ctx context.Context) error { var reached atomic.Int64 var wg sync.WaitGroup for _, id := range nodes { wg.Go(func() { if blobAnswers(ctx, bc, id) { reached.Add(1) } }) } wg.Wait() if got := int(reached.Load()); got < need { return fmt.Errorf("%d of %d blob nodes answered, need %d", got, len(nodes), need) } return nil }, } } // blobNodeReachable names one blob node in the probe response. Advisory: the // cluster tolerates losing a node, so one being gone is not unreadiness, but // which one it is has to be answerable without reading a gate's log. func blobNodeReachable(bc shardProber, id NodeID) admin.Check { return admin.Check{ Name: fmt.Sprintf("blob_node_%d", id), Advisory: true, Probe: func(ctx context.Context) error { if !blobAnswers(ctx, bc, id) { return fmt.Errorf("blob node %d did not answer", id) } return nil }, } } // blobAnswers reports whether a blob node served a read at all. A node that // says the shard is missing has opened its store and answered, which is the // whole question. func blobAnswers(ctx context.Context, bc shardProber, id NodeID) bool { body, err := bc.Get(ctx, id, blob.GetRequest{Key: probeShardKey, RangeStart: -1, RangeEnd: -1}) if err == nil { _ = body.Close() return true } return errors.Is(err, blob.ErrNotFound) } type repairReporter interface { RepairStats() (repair.Stats, bool) } var _ repairReporter = (*gate.Server)(nil) // repairEndpoint serves the repair sweep's counters on the admin port, so what // repair has done can be read on the node and not only in the telemetry sink. func repairEndpoint(gw repairReporter) admin.Endpoint { return admin.Endpoint{ Pattern: "GET /repair", Handler: admin.JSONHandler(func() any { stats, ok := gw.RepairStats() if !ok { return map[string]any{"enabled": false} } return map[string]any{"enabled": true, "stats": stats} }), } } // builtNode is one node's lifecycle and the readiness questions it can answer. // A node contributes only what it holds a client or a replica for, so a probe // describes the process it reached rather than the cluster as a whole. type builtNode struct { run func(context.Context) error cleanup func() checks []admin.Check endpoints []admin.Endpoint } // buildNode builds one node of this host: the transports it is reached over, // the rpc plumbing around them and the service it runs. Nothing listens or // dials until run is called, and cleanup releases what run does not. func buildNode(cfg *Config, host HostConfig, n NodeConfig, opts Options, barrier leaderBarrier) ( built builtNode, err error, ) { // A gate's port is its S3 port, so its rpc sockets bind ephemerally. port := n.Port if n.Role == RoleGate { port = 0 } // The pipe carries traffic between colocated nodes and the QUIC socket // traffic to other hosts, so each exists only when the cluster puts a peer // on the other end of it. Both are the cluster plane and bind the host's // address; only the gate's S3 listener may be given a public one. var trs []transport.Transport if len(host.Nodes) > 1 { trs = append(trs, transport.NewPipeTransport(host.Addr, port)) } if hasRemoteNodes(cfg, host.ID) { quic, qerr := transport.NewQUICTransport(config.HostBindAddr(host), port, host.TLSCert, host.TLSKey) if qerr != nil { return builtNode{}, fmt.Errorf("node %d quic transport: %w", n.ID, qerr) } trs = append(trs, quic) } // pool is set by the gate, the one role that dials without running a // server of its own; closing it and the transports is all a node leaves // behind, since every service releases what it opened. var pool *rpc.ConnPool cleanup := func() { if pool != nil { _ = pool.Close() } for _, tr := range trs { _ = tr.Close() } } res, err := rpc.NewResolver(cfg, n.ID, trs...) if err != nil { cleanup() return builtNode{}, err } // Nothing dials a gate, so it listens on nothing: the S3 frontend is // its only listener. var lns []transport.Listener if n.Role != RoleGate { for _, tr := range trs { ln, lerr := tr.Listen() if lerr != nil { cleanup() return builtNode{}, fmt.Errorf("node %d listen on %s: %w", n.ID, tr.Network(), lerr) } lns = append(lns, ln) } } dir := config.NodeDataDir(host, n) var serve func(context.Context) error var checks []admin.Check var endpoints []admin.Endpoint switch n.Role { case RoleGate: // The pool is private: a gate dials but is never dialed, so it has // nothing to share one with. pool = rpc.NewConnPool(n.ID, res) cli := rpc.NewClient(pool) metaClient, cerr := meta.NewClient(meta.ClientConfig{ Client: cli, Replicas: nodeIDs(nodesByRole(cfg, RoleMeta)), }) if cerr != nil { cleanup() return builtNode{}, fmt.Errorf("create s3 gate: %w", cerr) } blobClient, cerr := blob.NewClient(blob.ClientConfig{Client: cli}) if cerr != nil { cleanup() return builtNode{}, fmt.Errorf("create s3 gate: %w", cerr) } gw, gerr := gate.New(gateConfig(cfg, host, n, metaClient, blobClient)) if gerr != nil { cleanup() return builtNode{}, fmt.Errorf("create s3 gate: %w", gerr) } serve = func(ctx context.Context) error { select { case <-barrier.wait: case <-ctx.Done(): } return gw.Run(ctx) } // The gate is the one role holding a client for each plane, so it is // where reaching them can be asked about at all. blobIDs := nodeIDs(nodesByRole(cfg, RoleBlob)) checks = append(checks, metaReachable(metaClient), blobNodesReachable(blobClient, blobIDs, writeShardFloor(cfg))) for _, id := range blobIDs { checks = append(checks, blobNodeReachable(blobClient, id)) } endpoints = append(endpoints, repairEndpoint(gw)) case RoleBlob: // The node owns its store: it creates the directory, opens the engine // and closes it again, so no failure here can leak one. svc, berr := blob.New(blob.Config{ NodeID: n.ID, DataDir: dir, AEAD: opts.MasterKey.AEAD, Compaction: time.Duration(cfg.Compaction.IntervalSeconds) * time.Second, Listeners: lns, }) if berr != nil { cleanup() return builtNode{}, fmt.Errorf("create blob node %d: %w", n.ID, berr) } serve = svc.Run case RoleMeta: // The replica owns its raft node and the pool it is both dialed // through and dials its peers with, so no failure here can leak one. // The barrier opens however the election goes: a slow one still // releases the gate rather than never serving. svc, merr := meta.New(meta.Config{ NodeID: n.ID, DataDir: dir, Peers: nodeIDs(nodesByRole(cfg, RoleMeta)), Bootstrap: true, Listeners: lns, Resolver: res, OnLeader: barrier.open, SnapshotInterval: time.Duration(cfg.Meta.SnapshotIntervalSeconds) * time.Second, SnapshotThreshold: cfg.Meta.SnapshotThreshold, TrailingLogs: cfg.Meta.TrailingLogs, }) if merr != nil { cleanup() return builtNode{}, fmt.Errorf("create meta replica %d: %w", n.ID, merr) } serve = svc.Run checks = append(checks, leaderObserved(svc)) default: cleanup() return builtNode{}, fmt.Errorf("node %d has unknown role %q", n.ID, n.Role) } run := func(ctx context.Context) error { if err := serve(ctx); err != nil { return fmt.Errorf("%s node %d: %w", n.Role, n.ID, err) } return nil } return builtNode{run: run, cleanup: cleanup, checks: checks, endpoints: endpoints}, nil }