[snapshot] Add --publish transport for upload/download, with TLS hardening and transfer reliability fixes - #471
Closed
kkozoriz wants to merge 29 commits into
Closed
[snapshot] Add --publish transport for upload/download, with TLS hardening and transfer reliability fixes#471kkozoriz wants to merge 29 commits into
kkozoriz wants to merge 29 commits into
Conversation
… client Add ValidateHTTPSURL (transport) and NewSafeClientForConfig (safe client) as the foundational pieces needed to build an HTTPS client against a published (ingress) DataImport endpoint. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Add spec.publish to the DataImport built by d8 snapshot upload, align it alongside spec.ttl on reuse, wait on status.publicURL when publish is enabled, and switch to a merged TLS trust pool with 401/403 diagnostics on that path. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Wire the flag through to the DataImport importer, auto-detecting the upload mode when unset, and document the bearer-token requirement for the published path. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
SetTLSCAData merged a trust pool but never reset the caller's inherited insecure-skip-tls-verify/tls-server-name, so Go skipped certificate verification entirely on that path — any endpoint could receive the real Kubernetes bearer token. Force verification on unconditionally, in both the rest.Config and the cloned transport, since client-go may already have baked the insecure flag into a base transport before this wrapper runs. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Add spec.publish to the DataExport built by d8 snapshot download, upgrade it (one-way, optimistic-locked) on an adopted CR, wait on status.publicURL when publish is enabled, and switch to a merged TLS trust pool with 401/403 diagnostics on that path. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Header-only pass over data.tar's PAX metadata, no decode required. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
For zstd, reads only frame headers. Other codecs still need a streaming pass, but no output is buffered. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
VolumeInfo gains RawSizeBytes/StoredSizeBytes alongside the existing nominal Size, under a new format version. Size stays what it always was: the PVC quantity used to provision the scratch volume on re-import, not the payload's actual byte size — a thin-provisioning backend rounds the device up from it, so the two can differ. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
MeasurePayload reads the actual data.bin[.<ext>]/data.tar on disk and is called once per node at finalize time, since a resumed or re-published node may not have downloaded anything in this run. Test fixtures in pipeline/manifest_worker switch from placeholder bytes to real zstd/tar payloads, since finalize now parses them. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
An already-staged file with no source MD5 to compare against was trusted on sight. Decode it and compare the real plaintext size against the declared one instead; a mismatch re-fetches, same as an MD5 mismatch already did. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Upload's declared block-transfer size used to come from the nominal PVC quantity for every codec, block included. A thin-provisioning backend rounds the underlying device up from that quantity, so a compressed payload's real decoded size can exceed it — upload then rejected valid archives as size-mismatched. Now resolveBlockPayloadSize trusts the size recorded in the archive (from the previous commits) when present, falling back to measuring the payload itself for older archives. Also adds an X-Device-Size preflight so a target device provably too small fails before the first PUT instead of mid-transfer. Filesystem upload's progress total is now reported once, up front, from the preflight tar scan's exact sum — instead of growing as a running total that only reaches completeness at the last entry. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
SetTLSCAData merged a trust pool but never reset the caller's inherited insecure-skip-tls-verify, so certificate verification was a no-op on the d8 data export/import transports and the kubeconfig bearer token could be sent to any endpoint answering that address. Force verification on and clear the inherited tls-server-name at both levels — the rest.Config and the transport clone — since client-go bakes the insecure flag into the base transport before WrapTransport runs. Also chain a previously installed WrapTransport instead of clobbering it, and return the input RoundTripper rather than a typed-nil *http.Transport for non-transport RoundTrippers. Both mirror the already-fixed twin in internal/snapshot/transport, which is now cross-referenced from each side. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…uble-call chaining SetTLSCAData's existing 4 tests already caught line-by-line regressions on the Insecure/ServerName force and the prev-chain, but left three edge cases unexercised: garbage/empty caData (AppendCertsFromPEM silently ignores bad input, must not panic or leave verification off), CertData/KeyData/CertFile/ KeyFile client-cert fields (untouched by the function, worth pinning), and a second SetTLSCAData call on the same client (prev-chaining must not turn self-referential and recurse forever). Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…es in one place Introduce exporter.IsTransientDataPlaneError as the single place that decides whether a chunk/file transport error is worth retrying. It fails closed: anything not on the allow-list (io.ErrUnexpectedEOF, io.EOF, ErrDataPlaneIdle, ECONNRESET/ECONNABORTED/EPIPE/ETIMEDOUT, and a net.Error reporting Timeout()) is treated as fatal, so a misclassification costs a loud failure rather than a silent retry loop masking a real defect. Cancellation is checked before the net.Error timeout branch: context.DeadlineExceeded itself satisfies net.Error with Timeout() == true, so checking timeouts first would misclassify an intentional cancellation/deadline as retryable. ErrExportUnauthorized and ErrContentRangeMismatch are checked next and are never transient: they describe a request the server actively rejected or a response that cannot be trusted, not a broken transport worth re-issuing. syscall.ECONNREFUSED is deliberately excluded: it means the export never accepted the connection at all, not an abrupted mid-stream transport. HTTP 5xx statuses are excluded too: RangeGet turns a non-206 status into an ordinary status error, and none has ever been observed in production ingress logs. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…ubtest name Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
A single transient transport failure anywhere inside a chunk's Range GET used to fail the whole block/file volume, even though an exact byte-durable resume checkpoint (fsync'd .part + offset sidecar) already existed on disk. Reuse that same mechanism as the retry unit: on a transient error, re-issue the Range GET from the durable offset the interrupted attempt persisted, with bounded exponential backoff, instead of surfacing the failure to the caller immediately. chunkRetrier wraps fetchChunkRaw (unchanged: it stays a single attempt, preserving the resume contract several existing tests pin down) with a wait.ExponentialBackoffWithContext loop. Cancellation is checked before error classification so an aborted request never gets mistaken for a retryable transport error. A no-progress guard stops attempts that keep advancing zero bytes well before the backoff budget would otherwise be spent on a link that is never going to deliver. chunkProgressLedger de-duplicates onProgress credits across attempts: each fetchChunkRaw attempt re-credits its own resume prefix, so without the ledger a retried chunk would over-report progress past the volume's true size. downloadBlockChunks creates one chunkRetrier per volume, shared by every chunk goroutine, and logs a single aggregate WARN if any retries were absorbed. stageChunkedFile downloads every non-empty file through the same downloadBlockChunks path, so the filesystem volume path gets this fix for free. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…ky links --per-volume-concurrency default stays 4; the help text now hints that lowering it to 1 helps on a distant/flaky link where long-lived chunk streams keep breaking, now that a broken stream retries in place rather than failing the whole volume. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
TestDownloadBlockChunks_RetryIsPerChunk only ever flakes one chunk out of several clean ones, which never actually exercises concurrent increments to chunkRetrier.recovered or concurrent chunkProgressLedgers feeding the shared onProgress sink at the same time. Add two internal tests that drive several chunks through fetchChunk truly concurrently (real goroutines, one shared retrier and onProgress sink): - TestChunkRetrier_ConcurrentChunksIndependentRecoveredCount flakes every chunk exactly once at the same time and asserts recovered lands on the exact expected count, part-file contents are not cross-chunk corrupted, and the shared progress sink sums to exactly the total raw bytes with no loss or double-count. - TestChunkRetrier_ConcurrentContextCancelStopsAllRetries cancels a context shared by several chunks that are all mid-backoff at once and asserts every goroutine stops promptly instead of riding out its sleep. Both pass under -race -count=5. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…unk retry logging wait.Backoff.Cap forces chunkRetrier.fetchChunk's retry loop to stop one or more attempts short of the policy's declared Steps budget, but the code compared against Steps in two places: the WARN guard (which never actually suppressed the last attempt's log line once Cap cut the loop short) and the exhausted-budget error message (which always reported the declared budget, never the real attempt count). Remove the guard entirely — the closure has no way to observe Cap's early cutoff since wait.ExponentialBackoffWithContext mutates its own copy of backoff — and report the real attempt count in the error message instead. Also unwrap the %w chain before taking %T in the non-retryable-error diagnostic, since fetchChunkRaw always wraps its errors and the previous code only ever logged *fmt.wrapError. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…t, not wall clock
TestChunkRetrier_ConcurrentContextCancelStopsAllRetries and
TestChunkRetrier_ContextCancelStopsRetryImmediately raced a
time.AfterFunc-scheduled cancel() against goroutines that had not
necessarily issued their first HTTP request yet, so the retry loop's very
first backoff iteration could already observe a cancelled ctx and make zero
requests instead of the expected one per chunk (~60% flake rate on the
concurrent test under -race -count=5). Both tests now signal over a channel
once each doer has actually seen its first request, and cancel only after
every goroutine has reached that point; elapsed-time measurement starts
right before cancel() so it actually reflects time-to-stop.
Also add table-driven coverage for rootCause (nil, unwrapped, single- and
double-wrapped errors), note its known limitation on multi-wrap
(Unwrap() []error) chains, and reword the per-attempt WARN log to state a
plain fact ("chunk transfer interrupted by...") instead of "retrying",
since it fires on the terminal attempt too, where no retry follows.
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Shortens overly long doc/inline comments this branch added across production and test files, keeping the same meaning, to reduce diff size and ease review. No behavior or test-logic changes. Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…463 The team merges with "Squash and merge", which drops the #462 -> #463 -> #464 ancestry. Git then falls back to origin/main as the merge base and sees #462's changes -- already present here -- as competing additions, producing two conflicts that a plain merge chain never hits: CONFLICT (content): internal/snapshot/transport/http.go CONFLICT (add/add): pkg/libsaferequest/client/http_test.go Both were purely structural, so reshape them instead of changing behaviour: - http_test.go is created by #462 too, so an add/add conflicts unless both sides match byte for byte. Restore it to #462's exact content and move the 7 SetTLSCAData tests and their 3 helpers to a new http_tls_test.go, which exists on neither side of main and merges as a clean single-side add. - The "keep in sync" note sat at the end of a doc comment block #462 had just added, so the two additions were adjacent and conflicted. Move it just inside SetTLSCAData, a region #462 does not touch. No production code changes: pkg/libsaferequest/client/http.go is untouched and internal/snapshot/transport/http.go now matches #463 apart from the relocated comment. All 8 tests are preserved and still pass under -race. Verified from origin/main: squash #462, squash #463, merge #464, merge #465 -- all four clean, then build, vet and go test -race clean over ./internal/snapshot/... and ./pkg/libsaferequest/... Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
… into combined/snapshot-publish-and-retry-fixes Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…h' into combined/snapshot-publish-and-retry-fixes Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…ion' into combined/snapshot-publish-and-retry-fixes Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…o combined/snapshot-publish-and-retry-fixes Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
d8 snapshot upload/downloadcould only stream volume bytes to/from the exporter/importer pod's in-cluster service, so pulling or pushing a snapshot required direct network access to the cluster's internal service network — not possible from a machine that only has the cluster's public API URL.Separately, three things made that transfer path fragile even when reachable:
SafeClient.SetTLSCAData) merged a trust pool of CA certificates but never reset an inheritedinsecure-skip-tls-verify, so certificate verification was silently a no-op regardless of how good the trust pool was — the kubeconfig bearer token could be handed to whatever endpoint answered that address.uploadsometimes rejected perfectly valid archives as size-mismatched.Fix
--publishon bothuploadanddownload: stream volume bytes through the cluster's published Ingress endpoint (status.publicURL) instead of requiring direct service-network reachability. Unset autodetects whether the in-cluster endpoint is reachable; explicit--publish=falsekeeps the old path. Works only with a bearer-token kubeconfig — the Ingress terminates TLS with its own certificate and doesn't forward a client certificate to the pod.SafeClient.SetTLSCAData:Insecureis cleared on both therest.Configand the already-built transport clone (client-go bakes the flag into the base transport beforeWrapTransportruns), a previously installedWrapTransportis chained instead of clobbered, and a nil-panic on non-*http.Transporttypes is fixed.snapshot.yaml, manifest format version 3) instead of trusted from the nominal PVC quantity:uploadnow trusts the recorded size when present, with anX-Device-Sizepreflight so a genuinely too-small target device fails before the firstPUTinstead of mid-transfer. Filesystem upload reports its progress total up front from a preflight tar scan instead of growing a running total that only completed at the last entry. Version 2 archives are still read (readers fall back to measuring).Validation
Build. Over every touched package (
./internal/snapshot/...,./pkg/libsaferequest/...):go buildgo vetgo testokgo test -raceok, no data racesgolangci-lint runReal-cluster e2e.
sds-replicated-thin-r3) downloaded with--publish=truefrom a machine with no route into the cluster network produced an archive byte-identical by sha256, across all 4 volumes, to both an internal-network download of the same snapshot and the live volume data on the cluster.--publish=truefrom an external machine holding only a bearer-token kubeconfig: completed in 23.6 minutes, all 4DataImportobjects reachedCompleted, and the restored volumes' contents were confirmed by downloading them back and comparing sha256.unexpected EOF) on the two block volumes' chunked transfer, 4 per volume. Every one recovered on the first retry (attempt=1), the per-volume summary loggedretries=4, and the final archive's sha256 still matched two independently obtained copies of the same data. Roughly 458 progress log lines showed no double-counted bytes.storedSizeBytesmatchedstaton the produced archive files exactly for all four volumes, and the download progress total became a real 3.4 GiB instead of a nominal 4.0 GiB.download --publish=truefrom outside the cluster network:upload --publish=truefrom outside the cluster network:Measured vs. nominal payload sizes, cross-checked against
stat:Tests
internal/snapshot/cmd/download,internal/snapshot/cmd/snapimport— flag defaults, autodetection, examplesinternal/snapshot/exporter/{export,dataexport,http,retry}— publish endpoint selection, 401 hinting, transient-failure classificationinternal/snapshot/transport,pkg/libsaferequest/client— TLS verification forced on,WrapTransportchaining, invalid CA data, client-cert survival, non-transport pass-throughinternal/snapshot/pipeline— publish threaded through pipeline configinternal/snapshot/archive— manifest format version 3, round-trip, version-2 read compatibilityinternal/snapshot/volume— chunk retry, backoff, no-progress guard, concurrent retry, context cancellation, payload-size measurement, resumed staging verified by sizeinternal/snapshot/snapimport— upload trusts recorded size, device-size preflight, up-front filesystem progress totalAll of it passes under
-raceon the combined result, as shown above.