Skip to content

Commit a8bc4ed

Browse files
Andrey Kolkovclaude
andcommitted
fix(agent): set WhenRequired on the transfer manager too, not just the client
Review of #342 found the client-only option never reached the code path that uploads real snapshots. manager.Uploader carries its OWN RequestChecksumCalculation (NewUploader hardcodes WhenSupported) and never reads the s3.Client option, so any snapshot over the manager's 5 MiB part size — every real etcd snapshot — took the multipart branch and re-stamped the CRC32 STREAMING-UNSIGNED-PAYLOAD-TRAILER that Ceph RGW rejects. The previous fix only covered the degenerate sub-5-MiB single-part case. - Derive both knobs from one package-level constant (snapshotChecksumCalculation) and set it on the uploader via newSnapshotUploader as well as the client, so the two independent settings cannot silently drift. - Add TestUploadS3StreamMultipartNoChecksumTrailer: drives the real uploadS3Stream against a TLS httptest server (trusted via AWS_CA_BUNDLE) with a >5 MiB body and asserts no UploadPart carries a checksum trailer/algorithm. Fails without the uploader option; a check on s3.Options alone stays green while the bug is live. - Make TestS3ClientChecksumWhenRequired hermetic (AWS_CONFIG_FILE/… = /dev/null, empty AWS_PROFILE) and assert the response-side setting; keep it as a client-option unit check only. - Set ResponseChecksumValidation=WhenRequired symmetrically for the restore download path (same mismatch class; defensive, the downloader sets no ChecksumMode today). - Rewrite the agent.go comment that wrongly claimed a plain PutObject, and fix the stale "temp file it then multipart-uploads" sentence in docs/concepts.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Andrey Kolkov <andrey.kolkov@aenix.io>
1 parent 61144d4 commit a8bc4ed

6 files changed

Lines changed: 198 additions & 17 deletions

File tree

docs/concepts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ Two surfaces, one agent. The operator image doubles as a snapshot agent: `main.g
449449
An `EtcdSnapshot` is a one-shot record. The controller resolves `spec.clusterRef`, builds a Job (owned by the `EtcdSnapshot`, with `ttlSecondsAfterFinished` so it self-GCs, `automountServiceAccountToken: false`, and a restricted security context), and tracks `status.phase` through `Pending → Started → Complete | Failed`. The Job's Pod runs the snapshot agent, which:
450450

451451
1. dials the cluster's client Service — TLS material (server CA + operator-client cert) is mounted from the same Secrets the operator dials with, and when `status.authEnabled` is latched the agent authenticates as `root` using the password from `spec.auth.rootCredentialsSecretRef`;
452-
2. streams `clientv3` `Maintenance.Snapshot` to a local file (PVC destination) or a temp file it then multipart-uploads to S3, hashing (sha256) and counting bytes as it goes;
452+
2. streams `clientv3` `Maintenance.Snapshot` to a local file (PVC destination) or directly to S3 via the transfer manager — no local staging, multipart above the manager's 5 MiB part size — hashing (sha256) and counting bytes as it goes;
453453
3. prints a marker line — `snapshot uploaded: uri="..." size=N sha256=<hex>` — that the controller scans out of the Pod log (via an uncached `APIReader` to find the Pod and the typed Clientset to read its log) to populate `status.artifact{uri,sizeBytes,checksum}`.
454454

455455
Why parse a log line rather than have the agent write status? The agent has no Kubernetes API access by design (`automountServiceAccountToken: false`), so the controller — which does — is the one that records the result. The marker is the agent→controller channel. Terminal phases are sticky: a `Complete`/`Failed` snapshot is a historical record and never re-runs.

internal/agent/agent.go

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,37 @@ func (d destination) localPath(name string) string {
148148
return base + "/" + name + ".db"
149149
}
150150

151+
// snapshotChecksumCalculation is the S3 request-checksum policy the agent applies
152+
// on the upload path. It has to be set in TWO independent places, because the
153+
// snapshot upload touches two SDK layers that each carry their own copy of this
154+
// knob and do NOT share it:
155+
//
156+
// - the s3.Client option (s3Client, below) — covers the single-part PutObject
157+
// the transfer manager issues for a sub-5-MiB body, plus HeadObject;
158+
// - the transfer manager's own Uploader.RequestChecksumCalculation
159+
// (newSnapshotUploader in snapshot.go) — covers the multipart parts the
160+
// manager issues for a body over its 5-MiB part size, which is every real
161+
// etcd snapshot. manager.NewUploader hardcodes this field to WhenSupported
162+
// and never reads the client option, so setting only the client option
163+
// leaves the multipart path — the one that matters — broken.
164+
//
165+
// The two are separate knobs that MUST agree; deriving both from this single
166+
// constant is what keeps them from silently drifting (delete either site and the
167+
// failure returns — for the multipart case, invisibly).
168+
//
169+
// Why WhenRequired: since early 2025 aws-sdk-go-v2 defaults to WhenSupported,
170+
// which stamps a CRC32 on every PutObject/UploadPart — over HTTPS that rides as a
171+
// STREAMING-UNSIGNED-PAYLOAD-TRAILER. Ceph RGW (the confirmed case) rejects the
172+
// accompanying header with "InvalidArgument: x-amz-content-sha256 must be
173+
// UNSIGNED-PAYLOAD, STREAMING-AWS4-HMAC-SHA256-PAYLOAD or a valid sha256 value".
174+
// WhenRequired attaches a checksum only when the operation mandates one (none for
175+
// a plain PutObject or UploadPart) and is equally accepted by real AWS S3.
176+
const snapshotChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
177+
151178
// s3Client builds an S3 client honoring a custom endpoint, region and
152179
// path-style addressing. Credentials come from AWS_ACCESS_KEY_ID /
153180
// AWS_SECRET_ACCESS_KEY in the environment (loaded by the default config
154-
// chain).
181+
// chain). Used by BOTH the upload (snapshot) and download (restore) paths.
155182
func (d destination) s3Client(ctx context.Context) (*s3.Client, error) {
156183
region := d.s3Region
157184
if region == "" {
@@ -166,18 +193,20 @@ func (d destination) s3Client(ctx context.Context) (*s3.Client, error) {
166193
o.BaseEndpoint = aws.String(d.s3Endpoint)
167194
}
168195
o.UsePathStyle = d.s3PathStyle
169-
// Only add a request checksum when the operation actually requires one.
170-
//
171-
// Since early 2025, aws-sdk-go-v2 defaults RequestChecksumCalculation to
172-
// WhenSupported, which attaches a CRC32 to every PutObject. Many
173-
// S3-compatible backends that are not AWS S3 (Ceph RGW, some MinIO and
174-
// Cloudflare R2 versions, etc.) reject the accompanying header with
175-
// "InvalidArgument: x-amz-content-sha256 must be UNSIGNED-PAYLOAD,
176-
// STREAMING-AWS4-HMAC-SHA256-PAYLOAD or a valid sha256 value", so the
177-
// snapshot upload fails against them. WhenRequired keeps uploads working
178-
// on those backends and is equally accepted by real AWS S3, which does
179-
// not require a checksum on a plain PutObject.
180-
o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
196+
// Request-checksum policy for this client. This is only ONE of the two
197+
// sites that need it — the transfer manager carries its own copy for the
198+
// multipart path (see snapshotChecksumCalculation and newSnapshotUploader).
199+
// LoadDefaultConfig would otherwise populate this from
200+
// AWS_REQUEST_CHECKSUM_CALCULATION, but the agent's environment is a closed
201+
// list built by the controller (no operator can set that var on the Pod),
202+
// so we pin it here unconditionally rather than leave an escape hatch that
203+
// nothing can reach.
204+
o.RequestChecksumCalculation = snapshotChecksumCalculation
205+
// Symmetric response-side setting for the restore/download path (this
206+
// client is shared with downloadS3): don't demand a response checksum the
207+
// backend may not emit — the same AWS-default-vs-S3-compatible mismatch
208+
// class. Harmless on the upload path, which issues no checksum-bearing GET.
209+
o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
181210
}), nil
182211
}
183212

internal/agent/agent_test.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,30 @@ func TestObjectKey(t *testing.T) {
3636
}
3737
}
3838

39+
// hermeticAWSEnv isolates the aws-sdk-go-v2 default config chain from the host:
40+
// LoadDefaultConfig otherwise reads ~/.aws/config and AWS_PROFILE, so a runner
41+
// whose AWS_PROFILE points at a missing profile would fail these tests for
42+
// reasons unrelated to the assertion. It also pins static credentials and a
43+
// region so signing never reaches the EC2/ECS metadata endpoint.
44+
func hermeticAWSEnv(t *testing.T) {
45+
t.Helper()
46+
t.Setenv("AWS_CONFIG_FILE", "/dev/null")
47+
t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null")
48+
t.Setenv("AWS_PROFILE", "")
49+
t.Setenv("AWS_ACCESS_KEY_ID", "test")
50+
t.Setenv("AWS_SECRET_ACCESS_KEY", "test")
51+
t.Setenv("AWS_REGION", "us-east-1")
52+
}
53+
54+
// TestS3ClientChecksumWhenRequired is a unit check on the s3.Client OPTION only.
55+
// It does NOT exercise the path that actually broke: the transfer manager carries
56+
// its own RequestChecksumCalculation and overrides this on the multipart upload.
57+
// The end-to-end contract — that a real (>5 MiB) multipart snapshot upload emits
58+
// no checksum trailer — lives in TestUploadS3StreamMultipartNoChecksumTrailer
59+
// (snapshot_test.go). Keep both: this pins the client/HeadObject/single-part
60+
// knob, that one pins multipart.
3961
func TestS3ClientChecksumWhenRequired(t *testing.T) {
62+
hermeticAWSEnv(t)
4063
d := destination{
4164
kind: "s3",
4265
s3Endpoint: "https://s3.example.internal",
@@ -48,12 +71,17 @@ func TestS3ClientChecksumWhenRequired(t *testing.T) {
4871
}
4972
o := c.Options()
5073
// Non-AWS S3-compatible backends (Ceph RGW, some MinIO/R2) reject the
51-
// aws-sdk-go-v2 default (WhenSupported) flexible checksum on PutObject, so
52-
// the agent must only add a checksum when the operation requires one.
74+
// aws-sdk-go-v2 default (WhenSupported) flexible checksum, so the agent must
75+
// only add a checksum when the operation requires one.
5376
if o.RequestChecksumCalculation != aws.RequestChecksumCalculationWhenRequired {
5477
t.Errorf("RequestChecksumCalculation = %v, want WhenRequired (%v)",
5578
o.RequestChecksumCalculation, aws.RequestChecksumCalculationWhenRequired)
5679
}
80+
// Symmetric response-side setting covering the restore/download path.
81+
if o.ResponseChecksumValidation != aws.ResponseChecksumValidationWhenRequired {
82+
t.Errorf("ResponseChecksumValidation = %v, want WhenRequired (%v)",
83+
o.ResponseChecksumValidation, aws.ResponseChecksumValidationWhenRequired)
84+
}
5785
if !o.UsePathStyle {
5886
t.Error("UsePathStyle = false, want true")
5987
}

internal/agent/restore.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,11 @@ func downloadS3(ctx context.Context, src destination, stageDir string) (string,
270270
return "", err
271271
}
272272
defer f.Close()
273+
// client is built by s3Client, which pins ResponseChecksumValidation to
274+
// WhenRequired — so the downloader doesn't demand a response checksum an
275+
// S3-compatible backend may not return (symmetric to the upload-side request
276+
// checksum). manager.Downloader has no response-validation knob of its own, so
277+
// the client option is the only site for this.
273278
downloader := manager.NewDownloader(client)
274279
if _, err := downloader.Download(ctx, f, &s3.GetObjectInput{
275280
Bucket: aws.String(src.s3Bucket),

internal/agent/snapshot.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,13 +237,27 @@ func uploadS3Stream(ctx context.Context, dest destination, key string, body io.R
237237
if uid != "" {
238238
metadata = map[string]string{snapshotUIDMetaKey: uid} // stamp ownership so a retry recognizes its own object
239239
}
240-
return uploadStreamHashed(ctx, manager.NewUploader(client), &s3.PutObjectInput{
240+
return uploadStreamHashed(ctx, newSnapshotUploader(client), &s3.PutObjectInput{
241241
Bucket: aws.String(dest.s3Bucket),
242242
Key: aws.String(key),
243243
Metadata: metadata,
244244
}, body)
245245
}
246246

247+
// newSnapshotUploader builds the S3 transfer manager for snapshot uploads.
248+
//
249+
// manager.Uploader carries its OWN RequestChecksumCalculation (NewUploader
250+
// hardcodes it to WhenSupported) and never consults the s3.Client option, so the
251+
// multipart path — taken for every body over the 5-MiB part size, i.e. every real
252+
// etcd snapshot — would re-stamp the CRC32 trailer that the client option was set
253+
// to avoid. Pin it here too, from the same constant s3Client uses, so the two
254+
// knobs cannot drift. See snapshotChecksumCalculation.
255+
func newSnapshotUploader(client *s3.Client) *manager.Uploader {
256+
return manager.NewUploader(client, func(u *manager.Uploader) {
257+
u.RequestChecksumCalculation = snapshotChecksumCalculation
258+
})
259+
}
260+
247261
// s3Uploader abstracts manager.Uploader.Upload so uploadStreamHashed is testable
248262
// without a live S3 endpoint.
249263
type s3Uploader interface {

internal/agent/snapshot_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,20 @@ You may obtain a copy of the License at
1111
package agent
1212

1313
import (
14+
"bytes"
1415
"context"
1516
"crypto/sha256"
1617
"encoding/hex"
18+
"encoding/pem"
1719
"errors"
1820
"fmt"
1921
"io"
22+
"net/http"
23+
"net/http/httptest"
2024
"os"
2125
"path/filepath"
2226
"strings"
27+
"sync"
2328
"testing"
2429

2530
"github.com/aws/aws-sdk-go-v2/aws"
@@ -81,6 +86,106 @@ func TestUploadStreamHashed_UploadError(t *testing.T) {
8186
}
8287
}
8388

89+
// TestUploadS3StreamMultipartNoChecksumTrailer is the guard for the actual
90+
// failure mode. A real etcd snapshot exceeds the transfer manager's 5 MiB part
91+
// size, so the upload takes the MULTIPART branch, where manager.Uploader's own
92+
// RequestChecksumCalculation (NOT the s3.Client option) decides whether a CRC32
93+
// trailer is stamped on each UploadPart. Without newSnapshotUploader pinning it
94+
// to WhenRequired, every part rides `x-amz-content-sha256:
95+
// STREAMING-UNSIGNED-PAYLOAD-TRAILER` + `x-amz-trailer: x-amz-checksum-crc32` —
96+
// exactly the header Ceph RGW rejects.
97+
//
98+
// This drives the production uploadS3Stream against a TLS test server (trusted via
99+
// AWS_CA_BUNDLE, which LoadDefaultConfig wires into the real client, so s3Client
100+
// and newSnapshotUploader run exactly as in production) with a >5 MiB body, and
101+
// asserts no request carries a checksum trailer or algorithm. It fails if the
102+
// uploader option is dropped; a check on s3.Options alone (see agent_test.go)
103+
// would stay green while this real path stayed broken.
104+
func TestUploadS3StreamMultipartNoChecksumTrailer(t *testing.T) {
105+
hermeticAWSEnv(t)
106+
107+
type capturedReq struct{ method, sha, trailer, algo string }
108+
var mu sync.Mutex
109+
var reqs []capturedReq
110+
111+
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+
_, _ = io.Copy(io.Discard, r.Body)
113+
mu.Lock()
114+
reqs = append(reqs, capturedReq{
115+
method: r.Method,
116+
sha: r.Header.Get("X-Amz-Content-Sha256"),
117+
trailer: r.Header.Get("X-Amz-Trailer"),
118+
algo: r.Header.Get("X-Amz-Sdk-Checksum-Algorithm"),
119+
})
120+
mu.Unlock()
121+
122+
q := r.URL.Query()
123+
switch {
124+
case r.Method == http.MethodHead:
125+
// ensureObjectAbsent's HeadObject: report absent so the upload proceeds.
126+
w.WriteHeader(http.StatusNotFound)
127+
case r.Method == http.MethodPost && q.Has("uploads"):
128+
io.WriteString(w, `<?xml version="1.0" encoding="UTF-8"?>`+
129+
`<InitiateMultipartUploadResult><Bucket>b</Bucket><Key>k</Key>`+
130+
`<UploadId>test-upload-id</UploadId></InitiateMultipartUploadResult>`)
131+
case r.Method == http.MethodPut && q.Has("partNumber"):
132+
w.Header().Set("ETag", `"etag-`+q.Get("partNumber")+`"`)
133+
w.WriteHeader(http.StatusOK)
134+
case r.Method == http.MethodPost && q.Get("uploadId") != "":
135+
io.WriteString(w, `<?xml version="1.0" encoding="UTF-8"?>`+
136+
`<CompleteMultipartUploadResult><Bucket>b</Bucket><Key>k</Key>`+
137+
`<ETag>"final-etag"</ETag></CompleteMultipartUploadResult>`)
138+
default:
139+
w.WriteHeader(http.StatusOK)
140+
}
141+
}))
142+
defer srv.Close()
143+
144+
// Trust the test server's self-signed cert through the SDK's default config
145+
// chain, so uploadS3Stream's own s3Client reaches it — no hand-built client.
146+
caFile := filepath.Join(t.TempDir(), "ca.pem")
147+
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw})
148+
if err := os.WriteFile(caFile, certPEM, 0o600); err != nil {
149+
t.Fatalf("write CA bundle: %v", err)
150+
}
151+
t.Setenv("AWS_CA_BUNDLE", caFile)
152+
153+
const bodySize = 12 << 20 // 12 MiB > 5 MiB part size ⇒ three parts ⇒ multipart branch
154+
dest := destination{kind: "s3", s3Endpoint: srv.URL, s3Bucket: "b", s3PathStyle: true}
155+
156+
size, sum, err := uploadS3Stream(context.Background(), dest, "k", bytes.NewReader(make([]byte, bodySize)), "test-uid")
157+
if err != nil {
158+
t.Fatalf("uploadS3Stream: %v", err)
159+
}
160+
if size != bodySize {
161+
t.Errorf("streamed size = %d, want %d", size, bodySize)
162+
}
163+
if sum == "" {
164+
t.Error("empty sha256 digest")
165+
}
166+
167+
mu.Lock()
168+
defer mu.Unlock()
169+
var sawUploadPart bool
170+
for _, rq := range reqs {
171+
if rq.method == http.MethodPut {
172+
sawUploadPart = true
173+
}
174+
if rq.sha == "STREAMING-UNSIGNED-PAYLOAD-TRAILER" {
175+
t.Errorf("%s carried x-amz-content-sha256=STREAMING-UNSIGNED-PAYLOAD-TRAILER — the checksum trailer Ceph RGW rejects", rq.method)
176+
}
177+
if rq.trailer != "" {
178+
t.Errorf("%s carried x-amz-trailer=%q, want none", rq.method, rq.trailer)
179+
}
180+
if rq.algo != "" {
181+
t.Errorf("%s carried x-amz-sdk-checksum-algorithm=%q, want none", rq.method, rq.algo)
182+
}
183+
}
184+
if !sawUploadPart {
185+
t.Fatal("no UploadPart (PUT) request seen — the upload did not take the multipart branch, so this test is not exercising the path it guards")
186+
}
187+
}
188+
84189
type fakeHead struct {
85190
out *s3.HeadObjectOutput
86191
err error

0 commit comments

Comments
 (0)