package lease // CARLOS_S3_ENDPOINT support for the hibernation lease. // // release.NewS3Store has honoured CARLOS_S3_ENDPOINT since the first // S3-compatible deployment; this package did not, and every S3 client it // builds dialled real AWS regardless. That gap is not cosmetic: an activator // route is minted with Hibernate:true, the activator only arms when // CARLOS_HIBERNATE_BUCKET is set, and arming means acquiring the lease here. // On a non-AWS store (Hetzner Object Storage, R2, MinIO) the acquire could // never succeed, so the activator stayed nil and an exec-backed route with // nothing to spawn it 502'd forever — which took `carlos instances create`, // the whole console-driven provisioning path, down with it. The same clients // back the agent's status-publisher election (internal/host/agent.go), so the // override has to reach all three construction sites, not just the one. import ( "context" "errors" "fmt" "os" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" awss3 "github.com/aws/aws-sdk-go-v2/service/s3" lss3 "github.com/benbjohnson/litestream/s3" "github.com/carlosframework/platform/internal/rootaws" "github.com/carlosframework/platform/internal/s3compat" ) // s3Endpoint reports the CARLOS_S3_ENDPOINT override, or "" for stock AWS. // Delegated to s3compat so there is exactly one reader of that variable. func s3Endpoint() string { return s3compat.Endpoint() } // resolveRegion decides the region every client in this package signs for, in // strict order of authority: // // 1. the caller's explicit region (CARLOS_HIBERNATE_REGION), if any; // 2. whatever the AWS default chain yields — AWS_REGION, or a profile. This // step is why the resolution cannot be a pure function of the argument: a // compatible-store deployment sets AWS_REGION to its own location (the // private CARLOS uses nbg1), and that is the region release.NewS3Store // already signs for against the same bucket. Substituting a constant here // would make the lease sign differently from every other S3 client in the // process, against a store that may well check; // 3. only if BOTH are empty and an endpoint override is in play, litestream's // non-AWS default — better a working guess than a client signing for no // region at all. // // Step 3 is the one an override adds. Without it the AWS chain is authoritative // and an empty result is fine: stock AWS resolves the region per-request from // the bucket's own endpoint, which a compatible store cannot do. // // The resolved value is returned, not just applied, because it has to travel: // litestream's replica client does its OWN region defaulting (Init substitutes // the same non-AWS constant for an empty Region), so handing it the resolved // region is what stops the two halves of one lease — lock.json and the LTX // files beside it under the same prefix — from signing differently. func resolveRegion(ctx context.Context, region string) (string, error) { if region != "" { return region, nil // no config load needed: step 1 already decided } cfg, err := rootaws.LoadDefaultConfig(ctx) if err != nil { return "", fmt.Errorf("load aws config: %w", err) } return regionFor(region, cfg.Region), nil } // regionFor is resolveRegion's decision with the chain lookup already done — // the shared rule, so the leaser's path (which loads a config anyway, and must // not load a second one just to re-ask the same question) and Restore's cannot // drift apart. func regionFor(explicit, chainResolved string) string { if explicit != "" { return explicit } if chainResolved == "" && s3Endpoint() != "" { return lss3.DefaultRegion } return chainResolved } // newLeaserClient builds the S3 client the litestream leaser writes lock.json // with. Unlike the replica client, which builds its own from its Endpoint // field, the leaser takes a ready-made client (lss3.Leaser.SetClient) — so the // override has to be applied right here or the lock lands on AWS while the // data it guards lands on the compatible store. // // The override, path-style addressing, when-required checksums and every // per-provider quirk come from internal/s3compat, which is a no-op on stock // AWS — so the flagship's path stays byte-identical while this client picks up // the unquoted-If-Match fix that litestream's leaser needs to renew a lease on // Ceph RGW. Passing that client to lss3.Leaser.SetClient is the ONLY way the // fix reaches litestream, which has no provider handling in its leaser at all. // // It returns the region it resolved alongside the client, so AcquireTTL can // carry that exact value onto the Held for Replicate's replica client rather // than letting litestream substitute a different default of its own. func newLeaserClient(ctx context.Context, region string) (*awss3.Client, string, error) { var opts []func(*awsconfig.LoadOptions) error if region != "" { opts = append(opts, awsconfig.WithRegion(region)) } cfg, err := rootaws.LoadDefaultConfig(ctx, opts...) if err != nil { return nil, "", fmt.Errorf("load aws config: %w", err) } cfg.Region = regionFor(region, cfg.Region) return awss3.NewFromConfig(cfg, s3compat.Options()), cfg.Region, nil } // newReplicaClient builds litestream's replica client for the LTX files at // s3://bucket/prefix. litestream reads its OWN env var (LITESTREAM_S3_ENDPOINT) // only on the URL-parsing path, which this package never takes — it sets the // fields directly — so the override is applied here too. Endpoint/ForcePathStyle // is all it takes: litestream's Init derives the rest (the checksum knobs and // the non-AWS default region) from a non-empty Endpoint on its own, and the // signed-payload and content-MD5 defaults NewReplicaClient already ships are // the ones every compatible in litestream's provider table wants. func newReplicaClient(bucket, prefix, region string) (*lss3.ReplicaClient, error) { return newReplicaClientForUID(bucket, prefix, region, os.Geteuid(), rootaws.OffcloudCredentials) } var errRootReplicaSessionToken = errors.New("root Litestream cannot use a session token from /etc/carlos/credentials. set the keys in the environment instead, or use an EC2 instance profile") func newReplicaClientForUID(bucket, prefix, region string, euid int, offcloud func() (aws.Credentials, error)) (*lss3.ReplicaClient, error) { c := lss3.NewReplicaClient() if euid == 0 { creds, err := offcloud() if err != nil { return nil, err } if creds.SessionToken != "" { return nil, errRootReplicaSessionToken } c.AccessKeyID = creds.AccessKeyID c.SecretAccessKey = creds.SecretAccessKey } c.Bucket = bucket c.Path = prefix c.Region = region if ep := s3Endpoint(); ep != "" { c.Endpoint = ep c.ForcePathStyle = true } return c, nil } // ReadReplicaClient is newReplicaClient for READERS outside this package — // the console's db door (spec 2026-09-02 §6). Identical construction on // purpose, region resolution included (Restore's resolveRegion): a second // hand-rolled client is how the endpoint override, the path-style flag and // the signing region drift apart between the writer and the reader of the // same bucket. // // It returns the same type the writer uses, so nothing here can enforce // read-only by construction; that guarantee lives in internal/dbdoor's // client, which has no write methods at all, and in the door only ever // calling LTXFiles and OpenLTXFile on what this returns. func ReadReplicaClient(ctx context.Context, bucket, prefix, region string) (*lss3.ReplicaClient, error) { region, err := resolveRegion(ctx, region) if err != nil { return nil, err } return newReplicaClient(bucket, prefix, region) }