package lease
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/benbjohnson/litestream"
)
// casS3 is one key's worth of S3 with conditional writes: If-None-Match: *
// refuses an existing object, If-Match refuses another etag, with 412.
type casS3 struct {
mu sync.Mutex
reqs []string
body []byte
etag string
n int
puts []http.Header
}
func newCasS3(t *testing.T) (*casS3, string) {
t.Helper()
f := &casS3{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.reqs = append(f.reqs, r.Method)
fail := func() {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusPreconditionFailed)
_, _ = w.Write([]byte(`PreconditionFailedprecondition`))
}
switch r.Method {
case http.MethodGet:
if f.body == nil {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`NoSuchKeyno such key`))
return
}
w.Header().Set("ETag", f.etag)
_, _ = w.Write(f.body)
case http.MethodPut:
f.puts = append(f.puts, r.Header.Clone())
if r.Header.Get("If-None-Match") == "*" && f.body != nil {
fail()
return
}
if m := r.Header.Get("If-Match"); m != "" && strings.Trim(m, `"`) != strings.Trim(f.etag, `"`) {
fail()
return
}
b, _ := io.ReadAll(r.Body)
f.n++
f.body, f.etag = b, fmt.Sprintf(`"etag-%d"`, f.n)
w.Header().Set("ETag", f.etag)
case http.MethodDelete:
if m := r.Header.Get("If-Match"); m != "" && strings.Trim(m, `"`) != strings.Trim(f.etag, `"`) {
fail()
return
}
f.body, f.etag = nil, ""
w.WriteHeader(http.StatusNoContent)
}
}))
t.Cleanup(srv.Close)
return f, srv.URL
}
func (f *casS3) set(t *testing.T, l litestream.Lease) {
t.Helper()
raw, err := json.Marshal(l)
if err != nil {
t.Fatal(err)
}
f.mu.Lock()
defer f.mu.Unlock()
f.n++
f.body, f.etag = raw, fmt.Sprintf(`"etag-%d"`, f.n)
}
func (f *casS3) methods() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.reqs...)
}
func (f *casS3) lease(t *testing.T) litestream.Lease {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
var l litestream.Lease
if err := json.Unmarshal(f.body, &l); err != nil {
t.Fatalf("lock %q: %v", f.body, err)
}
return l
}
func casEnv(t *testing.T) (*casS3, context.Context) {
t.Helper()
f, url := newCasS3(t)
fakeCreds(t)
t.Setenv("CARLOS_S3_ENDPOINT", url)
return f, context.Background()
}
// A released lease deletes its lock, so the next wake finds none: it
// writes the lease create-only in one round trip, the same lease
// litestream writes for an absent lock (generation 1, this owner).
func TestAcquireAnAbsentLockIsOneCreateOnlyWrite(t *testing.T) {
f, ctx := casEnv(t)
held, err := Acquire(ctx, "b", "inst/x.example.com", "eu-west-1")
if err != nil {
t.Fatal(err)
}
if got := f.methods(); len(got) != 1 || got[0] != http.MethodPut {
t.Fatalf("requests = %v, want one PUT", got)
}
if h := f.puts[0]; h.Get("If-None-Match") != "*" || h.Get("If-Match") != "" {
t.Fatalf("the write was not create-only: %v", h)
}
l := f.lease(t)
if l.Generation != 1 || l.Owner == "" || time.Until(l.ExpiresAt) < LeaseTTL-time.Minute {
t.Fatalf("lease = %+v, want generation 1, an owner and a full TTL", l)
}
// The handle carries the etag its write returned: release is
// conditional on it, so it deletes this lock and no other.
if err := held.Release(ctx); err != nil {
t.Fatal(err)
}
if got := f.methods(); got[len(got)-1] != http.MethodDelete {
t.Fatalf("requests = %v, want the release's DELETE", got)
}
}
// An expired lock is taken over as litestream takes it over: read, then a
// conditional write of the next generation.
func TestAcquireAnExpiredLockFallsBackToTheTakeover(t *testing.T) {
f, ctx := casEnv(t)
f.set(t, litestream.Lease{Generation: 4, ExpiresAt: time.Now().Add(-time.Minute), Owner: "gone:1"})
held, err := Acquire(ctx, "b", "inst/x.example.com", "eu-west-1")
if err != nil {
t.Fatal(err)
}
defer held.Release(ctx)
if l := f.lease(t); l.Generation != 5 || l.Owner == "gone:1" {
t.Fatalf("lease = %+v, want generation 5 under this owner", l)
}
}
// A live lock is someone else's: refused with its holder, as before, and
// left exactly as it was.
func TestAcquireALiveLockIsRefused(t *testing.T) {
f, ctx := casEnv(t)
f.set(t, litestream.Lease{Generation: 2, ExpiresAt: time.Now().Add(10 * time.Minute), Owner: "other:7"})
before := f.lease(t)
_, err := Acquire(ctx, "b", "inst/x.example.com", "eu-west-1")
var held *HeldError
if !errors.As(err, &held) || held.Owner != "other:7" {
t.Fatalf("Acquire = %v, want a HeldError naming other:7", err)
}
if after := f.lease(t); after != before {
t.Fatalf("lock changed: %+v -> %+v", before, after)
}
}