carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package release
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "testing"
12)
13
14// rgwFake is a fake S3 that reproduces the one behaviour measured against
15// Hetzner Object Storage (Ceph RGW) on 2026-08-07: If-Match on PUT is compared
16// against the UNQUOTED etag, so the quoted entity-tag form S3 itself returns
17// never matches and every compare-and-swap replace 412s.
18//
19// It is deliberately strict in both directions — a wrong etag is rejected in
20// either form — so a fix that merely stopped sending the condition would fail
21// this test rather than pass it.
22type rgwFake struct {
23 etag string // current etag, unquoted
24 body string
25}
26
27func newRGWFake(t *testing.T) (*rgwFake, string) {
28 t.Helper()
29 f := &rgwFake{}
30 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31 precondFailed := func() {
32 w.Header().Set("Content-Type", "application/xml")
33 w.WriteHeader(http.StatusPreconditionFailed)
34 _, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>` +
35 `<Error><Code>PreconditionFailed</Code><Message>etag mismatch</Message></Error>`))
36 }
37 switch r.Method {
38 case http.MethodGet:
39 if f.etag == "" {
40 w.Header().Set("Content-Type", "application/xml")
41 w.WriteHeader(http.StatusNotFound)
42 _, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>` +
43 `<Error><Code>NoSuchKey</Code><Message>absent</Message></Error>`))
44 return
45 }
46 w.Header().Set("ETag", `"`+f.etag+`"`) // quoted, exactly as S3 does
47 _, _ = w.Write([]byte(f.body))
48 case http.MethodPut:
49 raw, _ := io.ReadAll(r.Body)
50 switch {
51 case r.Header.Get("If-None-Match") == "*":
52 if f.etag != "" {
53 precondFailed() // create-only against an existing object
54 return
55 }
56 case r.Header.Get("If-Match") != "":
57 got := r.Header.Get("If-Match")
58 // THE QUIRK: RGW compares against the unquoted etag, so a
59 // correctly-quoted If-Match never matches.
60 if got != f.etag {
61 precondFailed()
62 return
63 }
64 }
65 f.body = string(raw)
66 f.etag = fmt.Sprintf("%08x", len(f.body)*2654435761)
67 w.Header().Set("ETag", `"`+f.etag+`"`)
68 w.WriteHeader(http.StatusOK)
69 default:
70 w.WriteHeader(http.StatusMethodNotAllowed)
71 }
72 }))
73 t.Cleanup(srv.Close)
74 return f, srv.URL
75}
76
77func rgwEnv(t *testing.T, url string) {
78 t.Helper()
79 t.Setenv("AWS_ACCESS_KEY_ID", "k")
80 t.Setenv("AWS_SECRET_ACCESS_KEY", "s")
81 t.Setenv("AWS_SESSION_TOKEN", "")
82 t.Setenv("AWS_REGION", "nbg1")
83 t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
84 t.Setenv("CARLOS_S3_ENDPOINT", url)
85}
86
87// TestS3Store_CASReplaceAgainstRGW is the regression test for the
88// compare-and-swap failure that made the private CARLOS box report itself
89// UNREACHABLE: a single writer lost CAS 20/20 times against Hetzner because
90// every replace sent a quoted If-Match.
91//
92// The subtests are a matched pair — the quirk off case must FAIL, or the fix
93// is not what is making the on case pass.
94func TestS3Store_CASReplaceAgainstRGW(t *testing.T) {
95 ctx := context.Background()
96
97 replaceOnce := func(t *testing.T) error {
98 t.Helper()
99 _, url := newRGWFake(t)
100 rgwEnv(t, url)
101 s, err := NewS3Store(ctx, "b")
102 if err != nil {
103 t.Fatalf("NewS3Store: %v", err)
104 }
105 // Create-only first: this path uses If-None-Match:* and works on RGW,
106 // which is exactly why a fresh deployment looks healthy.
107 if _, err := s.Put(ctx, "status/boxes/x.json", []byte(`{"n":0}`), ""); err != nil {
108 t.Fatalf("create-only Put should succeed even on RGW: %v", err)
109 }
110 _, etag, err := s.Get(ctx, "status/boxes/x.json")
111 if err != nil {
112 t.Fatalf("Get: %v", err)
113 }
114 if !strings.HasPrefix(etag, `"`) {
115 t.Fatalf("fake should hand back a quoted etag like S3 does, got %q", etag)
116 }
117 // The replace — the operation that fails in production.
118 _, err = s.Put(ctx, "status/boxes/x.json", []byte(`{"n":1}`), etag)
119 return err
120 }
121
122 t.Run("quirk off: reproduces the production failure", func(t *testing.T) {
123 t.Setenv("CARLOS_S3_QUIRKS", "")
124 err := replaceOnce(t)
125 if !errors.Is(err, ErrCASConflict) {
126 t.Fatalf("want ErrCASConflict without the quirk (this is the bug), got %v", err)
127 }
128 })
129
130 t.Run("quirk on: replace succeeds", func(t *testing.T) {
131 // The fake's host is 127.0.0.1, so match-by-hostname cannot fire;
132 // force by name, the same way an operator would on a new store.
133 t.Setenv("CARLOS_S3_QUIRKS", "unquoted-if-match")
134 if err := replaceOnce(t); err != nil {
135 t.Fatalf("replace should succeed with the unquoted-if-match quirk: %v", err)
136 }
137 })
138}
139
140// TestS3Store_IfMatchOnDeletedObjectIsCASConflict: a replace-only write whose
141// object was DELETED under it comes back 404/NoSuchKey rather than 412 —
142// there is no etag left to fail the precondition against. It is still a lost
143// conditional write (the caller decided this body from bytes that no longer
144// exist), and classifying it as anything else surfaces as a 502 "could not
145// update, try again" that will never succeed, on a door that already knows how
146// to answer a conflict. Found reviewing the instance-record PATCH, 2026-08-12.
147//
148// The create-only half is the guard on the guard: on an If-None-Match:* write
149// a 404 is not an expected answer at all, and masking it into a conflict would
150// hide a broken store behind a retry-shaped error.
151func TestS3Store_IfMatchOnDeletedObjectIsCASConflict(t *testing.T) {
152 ctx := context.Background()
153 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
154 if r.Method != http.MethodPut {
155 w.WriteHeader(http.StatusMethodNotAllowed)
156 return
157 }
158 w.Header().Set("Content-Type", "application/xml")
159 w.WriteHeader(http.StatusNotFound)
160 _, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>` +
161 `<Error><Code>NoSuchKey</Code><Message>the object was deleted</Message></Error>`))
162 }))
163 t.Cleanup(srv.Close)
164 rgwEnv(t, srv.URL)
165 s, err := NewS3Store(ctx, "b")
166 if err != nil {
167 t.Fatalf("NewS3Store: %v", err)
168 }
169
170 _, err = s.Put(ctx, "apps/bab/titogo/instances/ha.go.tito.io.json", []byte(`{"schema":1}`), `"abc123"`)
171 if !errors.Is(err, ErrCASConflict) {
172 t.Fatalf("If-Match Put against a deleted object = %v, want ErrCASConflict", err)
173 }
174 if _, err := s.Put(ctx, "apps/bab/titogo/instances/ha.go.tito.io.json", []byte(`{"schema":1}`), ""); errors.Is(err, ErrCASConflict) {
175 t.Fatalf("create-only Put reported a 404 as a CAS conflict: %v", err)
176 } else if err == nil {
177 t.Fatal("create-only Put against a 404-ing store returned no error at all")
178 }
179}
180
181// TestS3Store_CASStillRejectsAStaleEtag is the safety half. Stripping quotes
182// must FIX compare-and-swap, not remove it: a genuinely stale etag has to keep
183// losing, or every CAS in CARLOS silently becomes a blind overwrite.
184func TestS3Store_CASStillRejectsAStaleEtag(t *testing.T) {
185 ctx := context.Background()
186 _, url := newRGWFake(t)
187 rgwEnv(t, url)
188 t.Setenv("CARLOS_S3_QUIRKS", "unquoted-if-match")
189
190 s, err := NewS3Store(ctx, "b")
191 if err != nil {
192 t.Fatalf("NewS3Store: %v", err)
193 }
194 key := "status/boxes/y.json"
195 if _, err := s.Put(ctx, key, []byte(`{"n":0}`), ""); err != nil {
196 t.Fatalf("seed Put: %v", err)
197 }
198 _, stale, err := s.Get(ctx, key)
199 if err != nil {
200 t.Fatalf("Get: %v", err)
201 }
202 // Someone else moves the object on.
203 if _, err := s.Put(ctx, key, []byte(`{"n":1,"other":true}`), stale); err != nil {
204 t.Fatalf("first replace: %v", err)
205 }
206 // Our now-stale etag must lose.
207 if _, err := s.Put(ctx, key, []byte(`{"n":2}`), stale); !errors.Is(err, ErrCASConflict) {
208 t.Fatalf("a stale etag must still lose CAS, got %v — the quirk has disabled compare-and-swap", err)
209 }
210}
211