Files
zeekayandHanzo Dev d910cd8e0c feat(sdk): explicit-destination Stream/Restore + ReplicaURL
AutoReplicate could only push one file to one env-configured prefix, and the
SDK had no restore at all — pulling a database back meant hand-building a
Replica and reaching into RestoreOptions. An app that replicates MANY files
(one per tenant) could do neither: the destination came from a process-wide
env var, so every file landed on the same prefix and overwrote the others.

Split the value (WHERE the data goes) from the policy (HOW it is protected):

  ReplicaURL(prefix) -> the env-configured destination for a prefix, "" = off
  Stream(dbPath, url) -> the running handle; Close flushes and stops
  Restore(ctx, dbPath, url) -> (false, nil) when nothing was ever replicated

AutoReplicate is now those three composed at the env prefix, so the
fail-closed age encryption rule lives in exactly ONE place (newReplica) for
the write path AND the read path. Drift there is a plaintext copy of customer
data in a bucket, which is why it is not duplicated per entry point.

Stream syncs once after opening. Without it a handle that lives less than one
SyncInterval replicates NOTHING: Close's final sync is guarded on the database
having been initialised, and initialisation otherwise waits for the monitor's
first tick. Open-write-close inside that window (an evicted tenant, a one-shot
job) silently lost everything. Proven by TestStreamRestoreRoundTrip, which
writes after Stream and closes milliseconds later.

Restore treats ErrTxNotAvailable / ErrNoSnapshots / fs.ErrNotExist as one
condition — nothing has ever been replicated here — because a cold start is
not an error.

Tests: 4 new, green. The 7 pre-existing metrics-counter failures in this
package are unchanged (verified against a pristine HEAD run).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 17:13:16 -07:00

256 lines
9.7 KiB
Go

package replicate
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/luxfi/age"
)
// AutoReplicate is the one-line SDK entry point for any Go app.
// Starts streaming E2E PQ-encrypted replication of a SQLite database to S3.
//
// Usage:
//
// stop := replicate.AutoReplicate("/app/data/data.db")
// defer stop()
//
// Reads all config from env vars:
//
// REPLICATE_S3_ENDPOINT — S3 endpoint (required, no-op if empty)
// REPLICATE_S3_BUCKET — bucket name (default: "replicate")
// REPLICATE_S3_PATH — key prefix (default: hostname)
// REPLICATE_S3_REGION — S3 region (default: "us-central1")
// REPLICATE_AGE_RECIPIENT — age public key for encryption (REQUIRED unless
// REPLICATE_ALLOW_PLAINTEXT=true; sourced from KMS)
// REPLICATE_AGE_IDENTITY — age private key for restore/decrypt
// REPLICATE_ALLOW_PLAINTEXT — opt out of mandatory encryption for
// non-sensitive local/dev targets (default: false)
// REPLICATE_SYNC_INTERVAL — WAL sync interval (default: "1s")
//
// Encryption is fail-closed: when REPLICATE_S3_ENDPOINT is set, replication
// REFUSES to start (loud log, no-op) unless a valid age recipient is configured
// or REPLICATE_ALLOW_PLAINTEXT=true is explicitly set. It never silently streams
// plaintext LTX to S3.
//
// Returns a stop function that gracefully shuts down replication.
// Returns a no-op function if REPLICATE_S3_ENDPOINT is not set or if the
// fail-closed encryption policy is violated.
//
// AutoReplicate is [Stream] at the env-configured destination. An app that
// replicates MANY files — one per tenant, say — cannot use one env-wide
// destination for all of them and calls [Stream] and [Restore] with a per-file
// URL from [ReplicaURL] instead.
func AutoReplicate(dbPath string) func() {
// The env prefix is the whole destination for this process, so an unset one
// falls back to the hostname to keep two hosts from writing one history.
prefix := os.Getenv("REPLICATE_S3_PATH")
if prefix == "" {
prefix, _ = os.Hostname()
}
replicaURL := ReplicaURL(prefix)
if replicaURL == "" {
return func() {} // replication not configured
}
db, err := Stream(dbPath, replicaURL)
if err != nil {
// Never fatal here: AutoReplicate is a side-car capability bolted onto a
// running app, and killing the app is not its call. Callers that must
// refuse to serve without durability use Stream directly and see the error.
slog.Error("replicate: not streaming", "db", dbPath, "error", err)
return func() {}
}
slog.Info("replicate: streaming",
"db", dbPath,
"url", replicaURL,
"sync", db.Replica.SyncInterval,
"encrypted", len(db.Replica.AgeRecipients) > 0,
)
return func() {
_ = db.Close(context.Background())
}
}
// ReplicaURL returns the replica URL for a remote key prefix, built from the
// REPLICATE_S3_* environment. It returns "" when REPLICATE_S3_ENDPOINT is unset
// — the one way every entry point spells "replication is not configured".
//
// An empty prefix means REPLICATE_S3_PATH. A caller that needs a sub-tree — one
// prefix per tenant file, say — passes it explicitly rather than rewriting the
// env, because the env is process-wide and a per-file destination is not.
func ReplicaURL(prefix string) string {
endpoint := os.Getenv("REPLICATE_S3_ENDPOINT")
if endpoint == "" {
return ""
}
if prefix == "" {
prefix = os.Getenv("REPLICATE_S3_PATH")
}
return fmt.Sprintf("s3://%s/%s?endpoint=%s&region=%s&force-path-style=true",
url.PathEscape(envOr("REPLICATE_S3_BUCKET", "replicate")),
url.PathEscape(prefix),
url.QueryEscape(endpoint),
url.QueryEscape(envOr("REPLICATE_S3_REGION", "us-central1")),
)
}
// Stream starts streaming the SQLite database at dbPath to replicaURL and
// returns the running handle; Close flushes a final sync and stops it.
//
// It is AutoReplicate with an explicit destination: everything except WHERE the
// data goes still comes from the REPLICATE_* env, so the fail-closed encryption
// policy is applied in one place for every caller. Unlike AutoReplicate it
// returns the reason it did not start, because a caller that picked its own
// destination is in a position to refuse to serve without it.
//
// One Stream per database FILE. A replica URL names a key prefix and its LTX
// history belongs to a single database; two databases pointed at one prefix
// overwrite each other's history.
func Stream(dbPath, replicaURL string) (*DB, error) {
r, err := newReplica(dbPath, replicaURL)
if err != nil {
return nil, err
}
db := r.DB()
db.Replica = r
if err := db.Open(); err != nil {
return nil, fmt.Errorf("replicate: open %s: %w", dbPath, err)
}
// Sync once, here, or a short-lived handle replicates NOTHING: Close's final
// sync is guarded on the database having been initialised, and initialisation
// otherwise happens on the monitor's first tick — a whole SyncInterval away.
// Open, write, Close inside that window (an evicted tenant, a one-shot job)
// silently loses everything. The first sync also makes the file durable
// immediately rather than up to an interval later.
if err := db.Sync(context.Background()); err != nil {
_ = db.Close(context.Background())
return nil, fmt.Errorf("replicate: initial sync %s: %w", dbPath, err)
}
return db, nil
}
// Restore pulls the newest replicated state at replicaURL into dbPath and
// reports whether there was anything to pull. (false, nil) means the replica
// holds nothing yet — a database that has never been replicated, which is a
// normal cold start, not an error. dbPath must not already exist: restore never
// overwrites a live local file.
//
// The fail-closed encryption policy is checked here too, so a process that could
// not have replicated this file does not quietly serve it: refusing at restore
// is refusing to take custody of data it cannot make durable.
func Restore(ctx context.Context, dbPath, replicaURL string) (bool, error) {
r, err := newReplica(dbPath, replicaURL)
if err != nil {
return false, err
}
opt := NewRestoreOptions()
opt.OutputPath = dbPath
if err := r.Restore(ctx, opt); err != nil {
// Three spellings of one condition: nothing has ever been replicated
// here. ErrTxNotAvailable is what the current restore planner returns for
// an empty destination, ErrNoSnapshots what the v3 path returns, and
// fs.ErrNotExist what the file client returns for a prefix no one has
// written to yet. A cold start is not an error.
if errors.Is(err, ErrTxNotAvailable) || errors.Is(err, ErrNoSnapshots) || errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, err
}
return true, nil
}
// newReplica connects dbPath to replicaURL, applying the REPLICATE_* policy: S3
// credentials, sync interval, and the fail-closed age encryption rule. Stream
// and Restore both go through it so that rule cannot drift between the write
// path and the read path — a drift whose failure mode is a plaintext copy of
// customer data sitting in a bucket.
//
// It performs no file or network I/O, so building a replica is also how a caller
// validates its configuration before it opens anything.
func newReplica(dbPath, replicaURL string) (*Replica, error) {
if dbPath == "" {
return nil, errors.New("replicate: database path required")
}
if replicaURL == "" {
return nil, errors.New("replicate: replica URL required")
}
// The S3 client reads AWS_*; REPLICATE_S3_* is what hanzo services set.
if ak := os.Getenv("REPLICATE_S3_ACCESS_KEY"); ak != "" {
os.Setenv("AWS_ACCESS_KEY_ID", ak)
}
if sk := os.Getenv("REPLICATE_S3_SECRET_KEY"); sk != "" {
os.Setenv("AWS_SECRET_ACCESS_KEY", sk)
}
client, err := NewReplicaClientFromURL(replicaURL)
if err != nil {
return nil, fmt.Errorf("replicate: bad replica URL %q: %w", replicaURL, err)
}
r := NewReplicaWithClient(NewDB(dbPath), client)
r.SyncInterval = parseDur("REPLICATE_SYNC_INTERVAL", DefaultSyncInterval)
// Fail-closed encryption policy. By default replicate REFUSES to stream
// plaintext: an age recipient (public key, sourced from the KMS-synced
// REPLICATE_AGE_RECIPIENT) is mandatory. Set REPLICATE_ALLOW_PLAINTEXT=true
// ONLY for non-sensitive local/dev targets.
r.RequireEncryption = !boolEnv("REPLICATE_ALLOW_PLAINTEXT")
if s := os.Getenv("REPLICATE_AGE_RECIPIENT"); s != "" {
rcs, err := age.ParseRecipients(strings.NewReader(s))
if err != nil {
// A malformed recipient must never silently downgrade to plaintext.
return nil, fmt.Errorf("replicate: invalid REPLICATE_AGE_RECIPIENT (fix the KMS age recipient, or set REPLICATE_ALLOW_PLAINTEXT=true for non-sensitive data): %w", err)
}
r.AgeRecipients = rcs
}
if s := os.Getenv("REPLICATE_AGE_IDENTITY"); s != "" {
ids, err := age.ParseIdentities(strings.NewReader(s))
if err != nil {
return nil, fmt.Errorf("replicate: invalid REPLICATE_AGE_IDENTITY (fix the KMS age identity): %w", err)
}
r.AgeIdentities = ids
}
if r.RequireEncryption && len(r.AgeRecipients) == 0 {
return nil, fmt.Errorf("replicate: REPLICATE_AGE_RECIPIENT is not set — refusing to stream plaintext to %s (wire the KMS-synced age recipient, or set REPLICATE_ALLOW_PLAINTEXT=true for non-sensitive data)", replicaURL)
}
return r, nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// boolEnv parses a boolean environment variable, returning false when unset or
// unparseable. Used for the fail-closed REPLICATE_ALLOW_PLAINTEXT opt-out.
func boolEnv(key string) bool {
v, _ := strconv.ParseBool(os.Getenv(key))
return v
}
func parseDur(envKey string, fallback time.Duration) time.Duration {
if v := os.Getenv(envKey); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return fallback
}