Compare commits

...
Author SHA1 Message Date
Devin dbb24e3bd4 notification: assert delivery in the named targets test
The test only exercised SendMessage; it now records requests on both
endpoints and checks matching, non-matching, and unrouted keys reach
the right servers exactly once.
2026-10-21 15:08:57 -07:00
Devin cff6fd1f4c notification: name the webhook target in failure and dead letter logs
Every target dead-letters to the same topic, so without the name there
is no way to tell which endpoint failed or needs a replay.
2026-10-21 15:08:26 -07:00
Devin c872298c8b notification: isolate webhook targets behind per-target queues
SendMessage published to each target's topic inline, so a subscriber
channel that filled up blocked every later target and the filer's
notification path with it. Each target now drains its own inbox through
a forwarder goroutine; SendMessage fails fast on a full inbox, matching
the "throw message" contract the filer already applies to errors.
2026-10-21 15:08:04 -07:00
Devin 3f26cb4475 notification: cancel the webhook queue only after all workers stop
Each target's router goroutine deferred the shared cancel, so the first
worker to exit tore down the queue context for every other target and
the dead letter logger.
2026-10-21 15:07:36 -07:00
Devin 1c62e30393 notification: resolve named target enabled flags through config
namedTargetPrefixes read the raw map value, which never sees environment
variable overrides; GetBool resolves WEED_* env bindings, so a named
target disabled in the file can now be switched on by
WEED_NOTIFICATION_WEBHOOK_<NAME>_ENABLED.
2026-10-21 15:07:21 -07:00
Devin 39fbcbc59e notification: require enabled for the flat webhook target
buildTargets included the flat section whenever an endpoint was set, so a
disabled [notification.webhook] block — which the scaffold ships with a
placeholder endpoint — would start receiving events as soon as any named
target activated the queue.
2026-10-21 15:07:05 -07:00
DevinandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7a8beb781b notification: support named webhook targets under [notification.webhook.<name>]
The webhook queue only accepted one endpoint, so events could not be
routed to different services by path prefix. Each enabled
[notification.webhook.<name>] subsection now spins up its own target —
own filter, http client, retry middleware and worker budget — published
on a per-target topic so every matching endpoint gets each event exactly
once. The flat [notification.webhook] section keeps working as the
unnamed target, and named subsections alone now activate the queue.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 14:55:57 -07:00
DevinandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ab5e38b1bd notification: honor the config prefix in the multi-queue guard
validateOneEnabledQueue checked "<name>.enabled" without the
notification prefix, so the fatal-on-two-queues guard never saw the
real keys and could never fire.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 14:52:34 -07:00
de2fc03c7d s3tables: accept the external-volume verification object at a table bucket's root (#11900)
* s3tables: accept the external-volume verification object at a table bucket's root

Snowflake verifies an S3-compatible external volume before it creates or
writes Iceberg tables in it (SYSTEM$VERIFY_EXTERNAL_VOLUME, also run
implicitly): it writes, reads, lists and deletes one small object,
<base>/verify_<epoch milliseconds>_<number>, a 21-byte body. A table bucket
refuses every key outside the Iceberg layout, so the check failed and
Snowflake could not use a table bucket as an external volume.

PUT, GET, HEAD, DELETE and DeleteObjects now accept exactly that key in a
table bucket, narrowly enough that it cannot be used to store data:
- at the bucket root only, so nothing is encoded in a prefix, written into
  a hidden tree or placed on a namespace or table name;
- one shape: verify_<13 digits of epoch ms within one hour of the server's
  clock>_<1-10 digits>;
- a PUT must declare a Content-Length of at most 1 KiB (no chunked or
  undeclared body) and carry no user metadata, tagging, Content-Disposition,
  Cache-Control, Expires or website redirect.
Multipart, copy, rename, ACL, tagging and POST policy writes keep the plain
layout rule. Buckets that are not table buckets are unaffected.

Tests: table_bucket_verify_object_test.go, the key accepted (with the
Iceberg layout as the positive control and the plain rule still refusing
it), 15 look-alikes refused (prefixed, hidden tree, wrong shape, outside the
window, ordinary keys), and seven PUT refusals through PutObjectHandler.

* s3tables: keep the verification key off namespaces and narrow its PUT

A namespace may be named like a verification key (verify_<ms>_<n>), and
the key then names the namespace's directory. A PUT replaced that
directory with a prefix object, dropping the namespace's catalog
metadata; a DELETE removed an empty namespace. The key now gets the plain
layout rule when it names a directory or its entry cannot be read, and a
PUT of it never falls back to a prefix object, so a namespace created
after the check is not replaced either.

The time window applies to PUT only: GET, HEAD and DELETE take a key of
any date, so an object a check left behind for more than an hour can
still be read and removed. A verification PUT also refuses an extraction
request and the stored Content-Language, Response-Content-Disposition and
Content-Encoding headers (aws-chunked framing alone is still accepted).

The integration test runs the check's PUT, HEAD, GET, list and DELETE
against a table bucket, and the PUT and DELETE over a namespace of the
key's shape.

---------

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
2026-10-21 14:49:36 -07:00
21add33b6e s3 backend: optional HTTP client settings for the cloud tier (#11897)
* s3 backend: optional HTTP client settings for the cloud tier

The S3 tier backend builds its client from a bare aws.Config, so it always
uses Go's default transport: HTTP/2 wherever the endpoint offers it over TLS,
no cap on connections per host, and a 90s idle timeout. Against some
S3-compatible services that is a poor fit. Over HTTP/2 every upload and
range read of a tiered volume shares one connection and its flow-control
window; with a small window, tier uploads and concurrent reads of tiered
volumes become throughput-bound on that one connection, while HTTP/1.1 opens
a connection per concurrent request. Some services also limit parallel
connections per client address, or drop idle connections before 90s.

Add three optional settings to [storage.backend.s3.<id>], all off by
default so existing deployments keep the SDK's default client:
- disable_http2: keep the client on HTTP/1.1 (ForceAttemptHTTP2=false and an
  empty TLSNextProto, the documented opt-out);
- max_connections: MaxConnsPerHost, 0 = no cap;
- idle_connection_timeout_seconds: IdleConnTimeout, 0 = Go's default.

The session cache key includes the options when any is set, so two backends
on the same endpoint with different settings do not share a client.
ToProperties reports the settings; the master.toml scaffold documents them.

Tests: TestS3BackendHTTPOptionsReachTheClient fails without the change (the
settings never reach the client); TestS3BackendDefaultHTTPClientUnchanged,
TestS3BackendSessionsDifferByHTTPOptions, TestParseNonNegative.

* s3 backend: reject an idle timeout too large for time.Duration

An idle_connection_timeout_seconds above math.MaxInt64/1e9 overflowed when
multiplied by time.Second. The negative result made the transport keep its
default timeout and ToProperties export a negative value. Warn and use the
default instead.

* s3 backend: parse the idle timeout as 64-bit seconds

parseIdleConnTimeout went through strconv.Atoi, so on 32-bit platforms
any value above 2147483647 seconds was rejected while the same value was
accepted on 64-bit, and the overflow test failed under GOARCH=386. Parse
with ParseInt(…, 64) and export the effective value with FormatInt so the
accepted range is the same on every platform.

---------

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
2026-10-21 14:44:39 -07:00
Chris LuGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
9a03c09396 s3api: cover the inline-content UploadPartCopy route at handler level (#11914)
s3api: exercise the inline-content route through CopyObjectPartHandler

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 14:15:04 -07:00
f40267db44 s3: answer InvalidBucketName to a denied request on a malformed bucket name (#11893)
* s3: answer InvalidBucketName to a denied request on a malformed bucket name

The store authorizes a request before it looks at the bucket, so a caller
denied on a bucket whose name breaks the S3 naming rules (upper case, an
underscore, too short, two adjacent periods, a non-ASCII letter) gets 403
AccessDenied, where AWS S3 answers 400 InvalidBucketName. Clients and
compatibility suites that check the error code read the 403 as a permission
problem.

Auth and AuthPostPolicy now turn an AccessDenied for a resolved identity into
InvalidBucketName when the bucket name fails VerifyS3BucketName or uses a
character outside a-z, 0-9, '.' and '-' (VerifyS3BucketName checks with
unicode.IsLower/IsNumber, which accept letters such as 'ü'). The answer depends
on the name alone, so it reveals nothing about which buckets exist. An
authentication failure, an allowed request and a denial on a well-formed name
answer as before.

Test: TestAuthDeniedInvalidBucketName drives iam.Auth with signed and anonymous
requests; on master every malformed name answers 403, with the change 400,
while the identity's own bucket stays allowed and a well-formed foreign name
stays 403. TestRefineDenialForBucketName covers the rule table.

* s3: refine POST-policy and deferred anonymous-read denials on a malformed bucket name

Two denial paths bypass the refinement in Auth and AuthPostPolicy:

- A browser POST upload carries its credentials in the form, so
  AuthPostPolicy delegates it to PostPolicyBucketHandler, which verifies the
  form and writes AccessDenied itself when the signer may not write the bucket
  or the per-key authorization denies the object. Both denials now go through
  refineDenialForBucketName with the verified identity.

  The coarse Write check used to run inside doesPolicySignatureV2Match and
  doesPolicySignatureV4Match before the signature was compared, so a form with
  a wrong signature was told whether the key may write the bucket. It now runs
  in doesPolicySignatureMatch after the signature verifies, as on the
  header-signed path; a form that fails verification answers
  SignatureDoesNotMatch (or MissingFields) and is never refined.

- Unsigned GET/HEAD object reads are deferred by AuthWithPublicRead to the
  object handlers, where writeAnonymousAwareNotFound masks a miss as
  AccessDenied. That denial is refined with the anonymous identity, so a
  missing malformed bucket answers InvalidBucketName when an anonymous
  identity is configured, as Auth does. A denial on an entry that was found
  is left alone: the bucket exists.

Test: TestPostPolicyDeniedInvalidBucketName drives AuthPostPolicy with signed
forms (Write denied, per-key policy deny, bad signature, well-formed name);
TestAnonymousReadDeniedInvalidBucketName drives AuthWithPublicRead with GET and
HEAD, with and without an anonymous identity. Both fail before this change.

* s3: refine a denied anonymous read of an existing key on a malformed bucket name

A deferred anonymous GET/HEAD refined only its masked miss to
InvalidBucketName, so in a bucket whose name breaks the S3 rules (one
VerifyS3BucketName still accepts, such as a non-ASCII name) a missing key
answered 400 and an existing private key 403, telling the caller which
keys exist. The entry check now refines its denial the same way, so both
answer alike. Without an anonymous identity no identity resolves and both
stay 403, as in Auth.

The deny-precedence case in TestAnonymousObjectACLConfiguredIdentity runs
on the fixture bucket "b", shorter than S3's three-character minimum, so
its denial now answers InvalidBucketName.

---------

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
2026-10-21 14:08:33 -07:00
Chris LuGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
6ee7c4c5c7 s3: no empty-directory marker in a "<dir>/" self-prefix listing (#11906)
An out-of-band empty directory listed under its own trailing-slash
prefix answered the probe with a fake entry of itself: a Contents key on
4.48 and, since recently, a CommonPrefix. That listing enumerates
children, where S3 returns nothing for an empty prefix, and clients that
read every returned key (Pulumi lock listing) choked on the marker.

The prefix object and directory key object paths are unchanged: a real
"dir/" object still lists as its own key.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 14:03:02 -07:00
Javier de la TorreGitHubJavier de la TorrechrislusfDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
9c427b265f volume tier: accept deletes on a volume as soon as it is tiered (#11894)
* volume tier: accept deletes on a volume as soon as it is tiered

volume.tier.upload marks the volume read-only without deletes before the
upload (markVolumeReplicasWritable(..., false, false), i.e. noWriteOrDelete),
and nothing clears that until the volume server restarts, when load() gives
every tiered volume noWriteCanDelete. Every delete in between is refused with
"volume N is read only": the needle stays live in the index, and its bytes in
the remote object, until someone notices.

After the remote file is loaded, the upload handler now gives the volume the
flags a restart would (MarkTieredDeletable: writes refused, deletes accepted),
with two exceptions that keep the current behaviour:
- a replicated volume: the shell copies the uploaded replica's .idx and .vif
  to the other replicas after the upload, and a tombstone written before that
  copy would reach one replica only;
- a volume in I/O quarantine: that flag is not the upload's to lift.

Tests: TestTierUploadAcceptsDeletesOnASingleCopyVolume fails without the
change ("volume 1 is read only") and checks writes stay refused;
TestTierUploadKeepsRefusingDeletesOnAReplicatedVolume; TestMarkTieredDeletable
(quarantine kept).

* volume tier: check the I/O quarantine under noWriteLock

MarkTieredDeletable read the quarantine state before taking noWriteLock.
CollectHeartbeat could quarantine the volume and set noWriteOrDelete in
between, and the upload then cleared that refusal, so a quarantined volume
accepted deletes until the next heartbeat. Take noWriteLock first: the
heartbeat marks the quarantine before it takes noWriteLock, so either the
check sees it or the heartbeat's refusal lands afterwards and stays.

Also leave alone a volume past IoErrorTolerance that the next heartbeat
will quarantine, as the heartbeat itself does.

* volume: read noWriteOrDelete under noWriteLock in DeleteVolumeNeedle

DeleteVolumeNeedle checked v.noWriteOrDelete without the lock that
MarkVolumeReadonly, CollectHeartbeat and now MarkTieredDeletable hold when
they write it, so a delete racing a tier upload was a data race. Copy the
flag under the read lock; the delete path is otherwise unchanged.

* volume tier: mirror the delete acceptance rules in the Rust volume server

The Rust tier upload already promoted a freshly tiered volume to
no-write-can-delete via refresh_remote_write_mode, and did so for every
volume -- including the replicated and I/O-quarantined ones the Go
MarkTieredDeletable deliberately refuses. A live tier-up now goes
through mark_tiered_deletable (replicated volumes and quarantines keep
their upload-time mark until the next load), while load() keeps
promoting every tiered volume unconditionally, as Go does.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* volume: keep a tiered volume delete-only when marked writable

MarkVolumeWritable preserved noWriteCanDelete on a remote-tiered volume
only when it was already set. A tiered volume still holding the
upload-time mark (a replicated volume, or one whose tier upload ran
before MarkTieredDeletable existed) had it clear, so marking writable
published the volume as fully writable while its .dat lives remotely --
writes then failed against a backend that cannot append, after the
master was told the volume is writable again.

load() gives every remote-tiered volume noWriteCanDelete; deriving the
same flag here keeps mark-writable consistent with a restart: "writable"
on a remote volume resolves to can-delete.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* volume: keep a tiered volume delete-only when marked writable (rust)

set_writable preserved the remote delete-only mode only when the flag was
already set, so a tiered volume holding the upload-time read-only mark
(replicated, or quarantined under mark_tiered_deletable) was published
fully writable with its .dat remote. Derive no_write_can_delete from
has_remote_file() instead, matching load() and the Go MarkVolumeWritable.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* volume tier: serialize the quarantine check with the flag swap (rust)

A heartbeat refusal landing between should_quarantine() and the store
could be clobbered until the next heartbeat re-asserted it — the same
race the Go side closed by checking under noWriteLock. A
compare-exchange gives the same ordering: the swap fails, and the retry
sees the quarantine mark the heartbeat sets first.

* volume tier: MarkTieredDeletable refuses a non-tiered volume

A stray call on a local single-copy volume would pin it
read-only-can-delete. The only caller promotes right after
LoadRemoteFile, but the guard makes the method safe to reuse; the Rust
mark_tiered_deletable gets the same check, though its caller already
only reaches it under has_remote.

---------

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
Co-authored-by: chrislusf <chris.lu@gmail.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 14:01:10 -07:00
97f8ca13ff s3tables, iceberg: log client refusals at V(1), not ERROR (#11896)
* s3tables, iceberg: log client refusals at V(1), not ERROR

Requests the S3 Tables and Iceberg REST APIs correctly refuse with a 4xx
(access denied, not authorized, no such namespace, already exists, not
empty, a commit conflict) were logged with glog.Errorf, so a client probing
for what it may do, or retrying an optimistic commit, filled the ERROR log
and hid real faults from anyone alerting on it.

- s3tables: HandleRequest wraps the ResponseWriter in a statusRecorder (with
  Unwrap for http.ResponseController) and logs a handler error by the status
  it answered: 4xx at V(1) as "<op> refused (<status>)", 5xx or no status at
  ERROR as before.
- s3tables: the "handleCreateNamespace called" trace moves from ERROR to V(3).
- iceberg: writeManagerError's mapping is split into classifyManagerError,
  shared with a new logManagerError that logs 4xx-mapped errors at V(1) and
  everything else at ERROR; used by CreateNamespace, CommitTable and
  CommitTransaction.
- storage: loading a remote-tier volume logged
  "readSuperBlock remote volume N: <nil>" at ERROR even when the read
  succeeded; it is now logged only when there is an error.

Tests: TestRequestRefusedWith4xxIsNotLoggedAsError, TestLogRequestErrorByStatus,
TestStatusRecorderKeepsTheFirstStatus, TestStatusRecorderUnwrapsForResponseController,
TestNoTraceLoggedAtErrorLevel (fails on master on the CreateNamespace trace),
TestLogManagerErrorRefusalsAreNotErrors, TestRemoteSuperBlockLogOnlyOnError.

* s3tables: do not log a malformed CreateNamespace body at ERROR

handleCreateNamespace logged a body it could not decode at ERROR before
answering 400; the dispatcher already logs that refusal once at V(1).

Also drop statusRecorder.Flush: it made the recorder an http.Flusher even
over a writer that cannot flush, so http.NewResponseController reported
success instead of ErrNotSupported. Unwrap already lets the controller
reach the wrapped writer.

* iceberg: require exactly one log line per classified manager error

The test accepted a refusal that produced no log, or both logs. Require
one V(1) refusal and no ERROR for a 4xx, and the reverse for a fault.

---------

Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
2026-10-21 14:00:33 -07:00
Chris LuGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
780760fccc s3: report the copied bytes' MD5 in UploadPartCopy ETags (#11884)
* s3api: regression test for UploadPartCopy ETag on ranged copies

Generated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: hash the copied bytes for a partial-chunk UploadPartCopy ETag

A ranged UploadPartCopy that lands inside a source chunk stored the source chunk's ETag on the destination chunk, so the part reported the MD5 of the wrong bytes. streamCopyChunkRange now tees the forwarded plaintext into an optional digest writer; copySingleChunkForRange feeds it an md5 only when the range is not the whole source chunk, since a whole-chunk copy's stored bytes (and ETag) are unchanged.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: stamp the copied range's MD5 on UploadPartCopy part entries

The part entry got no Attributes.Md5, so copyEntryETag fell back to filer.ETagChunks: a single-chunk part inherited the source chunk's MD5 and a multi-chunk part reported a <md5>-N composite of source chunks. copyChunksForRange now returns the MD5 of the copied bytes — a single chunk's own digest, or for multi-chunk parts a second ordered stream of the source range hashed beside the concurrent chunk copies (md5 cannot combine per-chunk digests). The handler stores it on Attributes.Md5 and mirrors putToFiler's ExtETagKey, so CopyPartResult, ListParts and the completed object's composite ETag use each part's own MD5 (#11855).\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: drop the source chunk's gzip flag on partial-range copies

A ranged UploadPartCopy fetches raw (decompressed) bytes from the source volume and stores them uncompressed, but the destination chunk kept the source's IsCompressed flag, so reads of a partial copy of a gzip-stored chunk would try to decompress raw bytes.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: hash the chunk range, not the entry, for the part MD5

getEncryptedStreamFromVolumes prefers inline Content over chunks, but copyChunksForRange copies the chunks — on an entry carrying both, the hash would describe different bytes than the copy.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: cover sparse ranges in the part MD5 and fail on hash errors

A single chunk's digest is the part MD5 only when it supplies the whole range; a sparse source zero-fills the rest, so gaps are hashed via the ordered range stream, and a fully chunkless range hashes zeros locally. A failed hash pass now fails the part copy instead of storing a chunk-composite ETag that describes the wrong bytes.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: rank copied-range destination chunks by source timestamps

The concurrent chunk copies stamped each destination chunk with an arbitrary finish time, but reads resolve overlapping chunks by ModifiedTsNs — a copy of overlapping source chunks could expose different bytes than the source (and than the part MD5 just hashed). Destination chunks now get timestamps ranked by the source's, preserving its overlap precedence.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: cancel the part-MD5 stream when a chunk copy fails

The hashing goroutine ran on context.Background(), so an early chunk-copy error return left it streaming and hashing the whole source range for a discarded result. A cancellable context now aborts it on every exit path.\n\nGenerated with [Devin](https://devin.ai)\n\nCo-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: hash the copied range when the covering chunk has no usable ETag

TUS and mount writes leave chunks without an ETag, so a whole-range
UploadPartCopy would otherwise save the part with an empty Md5 and no
ExtETagKey, and multipart completion rejects the part.

* s3api: build the md5 source entry without copying the proto message

* s3api: drop the issue reference from the part-copy etag test

* s3api: route inline-content sources through the plaintext part copy

The raw-byte path walks entry.GetChunks() only, so a ranged
UploadPartCopy of an inline-Content source (filer HTTP or mount writes)
produced a zero-filled part. Send those through
copyObjectPartViaReencryption, which streams the real bytes and stamps
their MD5 on the part.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: store the empty-part MD5 on writeEmptyCopyPart entries

The slow path's empty part returned the right CopyPartResult ETag but
saved no Attributes.Md5 or ExtETagKey, so ListParts and the completed
object's composite ETag still fell back to a chunk-derived value.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 13:46:11 -07:00
a3e97ec709 s3: apply Range to HeadObject (#11856)
* s3: apply Range to HeadObject

* s3: apply Range to a HEAD of a stored directory key

Directory-key entries answer through serveDirectoryContent before
HeadObjectHandler's range branch, so a ranged HEAD returned 200 and the
full length.

* s3: keep the missing-directory check for plain directories only

A stored directory-key marker is an object, so a range on its empty body
answers 416 like on any other zero-byte object.

* s3: let partNumber keep its priority over Range on a directory key HEAD

* s3: serve ranged GETs of a stored directory key

The same branch now applies the validated range to the inline content on
GET, so a directory-key object answers 206 with the selected bytes like
any other object.

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-10-21 13:37:00 -07:00
Lisandro PinGitHubClaude Opus 5.5Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Chris Lu
23d8c62844 volume: route lookups to the leader master only (#11888)
* volume: route lookups to the leader master only (#11887)

After a leader change, the volume server kept sending volume and EC
shard lookups to masters that were not the leader:

* currentMaster was set as soon as a heartbeat stream opened, before
  the peer said whether it was leader, and kept a follower through the
  redirect and its 3s sleep. A follower answers LookupVolume from its
  own vid cache, which right after an election can be empty, so reads
  went 404 rather than failing over.
* EC lookups used Store.MasterAddress, a second, unlocked copy that the
  heartbeat loop set before each dial and cleared on errors, so EC and
  plain reads could disagree on the master or have none.
* the receive goroutine called stream.Send itself when the volume
  options changed, racing the send loop, and exited silently on
  failure, after which leader redirects were no longer read.

Publish currentMaster only once a master is known to be leader: the
peer answered a new stream without naming a leader, which the leader
always does, or a follower named the leader. Compare redirects against
the connected peer. Replace Store.MasterAddress with MasterFn, backed
by getCurrentMaster. Have the receiver ask the send loop for the extra
heartbeat instead of sending it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume: ask the masters for the leader after losing the heartbeat

When the heartbeat stream to the leader failed, currentMaster kept the
lost master until a new stream answered, so lookups on a cache miss
kept dialing it through the retry delay even with a new leader already
elected.

On a regular heartbeat error, ask the configured masters for the raft
leader and publish it, within the retry delay rather than on top of it.
Without an answer the last known leader stays: clearing it would only
trade a dead address for an empty one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: stop the heartbeat on a failed assertion

The leader test closed stopChan only once every assertion had passed,
so a failure left the stream open and the fake master's GracefulStop
waited for the whole test run's timeout.

Start the heartbeat through a helper whose cleanup stops it, safe to
call twice, and waits for it to exit. It runs before the cleanups
registered earlier, so the stream is closed before the store and the
fake master go away. Both heartbeat tests use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume: clear the leader when no master names one

When the heartbeat lost its master and no configured master named a
leader, refreshLeaderFromPeers kept the lost one. That master is dead
or no longer leader, and a follower answers LookupVolume from its own
cache instead of refusing, so lookups could return locations no leader
vouched for.

Clear currentMaster instead. Lookups then fail fast ("missing address")
until a leader is known again, rather than reading a follower's cache
or waiting on a dial to a dead master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume: keep volumeSizeLimit 64-bit aligned on 32-bit builds

MasterFn (a func, 4 bytes on 386) replaced MasterAddress (a string, 8
bytes) ahead of volumeSizeLimit and moved it to a 4-byte-aligned
offset, where atomic.LoadUint64 panics with an unaligned 64-bit
operation. The atomic.Uint64 type carries the alignment itself.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* volume: publish the answering leader before a duplicate-uuid retry

Read the leader field first on each heartbeat reply. A follower reply
carries nothing else, so it no longer runs through the option updates;
and a leader that rejects the registration over duplicate uuids still
answers lookups, so it stays the lookup target through the retry wait
instead of leaving the previous master in place.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* volume: publish the master only once it is known leader (rust)

* volume: ask the masters for the leader after losing the heartbeat (rust)

* volume: read the leader field first on each heartbeat reply (rust)

* volume: drop the seed fallback from ec shard lookups (rust)

cached_lookup_ec_shard_locations fell back to the static first seed when
no leader was published, so a mid-election EC lookup could land on a
follower and read its stale shard map. Error out like Go's
masterAddress() returning "" instead, via a current_master() getter
matching getCurrentMaster.

* volume: send volume lookups to the confirmed leader, not the seed (rust)

Every HTTP lookup path — proxy/redirect targets, replicated writes and
deletes, batch chunk deletes, remote chunk reads — dialed the static
first seed, so after a failover they kept asking a follower whose vid
map can be stale. Go routes all of these through GetMaster; switch the
lookup calls and their leader-known gates to current_master().

* volume: mark volumes readonly on the leader, retrying the named one (rust)

notify_master_volume_readonly fell back to the seed when no leader was
published, which the peer answers with "Not current leader". It now
uses only the confirmed leader, skips the call in masterless mode, and
on NotLeader asks the seed masters who leads and retries there — the
same path Go's notifyMasterVolumeReadonly takes. The peer leader query
is shared with refresh_leader_from_peers as lookup_leader_from_peers.

* volume: ask the confirmed leader for copy prealloc options (rust)

VolumeCopy's preallocate query went to the static seed; Go issues it
through GetMaster. Route it through current_master() too — when no
leader is known the lookup fails fast and the copy proceeds without
preallocating, as before.

* volume: keep the announced master port forms, not just http (rust)

publish_current_master stripped to http form, so a leader configured
with an explicit gRPC port (host:9333.19999) was redialed on the
default offset. Store the announced host:port[.grpcPort] form like Go's
currentMaster; callers pick to_http_address / to_grpc_address /
parse_grpc_address per use, and Ping admission compares on the http
form.

* volume: fail replicated writes and deletes when no leader is known (rust)

Gating replication on a known leader meant a write could be
acknowledged on fewer copies than required, and a delete could leave
the data readable on the other replicas. Go fails the request when the
replica-location lookup cannot run; drop the gate so the empty-leader
lookup errors the same way.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-10-21 13:36:05 -07:00
Chris LuGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
4bdbfbaaa8 s3tables: judge an absent tag target by its canonical ARN (#11912)
* s3tables: judge an absent tag target by its canonical ARN

authorizeAbsentTarget matched the bucket policy's Deny statements
against the request's raw resource ARN. The ARN parsers accept any
account and region but resolve the target by name, so a caller with an
identity grant could reformat the ARN, miss the Deny match, and learn
whether a denied name exists — the oracle the absent-target check was
meant to close. Rebuild the ARN from the resolved names and the real
bucket owner and evaluate the Deny against that, matching what the
existing-target path checks.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3tables: cover a foreign-account ARN on an absent denied table

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3tables: keep the absent-target deny check behind a supplied ARN

Callers that pass no resource ARN (ListTables, ListViews,
CreateNamespace) must reach CheckTableOrBucketPermission's admin
shortcut unchanged; running the bucket-policy Deny match first would
deny the admin lists those callers take.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 13:31:04 -07:00
e73c23a4ee fix(s3): set Access-Control-Allow-Credentials on CORS responses (#11886)
* fix(s3): set Access-Control-Allow-Credentials on CORS responses (fixes #11885)

* fix(s3): expand wildcard exposed headers for credentialed CORS responses

* fix(s3): keep wildcard in exposed headers for non-credentialed CORS requests

* fix(s3): expose x-amz-tagging-count on credentialed CORS responses

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-10-21 13:10:49 -07:00
Chris LuGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
8c3d8fcea0 s3tables: keep absent-target answers identical to denied ones (#11905)
* s3tables: authorize before revealing a list or tag target exists

ListTables, ListViews and the tag operations read the target's catalog
metadata before any permission check, so a denied caller could tell an
existing name (403 or the resource's own error) from an absent one (an
empty list, or 404). Evaluate the operation's permission with whatever
bucket context could be read and answer a denied caller the same way for
absent, damaged and denied targets.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3tables: answer bucket reads uniformly when the entry is not a table bucket

A plain S3 bucket directory answers GetTableBucket and ListNamespaces with
500 attribute-not-found while a missing name gets 404, and a denied
CreateNamespace is 403 while a missing bucket is 404. Run the permission
check before the absence answer and read not-a-table-bucket as
NoSuchTableBucket.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3tables: cover absent-target answers matching denied ones

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 13:07:47 -07:00
68 changed files with 3631 additions and 470 deletions
+47 -16
View File
@@ -53,6 +53,12 @@ fn needle_not_found(needle_id: NeedleId) -> Status {
Status::not_found(format!("needle not found {}", needle_id.0))
}
/// Mirrors Go's `isNotLeaderErr`: the master returns raft's NotLeaderError,
/// which arrives as an Unknown-status message.
fn is_not_leader_status(e: &Status) -> bool {
e.message().contains("Not current leader")
}
/// Deadline from the caller's `grpc-timeout` header (TimeoutValue +
/// H|M|S|m|u|n unit, per the gRPC-over-HTTP2 spec). Tonic's GrpcTimeout layer
/// drops an over-deadline handler future anyway; parsing it here lets a queued
@@ -344,22 +350,46 @@ impl VolumeGrpcService {
is_readonly: bool,
) -> Result<(), Status> {
// VolumeMarkReadonly mutates raft-replicated master topology, so it must
// reach the leader. Prefer the live leader the heartbeat is talking to
// (current_master_url), falling back to the static seed before the first
// heartbeat — mirrors store_ec.rs and Go's vs.GetMaster(). Sending it to
// the static seed fails "not current leader" after any master failover.
let master_url = {
let live = self.state.current_master_url.read().await.clone();
if !live.is_empty() {
live
} else {
self.state.master_url.clone()
}
};
// reach the leader the heartbeat confirmed. On "Not current leader",
// ask the seed masters who leads and retry there — mirrors Go's
// notifyMasterVolumeReadonly.
let master_url = self.state.current_master().await;
if master_url.is_empty() {
return Ok(());
if !self.state.has_master {
return Ok(());
}
return Err(Status::unavailable("no master leader known"));
}
let grpc_addr = parse_grpc_address(&master_url).map_err(|e| {
match self
.volume_mark_readonly_on_master(&master_url, info, is_readonly)
.await
{
Err(e) if is_not_leader_status(&e) => {
let leader = super::heartbeat::lookup_leader_from_peers(
&self.state.master_urls,
self.state.outgoing_grpc_tls.as_ref(),
)
.await
.ok_or_else(|| {
Status::unavailable(format!(
"master {} rejected mark-readonly and no peer named a leader",
master_url
))
})?;
self.volume_mark_readonly_on_master(&leader, info, is_readonly)
.await
}
result => result,
}
}
async fn volume_mark_readonly_on_master(
&self,
master_url: &str,
info: &MasterVolumeInfo,
is_readonly: bool,
) -> Result<(), Status> {
let grpc_addr = parse_grpc_address(master_url).map_err(|e| {
Status::internal(format!("invalid master address {}: {}", master_url, e))
})?;
let channel = connect_channel(
@@ -5776,7 +5806,8 @@ impl VolumeCopyJob {
(&self.dest.data_base_name, self.dest.has_remote_dat);
let mut preallocate_size: i64 = 0;
if !has_remote_dat {
let grpc_addr = super::heartbeat::to_grpc_address(&state.master_url);
let master = state.current_master().await;
let grpc_addr = super::heartbeat::to_grpc_address(&master);
// Race the master-configuration RPC against the caller's
// response channel: a stalled master (or a slow leader
// election) would otherwise hold the task and its .note
@@ -5801,7 +5832,7 @@ impl VolumeCopyJob {
}
}
Err(e) => {
tracing::warn!("get master {} configuration: {}", state.master_url, e);
tracing::warn!("get master {} configuration: {}", master, e);
}
}
+12 -9
View File
@@ -603,7 +603,7 @@ async fn batch_delete_file_ids(
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.master_url,
&state.current_master().await,
volume_id,
)
.await
@@ -675,7 +675,7 @@ async fn do_replicated_request(
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.master_url,
&state.current_master().await,
vid,
)
.await
@@ -832,7 +832,7 @@ async fn proxy_or_redirect_to_target(
let locations = match lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.master_url,
&state.current_master().await,
vid.0,
)
.await
@@ -1238,7 +1238,7 @@ async fn proxy_missing_volume(
let query_string = uri.query().unwrap_or("").to_string();
if is_proxied_query(&query_string)
|| state.read_mode == ReadMode::Local
|| state.master_url.is_empty()
|| state.current_master().await.is_empty()
{
return StatusCode::NOT_FOUND.into_response();
}
@@ -1282,7 +1282,7 @@ async fn check_download_limit(
// Go tries proxy to replica once before the wait loop
// (checkDownloadLimit); it does not retry on each wakeup.
let should_try_replica =
!is_proxied_query(&query_string) && !state.master_url.is_empty() && {
!is_proxied_query(&query_string) && !state.current_master().await.is_empty() && {
let store = state.store.read().unwrap();
store
.find_volume(vid)
@@ -2960,7 +2960,9 @@ pub async fn post_handler(
// Replicate to remote volume servers if this volume has replicas.
// Matches Go's GetWritableRemoteReplications: skip if copy_count == 1.
if !is_replicate && write_result.is_ok() && !state.master_url.is_empty() {
// No leader-known gate: when the lookup cannot run, the write must fail
// rather than be acknowledged on fewer copies than required.
if !is_replicate && write_result.is_ok() {
let needs_replication = {
let store = state.store.read().unwrap();
store
@@ -3358,7 +3360,7 @@ pub async fn delete_handler(
};
let is_replicate = del_query.split('&').any(|p| p == "type=replicate");
if !is_replicate && delete_result.is_ok() && !state.master_url.is_empty() {
if !is_replicate && delete_result.is_ok() {
let needs_replication = {
let store = state.store.read().unwrap();
store
@@ -4232,7 +4234,7 @@ async fn read_remote_chunk_needle(
let locations = lookup_volume(
&state.http_client,
&state.outgoing_http_scheme,
&state.master_url,
&state.current_master().await,
vid.0,
)
.await?;
@@ -6671,7 +6673,8 @@ mod tests {
let s = Arc::get_mut(&mut state).unwrap();
// Empty self_url makes `url.contains(self_url)` true for every peer.
s.self_url = "198.51.100.8:8080".to_string();
s.master_url = master_url;
s.master_url = master_url.clone();
s.current_master_url = tokio::sync::RwLock::new(master_url);
state
}
+71 -16
View File
@@ -155,7 +155,11 @@ pub async fn run_heartbeat_with_state(
}
PostAction::SleepPulse => {
new_leader = None;
tokio::time::sleep(pulse).await;
// The lookup spends part of the retry delay rather than
// adding to it.
let retry_at = tokio::time::Instant::now() + pulse;
refresh_leader_from_peers(&config, &state, pulse).await;
tokio::time::sleep_until(retry_at).await;
}
}
@@ -254,10 +258,56 @@ pub async fn try_get_master_configuration(
Ok(resp.into_inner())
}
/// Publish the leader the configured masters report, so lookups stop going
/// to the master the heartbeat just lost while the loop retries. Without an
/// answer inside `timeout` the published master is cleared: the lost master
/// is dead or no longer leader, and a follower answers lookups from its own
/// cache rather than refusing them. Mirrors Go's refreshLeaderFromPeers.
async fn refresh_leader_from_peers(
config: &HeartbeatConfig,
state: &Arc<VolumeServerState>,
timeout: Duration,
) {
let lookup = lookup_leader_from_peers(
&config.master_addresses,
state.outgoing_grpc_tls.as_ref(),
);
match tokio::time::timeout(timeout, lookup).await {
Ok(Some(leader)) => publish_current_master(state, &leader).await,
_ => state.current_master_url.write().await.clear(),
}
}
/// Ask each seed master for the raft leader and return the first answer, or
/// None when none replies with one. Mirrors Go's
/// `operation.LookupRaftLeaderMaster`.
pub async fn lookup_leader_from_peers(
master_addrs: &[String],
tls: Option<&super::grpc_client::OutgoingGrpcTlsConfig>,
) -> Option<String> {
for master_addr in master_addrs {
let grpc_addr = to_grpc_address(master_addr);
if let Ok(resp) = try_get_master_configuration(&grpc_addr, tls).await
&& !resp.leader.is_empty()
{
return Some(resp.leader);
}
}
None
}
fn is_stopping(state: &VolumeServerState) -> bool {
*state.is_stopping.read().unwrap()
}
/// Store the master lookups should go to, in the address form it was
/// announced in — `host:port` or `host:port.grpcPort` — so an explicit gRPC
/// port survives to the gRPC callers, mirroring Go's setCurrentMaster.
/// The heartbeat calls this only for a master known to lead.
async fn publish_current_master(state: &Arc<VolumeServerState>, master: &str) {
*state.current_master_url.write().await = master.to_string();
}
fn duplicate_uuid_retry_delay(retry_count: u32) -> Duration {
Duration::from_secs((1u64 << retry_count) * 2)
}
@@ -430,14 +480,6 @@ async fn do_heartbeat(
let mut response_stream = client.send_heartbeat(stream).await?.into_inner();
info!("Heartbeat stream established with {}", grpc_addr);
// Publish the master we're now talking to in canonical http host:port
// form so Ping admission can recognise it once a leader change moves us
// off the seed list. Mirrors Go's vs.setCurrentMaster(masterAddress).
{
let normalised = super::volume_server::to_http_address(current_master).into_owned();
let mut guard = state.current_master_url.write().await;
*guard = normalised;
}
if is_stopping(state) {
state.is_heartbeating.store(false, Ordering::Relaxed);
send_deregister_heartbeat(config, state, &tx).await;
@@ -451,13 +493,31 @@ async fn do_heartbeat(
volume_tick.tick().await;
ec_tick.tick().await;
let mut confirmed_leader = false;
loop {
tokio::select! {
resp = response_stream.message() => {
match resp {
Ok(Some(hb_resp)) => {
// Match Go ordering: DuplicatedUuids first, then volume
// options, then leader redirect.
// A reply names the leader or comes from it, so it
// settles where lookups go before anything else on the
// reply is handled: a follower reply carries no other
// fields, and a leader stays the lookup target even
// when it rejects this registration. Mirrors Go.
if !hb_resp.leader.is_empty() {
if super::volume_server::to_http_address(current_master)
!= super::volume_server::to_http_address(&hb_resp.leader)
{
publish_current_master(state, &hb_resp.leader).await;
return Ok(Some(hb_resp.leader));
}
} else if !confirmed_leader {
// The leader answers every new stream without
// naming a leader, so this peer is the one lookups
// go to.
confirmed_leader = true;
publish_current_master(state, current_master).await;
}
if !hb_resp.duplicated_uuids.is_empty() {
let uuids = hb_resp.duplicated_uuids.clone();
let duplicate_dirs = off_runtime(config, state, move |_, state| {
@@ -502,11 +562,6 @@ async fn do_heartbeat(
if metrics_changed {
state.metrics_notify.notify_waiters();
}
// Match Go: only redirect if leader is non-empty AND
// different from the current master we're connected to.
if !hb_resp.leader.is_empty() && current_master != hb_resp.leader {
return Ok(Some(hb_resp.leader));
}
}
Ok(None) => return Ok(None),
Err(e) => return Err(Box::new(e)),
+2 -9
View File
@@ -1139,16 +1139,9 @@ async fn cached_lookup_ec_shard_locations(
state: &Arc<VolumeServerState>,
vid: VolumeId,
) -> io::Result<HashMap<ShardId, Vec<String>>> {
let master = {
let live = state.current_master_url.read().await.clone();
if !live.is_empty() {
live
} else {
state.master_url.clone()
}
};
let master = state.current_master().await;
if master.is_empty() {
return Err(io::Error::other("no master configured for ec shard lookup"));
return Err(io::Error::other("no master leader known for ec shard lookup"));
}
let grpc_addr =
+16 -6
View File
@@ -86,7 +86,8 @@ pub struct VolumeServerState {
/// If true, FetchAndWriteNeedle skips remote S3 endpoint validation,
/// allowing arbitrary (incl. loopback / link-local / metadata) hosts.
pub allow_untrusted_remote_endpoints: bool,
/// First master address for volume lookups (e.g., "localhost:9333").
/// First configured master address (e.g., "localhost:9333"). A seed, not a
/// lookup target: lookups go to `current_master_url`.
pub master_url: String,
/// Seed master addresses for UI rendering.
pub master_urls: Vec<String>,
@@ -94,10 +95,12 @@ pub struct VolumeServerState {
/// Built once at construction so Ping admission stays O(1). Mirrors
/// Go's `seedMasterSet` on `VolumeServer`.
pub seed_master_set: std::collections::HashSet<String>,
/// Current master this server is heartbeating with, in canonical http
/// `host:port` form. Empty when no heartbeat connection is active. The
/// heartbeat goroutine writes; admission reads — the lock keeps them
/// from racing on a leader change. Mirrors Go's `currentMaster` plus
/// Master this server takes as leader, in its announced
/// `host:port[.grpcPort]` form — callers pick http or gRPC form per use.
/// Empty until the heartbeat confirms one — a peer that answered a
/// stream without naming a leader, or the leader a follower named — and
/// cleared again when no master reports one. The heartbeat writes;
/// admission and lookups read. Mirrors Go's `currentMaster` plus
/// `currentMasterLock`.
pub current_master_url: tokio::sync::RwLock<String>,
/// This server's own address (ip:port) for filtering self from lookup results.
@@ -140,6 +143,13 @@ pub struct VolumeServerState {
}
impl VolumeServerState {
/// The master lookups should go to: the leader the heartbeat confirmed,
/// or empty while none is known, in its announced `host:port[.grpcPort]`
/// form. Mirrors Go's `vs.getCurrentMaster()`.
pub async fn current_master(&self) -> String {
self.current_master_url.read().await.clone()
}
/// Check if the server is in maintenance mode; return gRPC error if so.
pub fn check_maintenance(&self) -> Result<(), tonic::Status> {
if self.maintenance.load(Ordering::Relaxed) {
@@ -209,7 +219,7 @@ impl VolumeServerState {
return false;
}
let current = self.current_master_url.read().await.clone();
if !current.is_empty() && current == key {
if !current.is_empty() && to_http_address(&current) == key {
return true;
}
self.seed_master_set.contains(&key)
+124 -7
View File
@@ -4185,8 +4185,10 @@ impl Volume {
let _sync_guard = self.data_file_sync_lock.lock().unwrap();
let _guard = self.data_file_access_control.write_lock();
let (was_no_write_or_delete, was_no_write_can_delete) = self.no_write_flags();
// Remote-tiered volumes must stay no_write_can_delete regardless of marks.
let no_write_can_delete = was_no_write_can_delete && self.has_remote_file();
// A remote-tiered volume keeps the delete-only mode a load derives: its
// .dat cannot take appends, so "writable" resolves to can-delete rather
// than advertising writable with no local data file.
let no_write_can_delete = self.has_remote_file();
self.no_write.store(
if no_write_can_delete {
NO_WRITE_CAN_DELETE
@@ -4300,21 +4302,70 @@ impl Volume {
}
f(&mut self.volume_info.write().unwrap().files);
let mut io = self.io.write().unwrap();
self.refresh_remote_write_mode(&mut io)
self.refresh_remote_write_mode(&mut io, true)
}
/// Go's MarkTieredDeletable: a live tier-up lifts the upload-time
/// read-only mark, except on a replicated volume — the shell copies the
/// uploaded replica's .idx and .vif to the other replicas after the
/// upload, and a tombstone written before that copy would reach one
/// replica only — or under an I/O quarantine, which is not the tier
/// upload's to lift. A refused volume keeps its flags until the next
/// load, as the Go volume does.
fn mark_tiered_deletable(&self) -> bool {
if self
.super_block
.read()
.unwrap()
.replica_placement
.has_replication()
{
return false;
}
if !self.has_remote_file() {
return false;
}
// Go takes noWriteLock around the quarantine check and the flag
// change; here a compare-exchange gives the same ordering — a
// heartbeat refusal landing between the check and the swap fails the
// swap, and the retry sees the quarantine mark the heartbeat set
// first.
loop {
if self.should_quarantine() {
return false;
}
let flags = self.no_write.load(Ordering::Acquire);
let promoted = (flags & !NO_WRITE_OR_DELETE) | NO_WRITE_CAN_DELETE;
if self
.no_write
.compare_exchange(flags, promoted, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return true;
}
}
}
/// Recompute the Go-style write/delete mode from the current remote tier
/// state, and bring the needle map in line with it — a volume that stops
/// being remote also stops using the read-only sorted index.
///
/// `live` is a tier transition on a running volume: the promotion then
/// goes through mark_tiered_deletable, Go's MarkTieredDeletable, while a
/// load promotes every tiered volume unconditionally as Go's load() does.
///
/// If the map cannot be rebuilt the volume is pinned read-only rather than
/// published as writable with an index that rejects every put; a restart
/// recovers it.
fn refresh_remote_write_mode(&self, io: &mut VolumeIo) -> Result<(), VolumeError> {
fn refresh_remote_write_mode(&self, io: &mut VolumeIo, live: bool) -> Result<(), VolumeError> {
let has_remote = self.has_remote_file();
if has_remote {
// remote-tiered: no writes, deletes allowed
self.no_write.store(NO_WRITE_CAN_DELETE, Ordering::Release);
if !live {
self.no_write.store(NO_WRITE_CAN_DELETE, Ordering::Release);
} else {
self.mark_tiered_deletable();
}
} else if !self.volume_info.read().unwrap().read_only_can_delete {
// only clear the remoteness-derived flag, not an operator mark
self.set_no_write_flag(NO_WRITE_CAN_DELETE, false);
@@ -4363,7 +4414,7 @@ impl Volume {
}
}
*self.volume_info.write().unwrap() = pb_info;
self.refresh_remote_write_mode(io)?;
self.refresh_remote_write_mode(io, false)?;
{
let mut vi = self.volume_info.write().unwrap();
if vi.version == 0 {
@@ -4394,7 +4445,7 @@ impl Volume {
self.set_no_write_flag(NO_WRITE_OR_DELETE, true);
}
*self.volume_info.write().unwrap() = pb_info;
self.refresh_remote_write_mode(io)?;
self.refresh_remote_write_mode(io, false)?;
{
let mut vi = self.volume_info.write().unwrap();
if vi.version == 0 {
@@ -9701,6 +9752,72 @@ mod tests {
assert!(!v.is_no_write_or_delete());
}
/// Go's MarkTieredDeletable leaves a replicated volume's upload-time
/// read-only mark alone: the shell still has to copy the uploaded
/// replica's .idx to the others, and a tombstone written before that copy
/// would reach one replica only.
#[test]
fn test_update_remote_files_keeps_a_replicated_volume_refusing_deletes() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let v = make_test_volume(dir);
v.super_block.write().unwrap().replica_placement =
crate::storage::super_block::ReplicaPlacement::from_string("001").unwrap();
v.set_no_write_or_delete(true); // the shell's pre-upload mark
v.update_remote_files(|files| files.push(Default::default()))
.unwrap();
assert!(v.has_remote_file());
assert!(
v.is_no_write_or_delete(),
"a replicated volume keeps refusing deletes until its next load"
);
assert!(!v.is_no_write_can_delete());
}
/// An I/O quarantine is not the tier upload's to lift: a live tier-up
/// leaves a quarantined volume's read-only mark alone, as Go's
/// MarkTieredDeletable does.
#[test]
fn test_update_remote_files_keeps_an_io_quarantine() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let v = make_test_volume(dir);
v.set_no_write_or_delete(true);
v.mark_io_quarantined();
v.update_remote_files(|files| files.push(Default::default()))
.unwrap();
assert!(v.has_remote_file());
assert!(v.is_no_write_or_delete());
assert!(!v.is_no_write_can_delete());
}
/// A refused tiered volume keeps OR_DELETE without CAN_DELETE; marking it
/// writable must resolve to the delete-only mode a load derives — the
/// remote .dat cannot take appends — not drop every protection.
#[test]
fn test_set_writable_on_a_refused_tiered_volume_stays_delete_only() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let v = make_test_volume(dir);
v.super_block.write().unwrap().replica_placement =
crate::storage::super_block::ReplicaPlacement::from_string("001").unwrap();
v.set_no_write_or_delete(true);
v.update_remote_files(|files| files.push(Default::default()))
.unwrap();
assert!(v.is_no_write_or_delete());
v.set_writable().unwrap();
assert!(v.is_read_only());
assert!(!v.no_write_flags().0);
assert!(v.no_write_flags().1);
}
// Tier-down clears the remote mode and publishes the volume as writable. The
// map it booted with is the read-only sorted one, whose put always fails, so
// without a rebuild the first write appends to .dat and then cannot be
@@ -0,0 +1,139 @@
package s3tables
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newObjectClient(t *testing.T) *s3.Client {
t.Helper()
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion(testRegion),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(testAccessKey, testSecretKey, "")),
)
require.NoError(t, err)
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(sharedCluster.s3Endpoint)
o.UsePathStyle = true
})
}
func apiErrorCode(err error) string {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
return apiErr.ErrorCode()
}
return ""
}
// The external-volume verification object round-trips at a table bucket's
// root the way the check uses it: PUT, HEAD, GET, list, DELETE. Any other key
// at the root is still refused.
func TestTableBucketExternalVolumeVerificationRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ctx := context.Background()
tables := NewS3TablesClient(sharedCluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
bucket := "verify-" + randomString(8)
created, err := tables.CreateTableBucket(bucket, nil)
require.NoError(t, err)
defer tables.DeleteTableBucket(created.ARN)
client := newObjectClient(t)
body := []byte("verification payload!") // 21 bytes
key := fmt.Sprintf("verify_%d_%d", time.Now().UnixMilli(), 1081846483)
// the control: an ordinary key at the root is refused
_, err = client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String("notes.parquet"), Body: bytes.NewReader(body)})
require.Error(t, err)
assert.Equal(t, "AccessDenied", apiErrorCode(err))
_, err = client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(key), Body: bytes.NewReader(body), ContentLength: aws.Int64(int64(len(body)))})
require.NoError(t, err)
head, err := client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)})
require.NoError(t, err)
assert.Equal(t, int64(len(body)), aws.ToInt64(head.ContentLength))
got, err := client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)})
require.NoError(t, err)
data, err := io.ReadAll(got.Body)
got.Body.Close()
require.NoError(t, err)
assert.Equal(t, body, data)
list, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(bucket), Prefix: aws.String("verify_")})
require.NoError(t, err)
var keys []string
for _, o := range list.Contents {
keys = append(keys, aws.ToString(o.Key))
}
assert.Equal(t, []string{key}, keys)
_, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)})
require.NoError(t, err)
_, err = client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)})
require.Error(t, err, "the verification object must be gone after DELETE")
}
// A namespace can carry a name of the verification key's shape. The key
// then names the namespace's directory: a PUT or DELETE through it must not
// replace or remove the namespace.
func TestTableBucketVerificationKeyLeavesNamespaceAlone(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ctx := context.Background()
tables := NewS3TablesClient(sharedCluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
bucket := "verify-ns-" + randomString(8)
created, err := tables.CreateTableBucket(bucket, nil)
require.NoError(t, err)
defer tables.DeleteTableBucket(created.ARN)
client := newObjectClient(t)
body := []byte("verification payload!")
listed := func(name string) bool {
resp, err := tables.ListNamespaces(created.ARN, "", "", 0)
require.NoError(t, err)
for _, ns := range resp.Namespaces {
if len(ns.Namespace) == 1 && ns.Namespace[0] == name {
return true
}
}
return false
}
for i, op := range []string{"PUT", "DELETE"} {
t.Run(op, func(t *testing.T) {
name := fmt.Sprintf("verify_%d_%d", time.Now().UnixMilli(), 7+i)
_, err := tables.CreateNamespace(created.ARN, []string{name})
require.NoError(t, err)
defer tables.DeleteNamespace(created.ARN, []string{name})
require.True(t, listed(name), "setup: the namespace is listed")
if op == "PUT" {
_, err = client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(name), Body: bytes.NewReader(body), ContentLength: aws.Int64(int64(len(body)))})
assert.Error(t, err, "a PUT over a namespace must be refused")
} else {
_, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String(name)})
assert.Error(t, err, "a DELETE of a namespace must be refused")
}
_, err = tables.GetNamespace(created.ARN, []string{name})
assert.NoError(t, err, "the namespace survives the %s", op)
assert.True(t, listed(name), "the namespace is still listed after the %s", op)
})
}
}
+4
View File
@@ -28,6 +28,10 @@ region = "us-east-2"
bucket = "your_bucket_name" # an existing bucket
endpoint = ""
storage_class = "STANDARD_IA"
# optional HTTP client tuning for S3-compatible services:
# disable_http2 = false # true keeps the client on HTTP/1.1, one connection per concurrent request
# max_connections = 0 # cap on connections to the endpoint, 0 = no cap
# idle_connection_timeout_seconds = 0 # close idle connections sooner, 0 = Go default (90s)
# create this number of logical volumes if no more writable volumes
# count_x means how many copies of data.
+13
View File
@@ -81,3 +81,16 @@ workers = 5 # optional: concurrent worker
buffer_size = 10000 # optional: event buffer size (default: 10000, range: 100-1000000)
# event_types = ["create", "update", "delete", "rename"] # optional: filter by event types (default: all)
# path_prefixes = ["/important", "/data"] # optional: filter by path prefixes (default: all)
#
# Additional endpoints can be declared as named subsections, each with its own
# endpoint, filters, retry and worker settings, e.g.:
# [notification.webhook.file_ingress]
# enabled = true
# endpoint = "https://ingress.example.com/hook"
# event_types = ["create"]
# path_prefixes = ["/buckets/uploads/data"]
#
# [notification.webhook.metadata_extractor]
# enabled = true
# endpoint = "https://metadata.example.com/hook"
# path_prefixes = ["/buckets/repository"]
+20 -4
View File
@@ -26,10 +26,10 @@ func LoadConfiguration(config *util.ViperProxy, prefix string) {
return
}
validateOneEnabledQueue(config)
validateOneEnabledQueue(config, prefix)
for _, queue := range MessageQueues {
if config.GetBool(prefix + queue.GetName() + ".enabled") {
if queueEnabled(config, prefix, queue) {
if err := queue.Initialize(config, prefix+queue.GetName()+"."); err != nil {
glog.Fatalf("Failed to initialize notification for %s: %+v",
queue.GetName(), err)
@@ -42,10 +42,26 @@ func LoadConfiguration(config *util.ViperProxy, prefix string) {
}
func validateOneEnabledQueue(config *util.ViperProxy) {
// queueEnabled reports whether a queue is switched on — by the flat
// "<prefix><name>.enabled" flag or, for queues accepting named sub-instances
// ([notification.<queue>.<target>]), by any enabled subconfig.
func queueEnabled(config util.Configuration, prefix string, queue MessageQueue) bool {
key := prefix + queue.GetName() + "."
if config.GetBool(key + "enabled") {
return true
}
if q, ok := queue.(interface {
HasEnabledSubconfig(util.Configuration, string) bool
}); ok {
return q.HasEnabledSubconfig(config, key)
}
return false
}
func validateOneEnabledQueue(config *util.ViperProxy, prefix string) {
enabledQueue := ""
for _, queue := range MessageQueues {
if config.GetBool(queue.GetName() + ".enabled") {
if queueEnabled(config, prefix, queue) {
if enabledQueue == "" {
enabledQueue = queue.GetName()
} else {
-7
View File
@@ -3,7 +3,6 @@ package webhook
import (
"fmt"
"net/url"
"strconv"
"github.com/seaweedfs/seaweedfs/weed/notification"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
@@ -30,12 +29,6 @@ func (e eventType) valid() bool {
return notification.ValidEventType(string(e))
}
var (
pubSubHandlerNameTemplate = func(n int) string {
return "webhook_handler_" + strconv.Itoa(n)
}
)
type client interface {
sendMessage(message *webhookMessage) error
}
+220 -92
View File
@@ -4,6 +4,9 @@ import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/ThreeDotsLabs/watermill"
@@ -22,45 +25,145 @@ func init() {
notification.MessageQueues = append(notification.MessageQueues, &Queue{})
}
// target is one webhook endpoint with its own filter, http client and
// concurrency budget. The flat [notification.webhook] section is the unnamed
// target; each enabled [notification.webhook.<name>] subsection adds another.
type target struct {
name string
config *config
filter *filter
client client
sem chan struct{}
inbox chan *message.Message
}
func newTarget(name string, cfg *config) (*target, error) {
t := &target{
name: name,
config: cfg,
filter: newFilter(cfg),
sem: make(chan struct{}, cfg.nWorkers),
inbox: make(chan *message.Message, cfg.bufferSize),
}
hClient, err := newHTTPClient(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create webhook http client: %w", err)
}
t.client = hClient
return t, nil
}
func (t *target) topic() string {
if t.name == "" {
return pubSubTopicName
}
return pubSubTopicName + "_" + t.name
}
func (t *target) handlerName() string {
if t.name == "" {
return "webhook_handler"
}
return "webhook_handler_" + t.name
}
type Queue struct {
router *message.Router
routers []*message.Router
queueChannel *gochannel.GoChannel
config *config
client client
filter *filter
targets []*target
ctx context.Context
cancel context.CancelFunc
// Semaphore for controlling concurrent webhook requests
sem chan struct{}
}
func (w *Queue) GetName() string {
return queueName
}
// HasEnabledSubconfig reports whether any [notification.webhook.<name>]
// subsection is enabled, so the queue activates on named targets even when the
// flat [notification.webhook] section is absent.
func (w *Queue) HasEnabledSubconfig(configuration util.Configuration, prefix string) bool {
return len(namedTargetPrefixes(configuration, prefix)) > 0
}
// stringMapConfig is implemented by *util.ViperProxy; anything narrower simply
// gets no named-target discovery.
type stringMapConfig interface {
GetStringMap(key string) map[string]interface{}
}
func namedTargetPrefixes(configuration util.Configuration, prefix string) []string {
mg, ok := configuration.(stringMapConfig)
if !ok {
return nil
}
var prefixes []string
for k, v := range mg.GetStringMap(strings.TrimSuffix(prefix, ".")) {
if _, ok := v.(map[string]interface{}); !ok {
continue
}
p := prefix + k + "."
if !configuration.GetBool(p + "enabled") {
continue
}
prefixes = append(prefixes, p)
}
sort.Strings(prefixes)
return prefixes
}
func buildTargets(configuration util.Configuration, prefix string) ([]*target, error) {
var prefixes []string
if configuration.GetBool(prefix + "enabled") {
prefixes = append(prefixes, prefix)
}
prefixes = append(prefixes, namedTargetPrefixes(configuration, prefix)...)
var targets []*target
for _, p := range prefixes {
cfg := newConfigWithDefaults(configuration, p)
if err := cfg.validate(); err != nil {
return nil, err
}
t, err := newTarget(strings.TrimSuffix(strings.TrimPrefix(p, prefix), "."), cfg)
if err != nil {
return nil, err
}
targets = append(targets, t)
}
return targets, nil
}
func (w *Queue) SendMessage(key string, msg proto.Message) error {
eventNotification, ok := msg.(*filer_pb.EventNotification)
if !ok {
return nil
}
if w.filter != nil && !w.filter.shouldPublish(key, eventNotification) {
return nil
var firstErr error
for _, t := range w.targets {
if t.filter != nil && !t.filter.shouldPublish(key, eventNotification) {
continue
}
m := newWebhookMessage(key, msg)
if m == nil {
continue
}
wMsg, err := m.toWaterMillMessage()
if err != nil {
return err
}
wMsg.Metadata.Set("target", t.name)
select {
case t.inbox <- wMsg:
default:
if firstErr == nil {
firstErr = fmt.Errorf("webhook target %q buffer is full, dropping event for %s", t.name, key)
}
}
}
m := newWebhookMessage(key, msg)
if m == nil {
return nil
}
wMsg, err := m.toWaterMillMessage()
if err != nil {
return err
}
return w.queueChannel.Publish(pubSubTopicName, wMsg)
return firstErr
}
func (w *webhookMessage) toWaterMillMessage() (*message.Message, error) {
@@ -78,105 +181,128 @@ func (w *webhookMessage) toWaterMillMessage() (*message.Message, error) {
}
func (w *Queue) Initialize(configuration util.Configuration, prefix string) error {
c := newConfigWithDefaults(configuration, prefix)
if err := c.validate(); err != nil {
targets, err := buildTargets(configuration, prefix)
if err != nil {
return err
}
if len(targets) == 0 {
return fmt.Errorf("webhook endpoint is required")
}
return w.initialize(c)
return w.initialize(targets)
}
func (w *Queue) initialize(cfg *config) error {
func (w *Queue) initialize(targets []*target) error {
w.ctx, w.cancel = context.WithCancel(context.Background())
w.config = cfg
w.filter = newFilter(cfg)
w.targets = targets
// Initialize semaphore for controlling concurrent webhook requests
w.sem = make(chan struct{}, cfg.nWorkers)
hClient, err := newHTTPClient(cfg)
if err != nil {
return fmt.Errorf("failed to create webhook http client: %w", err)
}
w.client = hClient
if err = w.setupWatermillQueue(cfg); err != nil {
if err := w.setupWatermillQueue(targets); err != nil {
return fmt.Errorf("failed to setup watermill queue: %w", err)
}
if err = w.logDeadLetterMessages(); err != nil {
if err := w.logDeadLetterMessages(); err != nil {
return err
}
return nil
}
func (w *Queue) setupWatermillQueue(cfg *config) error {
func (w *Queue) setupWatermillQueue(targets []*target) error {
logger := watermill.NewStdLogger(false, false)
bufferSize := 0
for _, t := range targets {
if t.config.bufferSize > bufferSize {
bufferSize = t.config.bufferSize
}
}
pubSubConfig := gochannel.Config{
OutputChannelBuffer: int64(cfg.bufferSize),
OutputChannelBuffer: int64(bufferSize),
Persistent: false,
}
w.queueChannel = gochannel.NewGoChannel(pubSubConfig, logger)
router, err := message.NewRouter(
message.RouterConfig{
CloseTimeout: 60 * time.Second,
},
logger,
)
if err != nil {
return fmt.Errorf("failed to create router: %w", err)
}
w.router = router
var wg sync.WaitGroup
for _, t := range targets {
// One router per target so each keeps its own retry/backoff settings.
// Handlers subscribe to the target's own topic, so a message is
// delivered to an endpoint exactly once per matching target.
router, err := message.NewRouter(
message.RouterConfig{
CloseTimeout: 60 * time.Second,
},
logger,
)
if err != nil {
return fmt.Errorf("failed to create router: %w", err)
}
w.routers = append(w.routers, router)
retryMiddleware := middleware.Retry{
MaxRetries: cfg.maxRetries,
InitialInterval: time.Duration(cfg.backoffSeconds) * time.Second,
MaxInterval: time.Duration(cfg.maxBackoffSeconds) * time.Second,
Multiplier: 2.0,
RandomizationFactor: 0.3,
Logger: logger,
}.Middleware
retryMiddleware := middleware.Retry{
MaxRetries: t.config.maxRetries,
InitialInterval: time.Duration(t.config.backoffSeconds) * time.Second,
MaxInterval: time.Duration(t.config.maxBackoffSeconds) * time.Second,
Multiplier: 2.0,
RandomizationFactor: 0.3,
Logger: logger,
}.Middleware
poisonQueue, err := middleware.PoisonQueue(w.queueChannel, deadLetterTopic)
if err != nil {
return fmt.Errorf("failed to create poison queue: %w", err)
}
router.AddPlugin(plugin.SignalsHandler)
router.AddMiddleware(retryMiddleware, poisonQueue)
// Add a single handler to avoid duplicate message delivery.
// With gochannel's default behavior, each handler call creates
// a separate subscription, and all subscriptions receive their own copy of each message.
// Using a single handler ensures each webhook is sent only once.
// Concurrency is controlled via semaphore in handleWebhook based on nWorkers config.
router.AddConsumerHandler(
"webhook_handler",
pubSubTopicName,
w.queueChannel,
w.handleWebhook,
)
go func() {
// cancels the queue context so the dead letter logger exists in case context not canceled by the shutdown signal already
defer w.cancel()
if err := router.Run(w.ctx); err != nil && !errors.Is(err, context.Canceled) {
glog.Errorf("webhook pubsub worker stopped with error: %v", err)
poisonQueue, err := middleware.PoisonQueue(w.queueChannel, deadLetterTopic)
if err != nil {
return fmt.Errorf("failed to create poison queue: %w", err)
}
glog.Info("webhook pubsub worker stopped")
router.AddPlugin(plugin.SignalsHandler)
router.AddMiddleware(retryMiddleware, poisonQueue)
t := t
router.AddConsumerHandler(
t.handlerName(),
t.topic(),
w.queueChannel,
t.handleWebhook,
)
wg.Add(1)
go func() {
defer wg.Done()
if err := router.Run(w.ctx); err != nil && !errors.Is(err, context.Canceled) {
glog.Errorf("webhook pubsub worker stopped with error: %v", err)
}
glog.Info("webhook pubsub worker stopped")
}()
go w.forwardTargetMessages(t)
}
// the queue context lives until every target's worker has stopped so the
// dead letter logger keeps running while any of them is still up
go func() {
wg.Wait()
w.cancel()
}()
return nil
}
func (w *Queue) handleWebhook(msg *message.Message) error {
// forwardTargetMessages publishes each queued event to the target's topic in
// order. It owns the potentially blocking Publish call so a saturated target
// can neither stall the other targets nor the caller of SendMessage.
func (w *Queue) forwardTargetMessages(t *target) {
for {
select {
case msg := <-t.inbox:
if err := w.queueChannel.Publish(t.topic(), msg); err != nil {
glog.Errorf("webhook target %q failed to publish message: %v", t.name, err)
}
case <-w.ctx.Done():
return
}
}
}
func (t *target) handleWebhook(msg *message.Message) error {
// Acquire semaphore slot (blocks if at capacity)
w.sem <- struct{}{}
defer func() { <-w.sem }()
t.sem <- struct{}{}
defer func() { <-t.sem }()
var n filer_pb.EventNotification
if err := proto.Unmarshal(msg.Payload, &n); err != nil {
@@ -191,8 +317,8 @@ func (w *Queue) handleWebhook(msg *message.Message) error {
Notification: &n,
}
if err := w.client.sendMessage(webhookMsg); err != nil {
glog.Errorf("failed to send message to webhook %s: %v", webhookMsg.Key, err)
if err := t.client.sendMessage(webhookMsg); err != nil {
glog.Errorf("webhook target %q failed to send message for %s: %v", t.name, webhookMsg.Key, err)
return err
}
@@ -218,10 +344,12 @@ func (w *Queue) logDeadLetterMessages() error {
continue
}
key := "unknown"
targetName := ""
if msg.Metadata != nil {
if keyValue, exists := msg.Metadata["key"]; exists {
key = keyValue
}
targetName = msg.Metadata["target"]
}
payload := ""
if msg.Payload != nil {
@@ -232,7 +360,7 @@ func (w *Queue) logDeadLetterMessages() error {
payload = n.String()
}
}
glog.Errorf("received dead letter message: %s, key: %s", payload, key)
glog.Errorf("received dead letter message: %s, key: %s, target: %s", payload, key, targetName)
case <-w.ctx.Done():
return
}
+192 -95
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
@@ -170,6 +171,29 @@ func TestWebhookMessageSerialization(t *testing.T) {
}
}
func newQueueForTest(t *testing.T, cfg *config) *Queue {
t.Helper()
tgt, err := newTarget("", cfg)
if err != nil {
t.Fatalf("newTarget() error = %v", err)
}
q := &Queue{}
if err := q.initialize([]*target{tgt}); err != nil {
t.Fatalf("initialize() error = %v", err)
}
return q
}
func closeQueueForTest(q *Queue) {
if q.cancel != nil {
q.cancel()
}
time.Sleep(100 * time.Millisecond)
for _, r := range q.routers {
r.Close()
}
}
func TestQueueInitialize(t *testing.T) {
cfg := &config{
endpoint: "https://example.com/webhook",
@@ -182,32 +206,19 @@ func TestQueueInitialize(t *testing.T) {
bufferSize: 100,
}
q := &Queue{}
err := q.initialize(cfg)
if err != nil {
t.Errorf("Initialize() error = %v", err)
}
q := newQueueForTest(t, cfg)
defer closeQueueForTest(q)
defer func() {
if q.cancel != nil {
q.cancel()
}
time.Sleep(100 * time.Millisecond)
if q.router != nil {
q.router.Close()
}
}()
if q.router == nil {
if len(q.routers) != 1 {
t.Error("Expected router to be initialized")
}
if q.queueChannel == nil {
t.Error("Expected queueChannel to be initialized")
}
if q.client == nil {
if q.targets[0].client == nil {
t.Error("Expected client to be initialized")
}
if q.config == nil {
if q.targets[0].config == nil {
t.Error("Expected config to be initialized")
}
}
@@ -225,21 +236,8 @@ func TestQueueSendMessage(t *testing.T) {
bufferSize: 10,
}
q := &Queue{}
err := q.initialize(cfg)
if err != nil {
t.Fatalf("Failed to initialize queue: %v", err)
}
defer func() {
if q.cancel != nil {
q.cancel()
}
time.Sleep(100 * time.Millisecond)
if q.router != nil {
q.router.Close()
}
}()
q := newQueueForTest(t, cfg)
defer closeQueueForTest(q)
msg := &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{
@@ -247,8 +245,7 @@ func TestQueueSendMessage(t *testing.T) {
},
}
err = q.SendMessage("/test/path", msg)
if err != nil {
if err := q.SendMessage("/test/path", msg); err != nil {
t.Errorf("SendMessage() error = %v", err)
}
}
@@ -271,7 +268,7 @@ func TestQueueHandleWebhook(t *testing.T) {
}
client, _ := newHTTPClient(cfg)
q := &Queue{
tgt := &target{
client: client,
sem: make(chan struct{}, cfg.nWorkers),
}
@@ -287,8 +284,7 @@ func TestQueueHandleWebhook(t *testing.T) {
t.Fatalf("Failed to create watermill message: %v", err)
}
err = q.handleWebhook(wmMsg)
if err != nil {
if err := tgt.handleWebhook(wmMsg); err != nil {
t.Errorf("handleWebhook() error = %v", err)
}
}
@@ -307,21 +303,8 @@ func TestQueueEndToEnd(t *testing.T) {
bufferSize: 10,
}
q := &Queue{}
err := q.initialize(cfg)
if err != nil {
t.Fatalf("Failed to initialize queue: %v", err)
}
defer func() {
if q.cancel != nil {
q.cancel()
}
time.Sleep(100 * time.Millisecond)
if q.router != nil {
q.router.Close()
}
}()
q := newQueueForTest(t, cfg)
defer closeQueueForTest(q)
msg := &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{
@@ -329,8 +312,7 @@ func TestQueueEndToEnd(t *testing.T) {
},
}
err = q.SendMessage("/test/path", msg)
if err != nil {
if err := q.SendMessage("/test/path", msg); err != nil {
t.Errorf("SendMessage() error = %v", err)
}
}
@@ -347,33 +329,20 @@ func TestQueueRetryMechanism(t *testing.T) {
bufferSize: 10,
}
q := &Queue{}
err := q.initialize(cfg)
if err != nil {
t.Fatalf("Failed to initialize queue: %v", err)
}
defer func() {
if q.cancel != nil {
q.cancel()
}
time.Sleep(100 * time.Millisecond)
if q.router != nil {
q.router.Close()
}
}()
q := newQueueForTest(t, cfg)
defer closeQueueForTest(q)
// Verify that the queue is properly configured for retries
if q.config.maxRetries != 3 {
t.Errorf("Expected maxRetries=3, got %d", q.config.maxRetries)
if q.targets[0].config.maxRetries != 3 {
t.Errorf("Expected maxRetries=3, got %d", q.targets[0].config.maxRetries)
}
if q.config.backoffSeconds != 2 {
t.Errorf("Expected backoffSeconds=2, got %d", q.config.backoffSeconds)
if q.targets[0].config.backoffSeconds != 2 {
t.Errorf("Expected backoffSeconds=2, got %d", q.targets[0].config.backoffSeconds)
}
if q.config.maxBackoffSeconds != 10 {
t.Errorf("Expected maxBackoffSeconds=10, got %d", q.config.maxBackoffSeconds)
if q.targets[0].config.maxBackoffSeconds != 10 {
t.Errorf("Expected maxBackoffSeconds=10, got %d", q.targets[0].config.maxBackoffSeconds)
}
// Test that we can send a message (retry behavior is handled by Watermill middleware)
@@ -381,8 +350,7 @@ func TestQueueRetryMechanism(t *testing.T) {
NewEntry: &filer_pb.Entry{Name: "test.txt"},
}
err = q.SendMessage("/test/retry", msg)
if err != nil {
if err := q.SendMessage("/test/retry", msg); err != nil {
t.Errorf("SendMessage() error = %v", err)
}
}
@@ -419,21 +387,8 @@ func TestQueueNoDuplicateWebhooks(t *testing.T) {
bufferSize: 10,
}
q := &Queue{}
err := q.initialize(cfg)
if err != nil {
t.Fatalf("Failed to initialize queue: %v", err)
}
defer func() {
if q.cancel != nil {
q.cancel()
}
time.Sleep(100 * time.Millisecond)
if q.router != nil {
q.router.Close()
}
}()
q := newQueueForTest(t, cfg)
defer closeQueueForTest(q)
// Wait for router and subscriber to be fully ready
time.Sleep(200 * time.Millisecond)
@@ -444,8 +399,7 @@ func TestQueueNoDuplicateWebhooks(t *testing.T) {
},
}
err = q.SendMessage("/test/path", msg)
if err != nil {
if err := q.SendMessage("/test/path", msg); err != nil {
t.Errorf("SendMessage() error = %v", err)
}
@@ -607,3 +561,146 @@ func TestQueueSendMessageWithFilter(t *testing.T) {
})
}
}
type mapConfig struct {
values map[string]interface{}
}
func (m *mapConfig) GetString(key string) string {
v, _ := m.values[key].(string)
return v
}
func (m *mapConfig) GetBool(key string) bool {
v, _ := m.values[key].(bool)
return v
}
func (m *mapConfig) GetInt(key string) int {
v, _ := m.values[key].(int)
return v
}
func (m *mapConfig) GetStringSlice(key string) []string {
v, _ := m.values[key].([]string)
return v
}
func (m *mapConfig) SetDefault(key string, value interface{}) {}
func (m *mapConfig) GetStringMap(key string) map[string]interface{} {
v, _ := m.values[key].(map[string]interface{})
return v
}
func TestNamedTargets(t *testing.T) {
var hitsA, hitsB atomic.Int32
serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsA.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer serverA.Close()
serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitsB.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer serverB.Close()
cfg := &mapConfig{values: map[string]interface{}{
"notification.webhook": map[string]interface{}{
"file_ingress": map[string]interface{}{
"enabled": true,
"endpoint": serverA.URL,
"path_prefixes": []string{"/data/"},
},
"metadata_extractor": map[string]interface{}{
"enabled": true,
"endpoint": serverB.URL,
"path_prefixes": []string{"/repository/"},
},
"disabled_one": map[string]interface{}{
"enabled": false,
"endpoint": "https://example.com/never",
},
},
"notification.webhook.file_ingress.enabled": true,
"notification.webhook.file_ingress.endpoint": serverA.URL,
"notification.webhook.file_ingress.path_prefixes": []string{"/data/"},
"notification.webhook.metadata_extractor.enabled": true,
"notification.webhook.metadata_extractor.endpoint": serverB.URL,
"notification.webhook.metadata_extractor.path_prefixes": []string{"/repository/"},
}}
q := &Queue{}
if !q.HasEnabledSubconfig(cfg, "notification.webhook.") {
t.Fatal("expected named subconfig to be detected as enabled")
}
targets, err := buildTargets(cfg, "notification.webhook.")
if err != nil {
t.Fatalf("buildTargets() error = %v", err)
}
if len(targets) != 2 {
t.Fatalf("expected 2 targets, got %d", len(targets))
}
if targets[0].name != "file_ingress" || targets[1].name != "metadata_extractor" {
t.Fatalf("unexpected target names: %s, %s", targets[0].name, targets[1].name)
}
if err := q.initialize(targets); err != nil {
t.Fatalf("initialize() error = %v", err)
}
defer closeQueueForTest(q)
time.Sleep(200 * time.Millisecond)
msg := &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "f.txt"},
}
for _, key := range []string{"/data/f.txt", "/repository/f.txt", "/elsewhere/f.txt"} {
if err := q.SendMessage(key, msg); err != nil {
t.Errorf("SendMessage(%q) error = %v", key, err)
}
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) && (hitsA.Load() == 0 || hitsB.Load() == 0) {
time.Sleep(50 * time.Millisecond)
}
time.Sleep(200 * time.Millisecond)
if got := hitsA.Load(); got != 1 {
t.Errorf("expected file_ingress to receive 1 event, got %d", got)
}
if got := hitsB.Load(); got != 1 {
t.Errorf("expected metadata_extractor to receive 1 event, got %d", got)
}
}
// A disabled flat section keeps no target even when an endpoint is set —
// the scaffold ships exactly that combination, and a named target must not
// reactivate it.
func TestBuildTargetsDisabledFlat(t *testing.T) {
cfg := &mapConfig{values: map[string]interface{}{
"notification.webhook": map[string]interface{}{
"enabled": false,
"endpoint": "https://your-server.com/webhook",
"named": map[string]interface{}{
"enabled": true,
"endpoint": "https://example.com/hook",
},
},
"notification.webhook.enabled": false,
"notification.webhook.endpoint": "https://your-server.com/webhook",
"notification.webhook.named.enabled": true,
"notification.webhook.named.endpoint": "https://example.com/hook",
}}
targets, err := buildTargets(cfg, "notification.webhook.")
if err != nil {
t.Fatalf("buildTargets() error = %v", err)
}
if len(targets) != 1 || targets[0].name != "named" {
t.Fatalf("expected only the named target, got %v", targets)
}
}
+36
View File
@@ -25,6 +25,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3bucket"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
@@ -1680,6 +1681,7 @@ func (iam *IdentityAccessManagement) Auth(f http.HandlerFunc, action Action) htt
// succeeds but authZ denies, so the denied request still audits its
// requester; a failed authN resolves no identity at all.
identity, errCode, _ := iam.authRequestWithAuthType(r, action)
errCode = refineDenialForBucketName(r, identity, errCode)
if errCode != s3err.ErrNone {
glog.V(3).Infof("auth error: %v", errCode)
}
@@ -1719,6 +1721,7 @@ func (iam *IdentityAccessManagement) AuthPostPolicy(f http.HandlerFunc, action A
}
}
errCode = refineDenialForBucketName(r, identity, errCode)
if errCode != s3err.ErrNone {
glog.V(3).Infof("auth error: %v", errCode)
}
@@ -1727,6 +1730,39 @@ func (iam *IdentityAccessManagement) AuthPostPolicy(f http.HandlerFunc, action A
}
}
// refineDenialForBucketName answers 400 InvalidBucketName, as AWS S3 does,
// when an authenticated caller is denied on a bucket whose name breaks the S3
// naming rules. The rules are public and the answer depends on the name alone,
// so it tells the caller nothing about which buckets exist. Authentication
// failures (no identity resolved) and every other answer are left unchanged.
func refineDenialForBucketName(r *http.Request, identity *Identity, errCode s3err.ErrorCode) s3err.ErrorCode {
if errCode != s3err.ErrAccessDenied || identity == nil {
return errCode
}
bucket, _ := s3_constants.GetBucketAndObject(r)
if bucket == "" {
return errCode
}
if !isASCIIBucketName(bucket) || s3bucket.VerifyS3BucketName(bucket) != nil {
return s3err.ErrInvalidBucketName
}
return errCode
}
// isASCIIBucketName reports whether name uses only the characters S3 allows
// in a bucket name. VerifyS3BucketName checks with unicode.IsLower and
// unicode.IsNumber, which also accept letters such as 'ü' and digits such as
// '²' that S3 refuses.
func isASCIIBucketName(name string) bool {
for i := 0; i < len(name); i++ {
c := name[i]
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '-') {
return false
}
}
return true
}
// recordIdentityInContext stores the authenticated identity, its name and its
// principal ARN in the request context. An STS session's name is only an opaque
// session subject, so the ARN is what carries the assumed role and session name
+250
View File
@@ -0,0 +1,250 @@
package s3api
import (
"bytes"
"encoding/base64"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
invalidNameTestAccessKey = "INVALIDNAMEKEY000001"
invalidNameTestSecretKey = "invalid-name-secret-for-tests-only"
invalidNameTestBucket = "readable-bucket"
)
// newInvalidNameIAM has one identity that may read invalidNameTestBucket only
// and a configured anonymous identity with no actions.
func newInvalidNameIAM(t *testing.T) *IdentityAccessManagement {
t.Helper()
iam := &IdentityAccessManagement{hashes: make(map[string]*sync.Pool), hashCounters: make(map[string]*int32)}
require.NoError(t, iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "reader", Actions: []string{"Read:" + invalidNameTestBucket},
Credentials: []*iam_pb.Credential{{AccessKey: invalidNameTestAccessKey, SecretKey: invalidNameTestSecretKey}}},
{Name: s3_constants.AccountAnonymousId},
},
}))
require.True(t, iam.isEnabled())
return iam
}
func invalidNameRequest(t *testing.T, bucket string, sign bool) *http.Request {
t.Helper()
req := mustNewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:8333/%s/key", bucket), 0, nil, t)
if sign {
require.NoError(t, signRequestV4(req, invalidNameTestAccessKey, invalidNameTestSecretKey))
}
return mux.SetURLVars(req, map[string]string{"bucket": bucket, "object": "key"})
}
func serveAuth(iam *IdentityAccessManagement, r *http.Request) (int, bool) {
ran := false
rec := httptest.NewRecorder()
iam.Auth(func(w http.ResponseWriter, r *http.Request) { ran = true; w.WriteHeader(http.StatusOK) }, s3_constants.ACTION_READ).ServeHTTP(rec, r)
return rec.Code, ran
}
// A denied request on a malformed bucket name answers 400 InvalidBucketName,
// as AWS S3 does; a denied request on a well-formed name stays 403.
func TestAuthDeniedInvalidBucketName(t *testing.T) {
iam := newInvalidNameIAM(t)
code, ran := serveAuth(iam, invalidNameRequest(t, invalidNameTestBucket, true))
assert.True(t, ran, "the identity's own bucket is allowed")
assert.Equal(t, http.StatusOK, code)
code, ran = serveAuth(iam, invalidNameRequest(t, "other-bucket", true))
assert.False(t, ran)
assert.Equal(t, http.StatusForbidden, code, "a well-formed name the caller may not read stays AccessDenied")
for _, bucket := range []string{"Bad_Bucket", "ab", "bucket..name", "bücket"} {
for _, sign := range []bool{true, false} {
code, ran = serveAuth(iam, invalidNameRequest(t, bucket, sign))
assert.False(t, ran, bucket)
assert.Equal(t, http.StatusBadRequest, code, "bucket %q signed=%v", bucket, sign)
}
}
}
func TestRefineDenialForBucketName(t *testing.T) {
reader := &Identity{Name: "reader"}
req := func(bucket string) *http.Request {
return mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/", nil), map[string]string{"bucket": bucket})
}
cases := []struct {
name string
bucket string
identity *Identity
in, want s3err.ErrorCode
}{
{"space in the name", "bucket name", reader, s3err.ErrAccessDenied, s3err.ErrInvalidBucketName},
{"non-ASCII letter", "bücket", reader, s3err.ErrAccessDenied, s3err.ErrInvalidBucketName},
{"superscript digit", "b²cket", reader, s3err.ErrAccessDenied, s3err.ErrInvalidBucketName},
{"too short", "ab", reader, s3err.ErrAccessDenied, s3err.ErrInvalidBucketName},
{"well-formed name", "other-bucket", reader, s3err.ErrAccessDenied, s3err.ErrAccessDenied},
{"no bucket in the request", "", reader, s3err.ErrAccessDenied, s3err.ErrAccessDenied},
{"allowed", "Bad_Bucket", reader, s3err.ErrNone, s3err.ErrNone},
{"authentication failed", "Bad_Bucket", nil, s3err.ErrAccessDenied, s3err.ErrAccessDenied},
{"bad signature", "Bad_Bucket", nil, s3err.ErrSignatureDoesNotMatch, s3err.ErrSignatureDoesNotMatch},
}
for _, c := range cases {
assert.Equal(t, c.want, refineDenialForBucketName(req(c.bucket), c.identity, c.in), c.name)
}
}
// signedPostForm builds a multipart POST Object request for bucket, its policy
// signed with secret under postAuthzAccessKey.
func signedPostForm(t *testing.T, bucket, key, secret string) *http.Request {
t.Helper()
now := time.Now().UTC()
amzDate := now.Format(iso8601Format)
day := now.Format(yyyymmdd)
credential := fmt.Sprintf("%s/%s/%s/%s/aws4_request", postAuthzAccessKey, day, postAuthzRegion, postAuthzService)
policyJSON := fmt.Sprintf(`{"expiration":"%s","conditions":[["eq","$bucket","%s"],["eq","$key","%s"],`+
`["eq","$x-amz-credential","%s"],["eq","$x-amz-algorithm","AWS4-HMAC-SHA256"],["eq","$x-amz-date","%s"]]}`,
now.Add(time.Hour).Format("2006-01-02T15:04:05.000Z"), bucket, key, credential, amzDate)
encodedPolicy := base64.StdEncoding.EncodeToString([]byte(policyJSON))
signature := getSignature(getSigningKey(secret, day, postAuthzRegion, postAuthzService), encodedPolicy)
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
for _, field := range [][2]string{{"bucket", bucket}, {"key", key}, {"x-amz-credential", credential},
{"x-amz-algorithm", "AWS4-HMAC-SHA256"}, {"x-amz-date", amzDate}, {"policy", encodedPolicy}, {"x-amz-signature", signature}} {
require.NoError(t, writer.WriteField(field[0], field[1]))
}
filePart, err := writer.CreateFormFile("file", "payload.txt")
require.NoError(t, err)
_, err = filePart.Write([]byte("payload"))
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/"+bucket, &buf)
req.Header.Set("Content-Type", writer.FormDataContentType())
return mux.SetURLVars(req, map[string]string{"bucket": bucket})
}
// A browser POST upload authenticates in the handler, after AuthPostPolicy has
// delegated it, so its denials are refined there: a verified form denied on a
// malformed bucket name answers 400, a bad signature or a well-formed name 403.
func TestPostPolicyDeniedInvalidBucketName(t *testing.T) {
s3a := newPostAuthzServer(t)
for _, bucket := range []string{"Bad_Bucket", "other-bucket"} {
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{Name: bucket})
}
// A second identity may write Bad_Bucket, except under denied/.
require.NoError(t, s3a.iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "tester", Actions: []string{"Write:" + postAuthzBucket},
Credentials: []*iam_pb.Credential{{AccessKey: postAuthzAccessKey, SecretKey: postAuthzSecretKey}}},
},
}))
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy("Bad_Bucket", `{"Version":"2012-10-17","Statement":[`+
`{"Effect":"Deny","Principal":"*","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::Bad_Bucket/denied/*"]}]}`))
serve := func(r *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
s3a.iam.AuthPostPolicy(s3a.PostPolicyBucketHandler, s3_constants.ACTION_WRITE).ServeHTTP(rec, r)
return rec
}
rec := serve(signedPostForm(t, "Bad_Bucket", "k", postAuthzSecretKey))
assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "InvalidBucketName")
rec = serve(signedPostForm(t, "Bad_Bucket", "k", "not-the-secret"))
assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "SignatureDoesNotMatch", "a form that fails verification is not refined")
rec = serve(signedPostForm(t, "other-bucket", "k", postAuthzSecretKey))
assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "AccessDenied")
// The per-key authorization after the coarse Write check is refined too.
require.NoError(t, s3a.iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{Name: "tester", Actions: []string{"Write:Bad_Bucket"},
Credentials: []*iam_pb.Credential{{AccessKey: postAuthzAccessKey, SecretKey: postAuthzSecretKey}}},
},
}))
rec = serve(signedPostForm(t, "Bad_Bucket", "denied/k", postAuthzSecretKey))
assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "InvalidBucketName")
}
// Unsigned GET/HEAD object reads are authorized inside the object handlers, not
// by Auth; their denial on a malformed bucket name answers 400 as well.
func TestAnonymousReadDeniedInvalidBucketName(t *testing.T) {
read := func(s3a *S3ApiServer, method, bucket string) int {
r := httptest.NewRequest(method, "/"+bucket+"/key", nil)
r = mux.SetURLVars(r, map[string]string{"bucket": bucket, "object": "key"})
handler := s3a.GetObjectHandler
if method == http.MethodHead {
handler = s3a.HeadObjectHandler
}
rr := httptest.NewRecorder()
s3a.AuthWithPublicRead(handler, s3_constants.ACTION_READ)(rr, r)
return rr.Code
}
for _, method := range []string{http.MethodGet, http.MethodHead} {
s3a, _ := newAnonymousReadTestServer(t)
s3a.iam.identityAnonymous = &Identity{Name: s3_constants.AccountAnonymousId}
assert.Equal(t, http.StatusBadRequest, read(s3a, method, "Bad_Bucket"), method)
assert.Equal(t, http.StatusForbidden, read(s3a, method, "other-bucket"), "%s: a well-formed missing bucket stays masked", method)
// No anonymous identity configured: no identity resolves, as in Auth.
s3a, _ = newAnonymousReadTestServer(t)
assert.Equal(t, http.StatusForbidden, read(s3a, method, "Bad_Bucket"), method)
}
}
// An existing private key and a missing key in the same malformed-name bucket
// must answer alike, or the status code tells an anonymous caller which keys
// exist.
func TestAnonymousReadDeniedInvalidBucketNameExistingKey(t *testing.T) {
const bucket = "bücket"
read := func(s3a *S3ApiServer, method, object string) int {
r := httptest.NewRequest(method, "/"+bucket+"/"+object, nil)
r = mux.SetURLVars(r, map[string]string{"bucket": bucket, "object": object})
handler := s3a.GetObjectHandler
if method == http.MethodHead {
handler = s3a.HeadObjectHandler
}
rr := httptest.NewRecorder()
s3a.AuthWithPublicRead(handler, s3_constants.ACTION_READ)(rr, r)
return rr.Code
}
setup := func(anonymous bool) *S3ApiServer {
s3a, f := newAnonymousReadTestServer(t)
if anonymous {
s3a.iam.identityAnonymous = &Identity{Name: s3_constants.AccountAnonymousId}
}
s3a.bucketConfigCache.Set(bucket, &BucketConfig{Name: bucket, Ownership: s3_constants.OwnershipObjectWriter})
f.entries["/buckets/"+bucket+"/private"] = anonymousReadEntry([]byte(`[]`))
f.entries["/buckets/"+bucket+"/public"] = anonymousReadEntry(anonymousReadGrant("READ"))
return s3a
}
for _, method := range []string{http.MethodGet, http.MethodHead} {
s3a := setup(true)
assert.Equal(t, http.StatusOK, read(s3a, method, "public"), "%s: a public key is still served", method)
assert.Equal(t, http.StatusBadRequest, read(s3a, method, "missing"), method)
assert.Equal(t, http.StatusBadRequest, read(s3a, method, "private"), "%s: an existing private key answers like a missing one", method)
// No anonymous identity configured: both stay 403, as in Auth.
s3a = setup(false)
assert.Equal(t, http.StatusForbidden, read(s3a, method, "missing"), method)
assert.Equal(t, http.StatusForbidden, read(s3a, method, "private"), method)
}
}
-6
View File
@@ -29,7 +29,6 @@ import (
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
@@ -95,11 +94,6 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV2Match(formValues http.
return nil, s3err.ErrAccessDenied
}
bucket := formValues.Get("bucket")
if !identity.CanDo(s3_constants.ACTION_WRITE, bucket, "") {
return nil, s3err.ErrAccessDenied
}
policy := formValues.Get("Policy")
if policy == "" {
return nil, s3err.ErrMissingFields
-5
View File
@@ -858,11 +858,6 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV4Match(formValues http.
return nil, s3err.ErrAccessDenied
}
bucket := formValues.Get("bucket")
if !identity.CanDo(s3_constants.ACTION_WRITE, bucket, "") {
return nil, s3err.ErrAccessDenied
}
// Get signing key.
signingKey := getSigningKey(cred.SecretKey, credHeader.scope.date.Format(yyyymmdd), credHeader.scope.region, credHeader.scope.service)
+115
View File
@@ -2,8 +2,12 @@ package s3api
import (
"errors"
"net/http"
"path"
"regexp"
"strconv"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
@@ -83,6 +87,117 @@ func (s3a *S3ApiServer) validateTableBucketObjectPath(bucket, object string) err
return nil
}
// volumeVerifyKey matches the object Snowflake writes, reads, lists and
// deletes at the base of an S3-compatible external volume before it creates
// or writes Iceberg tables there (SYSTEM$VERIFY_EXTERNAL_VOLUME, also run
// implicitly): <base>/verify_<epoch milliseconds>_<number>, a 21-byte body.
// A table bucket refuses every key outside the Iceberg layout, so without
// this exception Snowflake cannot use a table bucket as an external volume.
//
// The exception is narrow so that it cannot be used to store data:
// - only at the bucket root: nothing can be encoded in a prefix, written
// into a hidden tree or take a namespace or table name;
// - one shape: 13 digits of epoch milliseconds, then 1-10 digits;
// - never a directory: a namespace may carry a name of that shape, and the
// key then names its directory (volumeVerifyKeyIsDirectory);
// - a PUT must be dated within volumeVerifyWindow of the server's clock,
// declare a Content-Length of at most volumeVerifyMaxBytes (no chunked or
// undeclared body), and carry no user metadata, tagging, stored content
// headers or extraction request (volumeVerifyPutAllowed). Reads and
// deletes take a key of any date, so an object a check left behind can
// still be removed.
//
// Only the calls the check makes (PUT, GET, HEAD, DELETE, DeleteObjects)
// accept the key; multipart, copy, rename, ACL, tagging and POST policy keep
// the plain layout rule.
var volumeVerifyKey = regexp.MustCompile(`^verify_([0-9]{13})_[0-9]{1,10}$`)
const (
volumeVerifyMaxBytes = 1024
volumeVerifyWindow = time.Hour
)
// volumeVerifyNow is the clock a PUT's key is checked against.
var volumeVerifyNow = time.Now
// volumeVerifyKeyTime is the time a verification key is dated, and whether
// the object has the key's shape.
func volumeVerifyKeyTime(object string) (time.Time, bool) {
m := volumeVerifyKey.FindStringSubmatch(strings.TrimPrefix(object, "/"))
if m == nil {
return time.Time{}, false
}
ms, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.UnixMilli(ms), true
}
func isVolumeVerifyKey(object string) bool {
_, ok := volumeVerifyKeyTime(object)
return ok
}
// volumeVerifyLookup reads the entry a verification key names.
var volumeVerifyLookup = func(s3a *S3ApiServer, dir, name string) (*filer_pb.Entry, error) {
return s3a.getEntry(dir, name)
}
// volumeVerifyKeyIsDirectory reports whether the key names a directory (a
// namespace), or whether that cannot be known.
func (s3a *S3ApiServer) volumeVerifyKeyIsDirectory(bucket, object string) bool {
entry, err := volumeVerifyLookup(s3a, s3a.bucketDir(bucket), strings.TrimPrefix(object, "/"))
if err != nil {
return !errors.Is(err, filer_pb.ErrNotFound)
}
return entry != nil && entry.IsDirectory
}
// validateTableBucketObjectPathOrVerify is validateTableBucketObjectPath that
// also accepts the external-volume verification key, and reports it so the
// PUT handler can apply its limits.
func (s3a *S3ApiServer) validateTableBucketObjectPathOrVerify(bucket, object string) (isVerify bool, err error) {
if !s3a.isTableBucket(bucket) {
return false, nil
}
if isVolumeVerifyKey(object) && !s3a.volumeVerifyKeyIsDirectory(bucket, object) {
return true, nil
}
return false, s3a.validateTableBucketObjectPath(bucket, object)
}
// volumeVerifyPutAllowed reports whether a PUT to the verification key is
// dated now, declares a small body and stores nothing beside it.
func volumeVerifyPutAllowed(r *http.Request, object string) bool {
at, ok := volumeVerifyKeyTime(object)
if !ok {
return false
}
if d := volumeVerifyNow().Sub(at); d > volumeVerifyWindow || d < -volumeVerifyWindow {
return false
}
if r.ContentLength < 0 || r.ContentLength > volumeVerifyMaxBytes || len(r.TransferEncoding) > 0 {
return false
}
if isExtractZip(r) {
return false
}
for name, values := range r.Header {
n := strings.ToLower(name)
if strings.HasPrefix(n, "x-amz-meta-") || n == "x-amz-tagging" || n == "content-disposition" ||
n == "response-content-disposition" || n == "content-language" ||
n == "cache-control" || n == "expires" || n == "x-amz-website-redirect-location" {
return false
}
// aws-chunked only frames the body; any other coding is stored.
if n == "content-encoding" && storedContentEncoding(values) != "" {
return false
}
}
return true
}
func (s3a *S3ApiServer) bucketPrefix(bucket string) string {
return s3a.bucketDir(bucket) + "/"
}
+47 -3
View File
@@ -235,13 +235,15 @@ func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse
// Set exposed headers
if len(rule.ExposeHeaders) > 0 {
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
response.ExposeHeaders = formatExposeHeaders(rule.ExposeHeaders)
}
// Set max age
if rule.MaxAgeSeconds != nil {
response.MaxAge = strconv.Itoa(*rule.MaxAgeSeconds)
}
response.AllowCredentials = true
}
return response
@@ -250,7 +252,8 @@ func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse
// buildResponse builds a CORS response from a matching rule
func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
response := &CORSResponse{
AllowOrigin: corsReq.Origin,
AllowOrigin: corsReq.Origin,
AllowCredentials: true,
}
// Set allowed methods
@@ -263,7 +266,7 @@ func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
// Set expose headers
if len(rule.ExposeHeaders) > 0 {
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
response.ExposeHeaders = formatExposeHeaders(rule.ExposeHeaders)
}
// Set max age
@@ -274,6 +277,47 @@ func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
return response
}
// defaultExposeHeaders are emitted alongside "*" so credentialed CORS requests
// can read standard response headers ("*" is treated literally when credentials
// are included, per the Fetch spec).
var defaultExposeHeaders = []string{
"ETag",
"Content-Length",
"Content-Type",
"Last-Modified",
"Content-Range",
"x-amz-request-id",
"x-amz-version-id",
"x-amz-tagging-count",
}
// formatExposeHeaders formats the exposed headers slice into a comma-separated string.
func formatExposeHeaders(exposeHeaders []string) string {
var headers []string
hasWildcard := false
seen := map[string]bool{}
for _, h := range exposeHeaders {
if h == "*" {
hasWildcard = true
continue
}
if lower := strings.ToLower(h); !seen[lower] {
seen[lower] = true
headers = append(headers, h)
}
}
if hasWildcard {
for _, h := range defaultExposeHeaders {
if lower := strings.ToLower(h); !seen[lower] {
seen[lower] = true
headers = append(headers, h)
}
}
headers = append(headers, "*")
}
return strings.Join(headers, ", ")
}
// Helper functions
// matchesOrigin checks if the request origin matches any allowed origin
+51 -23
View File
@@ -405,11 +405,12 @@ func TestEvaluateRequest(t *testing.T) {
Method: "GET",
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowCredentials: true,
},
wantErr: false,
},
@@ -421,8 +422,9 @@ func TestEvaluateRequest(t *testing.T) {
Method: "PUT",
},
want: &CORSResponse{
AllowOrigin: "http://other.com",
AllowMethods: "PUT",
AllowOrigin: "http://other.com",
AllowMethods: "PUT",
AllowCredentials: true,
},
wantErr: false,
},
@@ -447,11 +449,12 @@ func TestEvaluateRequest(t *testing.T) {
AccessControlRequestHeaders: []string{"Content-Type"},
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowCredentials: true,
},
wantErr: false,
},
@@ -467,7 +470,30 @@ func TestEvaluateRequest(t *testing.T) {
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
// No AllowMethods or AllowHeaders because the requested header is forbidden
// No AllowMethods, AllowHeaders, or AllowCredentials because the requested header is forbidden
},
wantErr: false,
},
{
name: "wildcard expose headers expands for credentialed responses",
config: &CORSConfiguration{
CORSRules: []CORSRule{
{
AllowedOrigins: []string{"http://example.com"},
AllowedMethods: []string{"GET"},
ExposeHeaders: []string{"*"},
},
},
},
corsReq: &CORSRequest{
Origin: "http://example.com",
Method: "GET",
},
want: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET",
ExposeHeaders: "ETag, Content-Length, Content-Type, Last-Modified, Content-Range, x-amz-request-id, x-amz-version-id, x-amz-tagging-count, *",
AllowCredentials: true,
},
wantErr: false,
},
@@ -511,18 +537,20 @@ func TestApplyHeaders(t *testing.T) {
{
name: "complete response",
corsResp: &CORSResponse{
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowOrigin: "http://example.com",
AllowMethods: "GET, POST",
AllowHeaders: "Content-Type",
ExposeHeaders: "ETag",
MaxAge: "3600",
AllowCredentials: true,
},
want: map[string]string{
"Access-Control-Allow-Origin": "http://example.com",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Expose-Headers": "ETag",
"Access-Control-Max-Age": "3600",
"Access-Control-Allow-Origin": "http://example.com",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Expose-Headers": "ETag",
"Access-Control-Max-Age": "3600",
"Access-Control-Allow-Credentials": "true",
},
},
{
+1 -1
View File
@@ -397,7 +397,7 @@ func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s after update failure: %v", newMetadataLocation, cleanupErr)
}
glog.Errorf("Iceberg: CommitTable UpdateTable error: %v", err)
logManagerError("CommitTable UpdateTable", err)
writeManagerError(w, err)
return
}
+1 -1
View File
@@ -140,7 +140,7 @@ func (s *Server) handleCreateNamespace(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
return
}
glog.Errorf("Iceberg: CreateNamespace error: %v", err)
logManagerError("CreateNamespace", err)
writeManagerError(w, err)
return
}
+1 -1
View File
@@ -109,7 +109,7 @@ func (s *Server) handleCommitTransaction(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusConflict, "CommitFailedException", "Version token mismatch")
return
}
glog.Errorf("Iceberg: CommitTransaction UpdateTable error: %v", err)
logManagerError("CommitTransaction UpdateTable", err)
writeManagerError(w, err)
return
}
@@ -0,0 +1,36 @@
package iceberg
import (
"errors"
"fmt"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func TestLogManagerErrorRefusalsAreNotErrors(t *testing.T) {
var refusals, faults []string
oldR, oldF := logManagerRefusal, logManagerFault
logManagerRefusal = func(f string, a ...interface{}) { refusals = append(refusals, fmt.Sprintf(f, a...)) }
logManagerFault = func(f string, a ...interface{}) { faults = append(faults, fmt.Sprintf(f, a...)) }
defer func() { logManagerRefusal, logManagerFault = oldR, oldF }()
for _, c := range []struct {
err error
fault bool
}{
{&s3tables.S3TablesError{Type: s3tables.ErrCodeAccessDenied, Message: "not authorized to create namespace in this bucket"}, false},
{&s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchNamespace, Message: "no such namespace"}, false},
{&s3tables.S3TablesError{Type: s3tables.ErrCodeNamespaceAlreadyExists, Message: "exists"}, false},
{errors.New("filer: connection refused"), true}, // positive control: a real fault stays at ERROR
} {
refusals, faults = nil, nil
logManagerError("CreateNamespace", c.err)
wantFaults, wantRefusals := 0, 1
if c.fault {
wantFaults, wantRefusals = 1, 0
}
if len(faults) != wantFaults || len(refusals) != wantRefusals {
t.Errorf("%v: %d ERROR and %d refusal log(s), want %d and %d (refusals %v, faults %v)", c.err, len(faults), len(refusals), wantFaults, wantRefusals, refusals, faults)
}
}
}
+35 -16
View File
@@ -158,7 +158,9 @@ func nameValidationError(err error) bool {
// errors (400) and must carry the reason -- a variant field without
// format-version 3 otherwise reads as a bare 500 with "variant is not supported
// until v3" only in the log. Anything else is a server fault (500).
func writeManagerError(w http.ResponseWriter, err error) {
// classifyManagerError maps a storage-layer error to the HTTP status, the
// Iceberg error type and the message the catalog answers with.
func classifyManagerError(err error) (int, string, string) {
switch {
case nameValidationError(err),
errors.Is(err, iceberg.ErrInvalidSchema),
@@ -166,8 +168,7 @@ func writeManagerError(w http.ResponseWriter, err error) {
errors.Is(err, iceberg.ErrInvalidTypeString),
errors.Is(err, iceberg.ErrInvalidTransform),
errors.Is(err, iceberg.ErrInvalidArgument):
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
return http.StatusBadRequest, "BadRequestException", err.Error()
}
// Storage-layer failures are mostly the client's, not the server's. Reporting
// a missing namespace or a name conflict as a 500 makes the catalog look
@@ -179,31 +180,49 @@ func writeManagerError(w http.ResponseWriter, err error) {
// The storage-layer message names the resolved bucket, which for a
// client that sent no warehouse at all is the default one it never
// asked for, so say how to select a real table bucket.
writeError(w, http.StatusNotFound, "NoSuchNamespaceException",
fmt.Sprintf("%s: each table bucket is a separate catalog, select one with warehouse=s3://<table-bucket>/ or /v1/<table-bucket>/", tableErr.Message))
return http.StatusNotFound, "NoSuchNamespaceException",
fmt.Sprintf("%s: each table bucket is a separate catalog, select one with warehouse=s3://<table-bucket>/ or /v1/<table-bucket>/", tableErr.Message)
case s3tables.ErrCodeNoSuchNamespace:
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", tableErr.Message)
return http.StatusNotFound, "NoSuchNamespaceException", tableErr.Message
case s3tables.ErrCodeNoSuchTable:
writeError(w, http.StatusNotFound, "NoSuchTableException", tableErr.Message)
return http.StatusNotFound, "NoSuchTableException", tableErr.Message
case s3tables.ErrCodeNoSuchView:
writeError(w, http.StatusNotFound, "NoSuchViewException", tableErr.Message)
return http.StatusNotFound, "NoSuchViewException", tableErr.Message
case s3tables.ErrCodeNamespaceAlreadyExists, s3tables.ErrCodeBucketAlreadyExists,
s3tables.ErrCodeTableAlreadyExists, s3tables.ErrCodeViewAlreadyExists:
writeError(w, http.StatusConflict, "AlreadyExistsException", tableErr.Message)
return http.StatusConflict, "AlreadyExistsException", tableErr.Message
case s3tables.ErrCodeNamespaceNotEmpty, s3tables.ErrCodeBucketNotEmpty:
writeError(w, http.StatusConflict, "AlreadyExistsException", tableErr.Message)
return http.StatusConflict, "AlreadyExistsException", tableErr.Message
case s3tables.ErrCodeConflict:
writeError(w, http.StatusConflict, "CommitFailedException", tableErr.Message)
return http.StatusConflict, "CommitFailedException", tableErr.Message
case s3tables.ErrCodeAccessDenied:
writeError(w, http.StatusForbidden, "ForbiddenException", tableErr.Message)
return http.StatusForbidden, "ForbiddenException", tableErr.Message
case s3tables.ErrCodeInvalidRequest, s3tables.ErrCodeInvalidIcebergLayout:
writeError(w, http.StatusBadRequest, "BadRequestException", tableErr.Message)
default:
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
return http.StatusBadRequest, "BadRequestException", tableErr.Message
}
}
return http.StatusInternalServerError, "InternalServerError", err.Error()
}
func writeManagerError(w http.ResponseWriter, err error) {
status, typ, msg := classifyManagerError(err)
writeError(w, status, typ, msg)
}
// A storage-layer error the catalog answers with a 4xx (access denied, not
// found, already exists, not empty, a conflict) is the API refusing a request
// as intended, not a server fault; it is logged at V(1). A 5xx stays at ERROR.
var (
logManagerRefusal = func(format string, args ...interface{}) { glog.V(1).Infof(format, args...) }
logManagerFault = glog.Errorf
)
func logManagerError(op string, err error) {
if status, _, _ := classifyManagerError(err); status >= 400 && status < 500 {
logManagerRefusal("Iceberg: %s refused (%d): %v", op, status, err)
return
}
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
logManagerFault("Iceberg: %s error: %v", op, err)
}
// resolveWarehouseBucket maps a client-supplied warehouse value to a table
+4 -2
View File
@@ -171,10 +171,12 @@ func (s3a *S3ApiServer) authorizeAnonymousBucketList(r *http.Request, bucket str
// writeAnonymousAwareNotFound keeps missing-bucket and missing-key misses
// indistinguishable from denied reads for anonymous callers: a 404 only
// reaches the wire when the bucket is anonymously listable.
// reaches the wire when the bucket is anonymously listable. The denial is
// refined like Auth's, so a malformed bucket name answers InvalidBucketName.
func (s3a *S3ApiServer) writeAnonymousAwareNotFound(w http.ResponseWriter, r *http.Request, bucket string, code s3err.ErrorCode) {
if isAnonymousObjectRead(r) && s3a.authorizeAnonymousBucketList(r, bucket) != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, s3err.ErrAccessDenied))
return
}
s3err.WriteErrorResponse(w, r, code)
@@ -299,7 +299,9 @@ func TestAnonymousObjectACLConfiguredIdentity(t *testing.T) {
}{
{"legacy anonymous permission", false, []Action{s3_constants.ACTION_READ}, "", false, 200},
{"identity policy allow", false, nil, `{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}`, false, 200},
{"identity deny beats object ACL", true, nil, `{"Effect":"Deny","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}`, false, 403},
// The fixture bucket "b" is shorter than S3's three-character minimum,
// so a denial for a resolved identity answers InvalidBucketName.
{"identity deny beats object ACL", true, nil, `{"Effect":"Deny","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}`, false, 400},
{"disabled identity cannot grant", false, []Action{s3_constants.ACTION_READ}, "", true, 403},
{"public ACL does not require identity", true, nil, "", true, 200},
} {
+37 -6
View File
@@ -181,8 +181,10 @@ func (s3a *S3ApiServer) parseAndValidateRange(w http.ResponseWriter, r *http.Req
return 0, totalSize, false, nil
}
// Empty directory: 404. A file promoted to a directory keeps its data and stays retrievable.
if entry.IsDirectory && totalSize == 0 {
// Empty directory: 404. A file promoted to a directory keeps its data and
// stays retrievable, and a stored directory-key marker is an object, not a
// missing key — an unsatisfiable range answers 416.
if entry.IsDirectory && totalSize == 0 && !entry.IsDirectoryKeyObject() {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("directory object %s/%s cannot be retrieved", bucket, object))
}
@@ -533,6 +535,24 @@ func (s3a *S3ApiServer) serveDirectoryContent(w http.ResponseWriter, r *http.Req
if r.Method == http.MethodHead && len(entry.Chunks) > 0 {
contentLength = int64(entry.Attributes.FileSize)
}
statusCode := http.StatusOK
if r.Header.Get("Range") != "" && requestedPartNumber(r) == 0 {
// A ranged request answers like the object path does: 206 with its
// Content-Range and the range's length, served on GET, reported on HEAD.
totalSize := contentLength
offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, entry, totalSize, bucket, object)
if rangeErr != nil {
return
}
if isRangeRequest {
contentLength = size
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize))
statusCode = http.StatusPartialContent
if r.Method != http.MethodHead {
content = content[offset : offset+size]
}
}
}
w.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
// Set last modified
@@ -545,12 +565,12 @@ func (s3a *S3ApiServer) serveDirectoryContent(w http.ResponseWriter, r *http.Req
// For HEAD requests, don't write body
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
w.WriteHeader(statusCode)
return
}
// Write content
w.WriteHeader(http.StatusOK)
w.WriteHeader(statusCode)
if len(content) > 0 {
if _, err := w.Write(content); err != nil {
glog.Errorf("serveDirectoryContent: failed to write response: %v", err)
@@ -784,7 +804,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request)
bucket, object := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("GetObjectHandler %s %s", bucket, object)
if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil {
if _, err := s3a.validateTableBucketObjectPathOrVerify(bucket, object); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
@@ -2479,7 +2499,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
bucket, object := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("HeadObjectHandler %s %s", bucket, object)
if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil {
if _, err := s3a.validateTableBucketObjectPathOrVerify(bucket, object); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
@@ -2745,6 +2765,17 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", startOffset, endOffset, totalSize))
statusCode = http.StatusPartialContent
glog.V(3).Infof("HeadObject: part %d is bytes %d-%d/%d", partNumber, startOffset, endOffset, totalSize)
} else if r.Header.Get("Range") != "" {
// A ranged HEAD reports the range like GET does: 206 with its Content-Range and length
offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, objectEntryForSSE, totalSize, bucket, object)
if rangeErr != nil {
return
}
if isRangeRequest {
responseSize = size
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize))
statusCode = http.StatusPartialContent
}
}
s3a.setResponseHeaders(w, r, objectEntryForSSE, responseSize)
+142 -8
View File
@@ -2,17 +2,21 @@ package s3api
import (
"bytes"
"cmp"
"context"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"hash"
"io"
"net/http"
"net/url"
"os"
"reflect"
"slices"
"strconv"
"strings"
"time"
@@ -1146,7 +1150,9 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req
return
}
if uploadEntryHasSSE(uploadEntry) || sourceEntryIsEncrypted(entry) || uploadEntryHasChecksum(uploadEntry) {
// Inline-Content sources have no chunks for the raw-byte path to copy;
// the plaintext stream serves their range instead.
if uploadEntryHasSSE(uploadEntry) || sourceEntryIsEncrypted(entry) || uploadEntryHasChecksum(uploadEntry) || len(entry.Content) > 0 {
etag, sseMetadata, errCode := s3a.copyObjectPartViaReencryption(r, entry, startOffset, endOffset, dstBucket, dstObject, uploadID, partID, uploadEntry)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
@@ -1204,15 +1210,24 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req
// For zero-size files or invalid ranges, create an empty part with size 0
dstEntry.Attributes.FileSize = 0
dstEntry.Chunks = nil
dstEntry.Attributes.Md5 = md5.New().Sum(nil)
} else {
// Copy chunks that overlap with the range
dstChunks, err := s3a.copyChunksForRange(entry, startOffset, endOffset, dstAssignPath)
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, startOffset, endOffset, dstAssignPath)
if err != nil {
glog.Errorf("CopyObjectPartHandler copy chunks error: %v", err)
s3err.WriteErrorResponse(w, r, s3a.mapCopyErrorToS3Error(err))
return
}
dstEntry.Chunks = dstChunks
dstEntry.Attributes.Md5 = partMd5
}
// Mirror putToFiler: the part entry carries the MD5 of its bytes so
// CopyPartResult, ListParts and complete's composite ETag all describe
// the copied range rather than the source chunks.
if len(dstEntry.Attributes.Md5) > 0 {
dstEntry.Extended[s3_constants.ExtETagKey] = []byte(fmt.Sprintf("%x", dstEntry.Attributes.Md5))
}
// Save the part entry to the multipart uploads folder
@@ -1479,7 +1494,7 @@ func (s3a *S3ApiServer) copySingleChunk(chunk *filer_pb.FileChunk, dstPath strin
// the source volume for compressed bytes, so a gzipped chunk is
// forwarded to the destination without anyone having to decompress.
if canStreamCopyChunk(chunk) {
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, 0, int64(chunk.Size), true /*isFullChunk*/, assignResult); err != nil {
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, 0, int64(chunk.Size), true /*isFullChunk*/, assignResult, nil); err != nil {
return nil, fmt.Errorf("stream chunk: %w", err)
}
return dstChunk, nil
@@ -1534,9 +1549,22 @@ func (s3a *S3ApiServer) copySingleChunkForRange(originalChunk, rangeChunk *filer
// part-size = chunk-size assemble pattern hits this branch.
if canStreamCopyChunk(originalChunk) {
isFullChunk := offsetInChunk == 0 && rangeChunk.Size == originalChunk.Size
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size), isFullChunk, assignResult); err != nil {
// A partial range stores different bytes than the source chunk, so
// its ETag has to be recomputed; a whole-chunk copy reuses it.
var digest hash.Hash
if !isFullChunk {
digest = md5.New()
}
if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size), isFullChunk, assignResult, digest); err != nil {
return nil, fmt.Errorf("stream chunk range: %w", err)
}
if digest != nil {
dstChunk.ETag = base64.StdEncoding.EncodeToString(digest.Sum(nil))
// The range fetch returns raw bytes and the destination stores
// them uncompressed, so the source chunk's gzip flag must not
// carry over onto a partial copy.
dstChunk.IsCompressed = false
}
return dstChunk, nil
}
@@ -1609,8 +1637,16 @@ func parseRangeHeader(rangeHeader string, fileSize int64) (startOffset, endOffse
return startOffset, endOffset, nil
}
// copyChunksForRange copies chunks that overlap with the specified range
func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, endOffset int64, dstPath string) ([]*filer_pb.FileChunk, error) {
// partMd5Result carries the whole-part MD5 the copiedRangeMd5 goroutine
// computes beside the per-chunk copies.
type partMd5Result struct {
sum []byte
err error
}
// copyChunksForRange copies chunks that overlap with the specified range and
// returns the MD5 of the copied bytes for the part entry's Attributes.Md5.
func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, endOffset int64, dstPath string) ([]*filer_pb.FileChunk, []byte, error) {
var relevantChunks []*filer_pb.FileChunk
var originalChunks []*filer_pb.FileChunk
@@ -1641,6 +1677,36 @@ func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, e
}
}
// The part's Attributes.Md5 must cover the copied range in byte order,
// and md5 cannot combine per-chunk digests. A single chunk's own digest
// is the answer only when it supplies the whole range; anything else —
// several chunks, or gaps a sparse source zero-fills — needs an ordered
// pass the concurrent copies cannot feed, so a second stream of the
// source range hashes beside them.
partSize := endOffset - startOffset + 1
singleChunkCovers := len(relevantChunks) == 1 &&
relevantChunks[0].Offset == 0 && int64(relevantChunks[0].Size) == partSize
md5Ctx, cancelMd5 := context.WithCancel(context.Background())
defer cancelMd5()
var partMd5 []byte
var partMd5Ch chan partMd5Result
switch {
case singleChunkCovers:
// resolved below from the copied chunk's own digest
case len(entry.GetChunks()) == 0:
// no chunks to read at all: the part is partSize zero bytes (the
// stream helper returns empty for a chunkless entry, not zero-fill)
partMd5 = md5Zeros(partSize)
default:
partMd5Ch = make(chan partMd5Result, 1)
go func() {
sum, err := s3a.copiedRangeMd5(md5Ctx, entry, startOffset, endOffset)
partMd5Ch <- partMd5Result{sum, err}
}()
}
// Copy the relevant chunks using a specialized method for range copies
dstChunks := make([]*filer_pb.FileChunk, len(relevantChunks))
const defaultChunkCopyConcurrency = 4
@@ -1664,11 +1730,79 @@ func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, e
// Wait for all operations to complete and check for errors
for i := 0; i < len(relevantChunks); i++ {
if err := <-errChan; err != nil {
return nil, err
return nil, nil, err
}
}
return dstChunks, nil
// Reads resolve overlapping chunks by ModifiedTsNs, and the concurrent
// copies stamped arbitrary finish times: rank the destination chunks by
// the source's timestamps so the part resolves overlaps the way the
// source did (and the way copiedRangeMd5 hashed it).
if len(dstChunks) > 1 {
order := make([]int, len(dstChunks))
for i := range order {
order[i] = i
}
slices.SortStableFunc(order, func(a, b int) int {
return cmp.Compare(originalChunks[a].ModifiedTsNs, originalChunks[b].ModifiedTsNs)
})
now := time.Now().UnixNano()
for rank, i := range order {
dstChunks[i].ModifiedTsNs = now + int64(rank)
}
}
if partMd5Ch != nil {
if res := <-partMd5Ch; res.err != nil {
return nil, nil, fmt.Errorf("md5 of copied range: %w", res.err)
} else {
partMd5 = res.sum
}
} else if singleChunkCovers {
if partMd5 = util.Base64Md5ToBytes(dstChunks[0].ETag); len(partMd5) != md5.Size {
// TUS and mount writes leave chunks without an ETag; hash the
// copied range when the inherited digest is missing or malformed.
sum, err := s3a.copiedRangeMd5(md5Ctx, entry, startOffset, endOffset)
if err != nil {
return nil, nil, fmt.Errorf("md5 of copied range: %w", err)
}
partMd5 = sum
}
}
return dstChunks, partMd5, nil
}
// md5Zeros hashes size zero bytes without a volume read — a copied range
// that overlaps no source chunk assembles to all zeros.
func md5Zeros(size int64) []byte {
h := md5.New()
var zeros [1 << 16]byte
for size > 0 {
n := min(size, int64(len(zeros)))
h.Write(zeros[:n])
size -= n
}
return h.Sum(nil)
}
// copiedRangeMd5 streams the source range once and hashes its plaintext —
// exactly the bytes the non-SSE part copy assembles on the destination.
func (s3a *S3ApiServer) copiedRangeMd5(ctx context.Context, entry *filer_pb.Entry, startOffset, endOffset int64) ([]byte, error) {
size := endOffset - startOffset + 1
// The copy walks the chunks, so the hash must too: on an entry that also
// carries inline Content the stream helper would prefer Content instead.
chunked := &filer_pb.Entry{Chunks: entry.GetChunks()}
reader, err := s3a.getEncryptedStreamFromVolumes(ctx, chunked, startOffset, size)
if err != nil {
return nil, err
}
defer reader.Close()
h := md5.New()
if _, err := io.Copy(h, reader); err != nil {
return nil, err
}
return h.Sum(nil), nil
}
// Helper methods for copy operations to avoid code duplication
@@ -160,7 +160,7 @@ func BenchmarkCopyChunk_Streamed(b *testing.B) {
for i := 0; i < b.N; i++ {
if err := s3a.streamCopyChunkRange(context.Background(),
env.srcSrv.URL, env.assign.FileId, 0, int64(size),
true /*isFullChunk*/, env.assign); err != nil {
true /*isFullChunk*/, env.assign, nil); err != nil {
b.Fatalf("stream: %v", err)
}
}
@@ -1,14 +1,21 @@
package s3api
import (
"bytes"
"crypto/md5"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
func TestCopyEntryETagPrefersStoredExtendedETag(t *testing.T) {
@@ -145,3 +152,349 @@ func TestParseRangeHeaderRejectsUnsatisfiableRange(t *testing.T) {
})
}
}
// A part copied with x-amz-copy-source-range must report the MD5 of the copied
// bytes: per destination chunk for a partial-chunk copy, and the whole-part MD5
// the part entry's Attributes.Md5 carries for CopyPartResult / ListParts /
// complete's composite ETag.
func TestCopyChunksForRangeCopiedETags(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
const chunkSize = 4 << 20
payload := make([]byte, 2*chunkSize)
for i := range payload {
payload[i] = byte(i*31 + 7)
}
b64md5 := func(data []byte) string {
sum := md5.Sum(data)
return base64.StdEncoding.EncodeToString(sum[:])
}
fid0 := fmt.Sprintf("3,%016x%08x", 101, 202)
fid1 := fmt.Sprintf("3,%016x%08x", 103, 204)
volume.stored[fid0] = payload[:chunkSize]
volume.stored[fid1] = payload[chunkSize:]
entry := &filer_pb.Entry{
Name: "src",
Attributes: &filer_pb.FuseAttributes{
FileSize: uint64(len(payload)),
},
Chunks: []*filer_pb.FileChunk{
{FileId: fid0, Offset: 0, Size: chunkSize, ETag: b64md5(payload[:chunkSize])},
{FileId: fid1, Offset: chunkSize, Size: chunkSize, ETag: b64md5(payload[chunkSize:])},
},
}
// A range spanning the first chunk fully plus 1 MiB of the second.
multiEnd := int64(chunkSize + 1<<20 - 1)
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, multiEnd, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange: %v", err)
}
if len(dstChunks) != 2 {
t.Fatalf("copyChunksForRange returned %d chunks, want 2", len(dstChunks))
}
// A whole-chunk copy forwards the stored bytes, so the source ETag stays valid.
if got, want := dstChunks[0].ETag, entry.Chunks[0].ETag; got != want {
t.Errorf("whole-chunk copy ETag = %q, want source chunk ETag %q", got, want)
}
// A partial copy stores only the range bytes; its ETag must be their MD5.
if got, want := dstChunks[1].ETag, b64md5(payload[chunkSize:multiEnd+1]); got != want {
t.Errorf("partial-chunk copy ETag = %q, want copied-bytes MD5 %q", got, want)
}
// The copied bytes landed on the destination volume (inside the multipart framing).
if stored := volume.stored[dstChunks[1].GetFileIdString()]; !bytes.Contains(stored, payload[chunkSize:multiEnd+1]) {
t.Error("destination volume does not hold the copied range bytes")
}
// The whole-part MD5 hashes every copied byte once, in range order.
wantPartMd5 := md5.Sum(payload[:multiEnd+1])
if !bytes.Equal(partMd5, wantPartMd5[:]) {
t.Errorf("part Md5 = %x, want %x", partMd5, wantPartMd5)
}
partEntry := &filer_pb.Entry{
Attributes: &filer_pb.FuseAttributes{Md5: partMd5},
Chunks: dstChunks,
}
if got, want := copyEntryETag(partEntry), fmt.Sprintf("%x", wantPartMd5); got != want {
t.Errorf("copyEntryETag = %q, want %q", got, want)
}
// A range inside one chunk: the part's ETag is the MD5 of those bytes.
innerChunks, innerMd5, err := s3a.copyChunksForRange(entry, 100, 199, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange inner range: %v", err)
}
if len(innerChunks) != 1 {
t.Fatalf("inner range returned %d chunks, want 1", len(innerChunks))
}
if got, want := innerChunks[0].ETag, b64md5(payload[100:200]); got != want {
t.Errorf("inner-range chunk ETag = %q, want copied-bytes MD5 %q", got, want)
}
wantInnerMd5 := md5.Sum(payload[100:200])
if !bytes.Equal(innerMd5, wantInnerMd5[:]) {
t.Errorf("inner-range part Md5 = %x, want %x", innerMd5, wantInnerMd5)
}
}
// A range over a sparse source zero-fills the gaps, so the part MD5 has to
// hash the logical range — not just the one chunk that overlaps it.
func TestCopyChunksForRangeSparseETags(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
fid := fmt.Sprintf("3,%016x%08x", 105, 206)
volume.stored[fid] = []byte("abc")
sum := md5.Sum([]byte("abc"))
entry := &filer_pb.Entry{
Name: "sparse",
Attributes: &filer_pb.FuseAttributes{
FileSize: 6,
},
Chunks: []*filer_pb.FileChunk{
{FileId: fid, Offset: 3, Size: 3, ETag: base64.StdEncoding.EncodeToString(sum[:])},
},
}
// bytes 0-5 read as "\x00\x00\x00abc"; the part MD5 must cover the zeros too.
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 5, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange sparse: %v", err)
}
if len(dstChunks) != 1 || dstChunks[0].Offset != 3 {
t.Fatalf("sparse copy chunks = %+v, want one chunk at offset 3", dstChunks)
}
wantMd5 := md5.Sum([]byte("\x00\x00\x00abc"))
if !bytes.Equal(partMd5, wantMd5[:]) {
t.Errorf("sparse part Md5 = %x, want %x (zero-filled range)", partMd5, wantMd5)
}
// A range over a pure hole copies nothing but still reads as zeros.
holeChunks, holeMd5, err := s3a.copyChunksForRange(entry, 0, 2, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange hole: %v", err)
}
if len(holeChunks) != 0 {
t.Fatalf("hole copy returned %d chunks, want 0", len(holeChunks))
}
wantHoleMd5 := md5.Sum([]byte{0, 0, 0})
if !bytes.Equal(holeMd5, wantHoleMd5[:]) {
t.Errorf("hole part Md5 = %x, want %x (all zeros)", holeMd5, wantHoleMd5)
}
}
// Destination chunks must resolve overlaps the way the source did: reads
// pick winners by ModifiedTsNs, so the copied chunks keep the source's
// precedence even though they were uploaded concurrently.
func TestCopyChunksForRangePreservesSourcePrecedence(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
etagOf := func(b []byte) string {
sum := md5.Sum(b)
return base64.StdEncoding.EncodeToString(sum[:])
}
fidA := fmt.Sprintf("3,%016x%08x", 107, 208)
fidB := fmt.Sprintf("3,%016x%08x", 109, 210)
volume.stored[fidA] = []byte("aaaa")
volume.stored[fidB] = []byte("bbbb")
// A covers [0,4), B covers [2,6); B's range is copied into a chunk that
// overlaps A's copy on [2,4), and the source says A wins there.
entry := &filer_pb.Entry{
Name: "overlap",
Attributes: &filer_pb.FuseAttributes{FileSize: 6},
Chunks: []*filer_pb.FileChunk{
{FileId: fidA, Offset: 0, Size: 4, ModifiedTsNs: 20, ETag: etagOf([]byte("aaaa"))},
{FileId: fidB, Offset: 2, Size: 4, ModifiedTsNs: 10, ETag: etagOf([]byte("bbbb"))},
},
}
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 5, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange overlap: %v", err)
}
if len(dstChunks) != 2 {
t.Fatalf("overlap copy returned %d chunks, want 2", len(dstChunks))
}
if dstChunks[0].ModifiedTsNs <= dstChunks[1].ModifiedTsNs {
t.Errorf("destination precedence inverted: chunk from ts=20 got %d, chunk from ts=10 got %d",
dstChunks[0].ModifiedTsNs, dstChunks[1].ModifiedTsNs)
}
// The source resolves to "aaaa"+"bb"; the part MD5 must match that view.
wantMd5 := md5.Sum([]byte("aaaabb"))
if !bytes.Equal(partMd5, wantMd5[:]) {
t.Errorf("overlap part Md5 = %x, want %x", partMd5, wantMd5)
}
}
// An inline-Content source has no chunks for the raw-byte copy; the plaintext
// stream path must write the range's real bytes and tag the part with their
// MD5, not a zero-fill digest.
func TestCopyObjectPartInlineContent(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
content := []byte("inline-content")
entry := &filer_pb.Entry{
Name: "src",
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(len(content))},
Content: content,
}
r := httptest.NewRequest(http.MethodPut, "/b/dst?partNumber=1&uploadId=u1", nil)
etag, _, code := s3a.copyObjectPartViaReencryption(r, entry, 2, 5, "b", "dst", "u1", 1, &filer_pb.Entry{})
if code != s3err.ErrNone {
t.Fatalf("copyObjectPartViaReencryption: %v", code)
}
wantMd5 := md5.Sum(content[2:6])
if got := strings.Trim(etag, `"`); got != fmt.Sprintf("%x", wantMd5) {
t.Errorf("etag = %q, want %x", got, wantMd5)
}
var partEntry *filer_pb.Entry
for key, e := range filerImpl.entries {
if strings.Contains(key, "u1") {
partEntry = e
}
}
if partEntry == nil || len(partEntry.Chunks) == 0 {
t.Fatalf("no part entry with chunks stored: %+v", filerImpl.entries)
}
found := false
for _, stored := range volume.stored {
if bytes.Contains(stored, content[2:6]) {
found = true
}
}
if !found {
t.Errorf("destination volume does not hold the copied content bytes")
}
}
// The handler must route an inline-Content source away from the raw chunk
// copy — CopyObjectPartHandler has to send it through the plaintext stream
// path or the part lands as zeros.
func TestCopyObjectPartHandlerInlineContent(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
util_http.InitGlobalHttpClient()
content := []byte("inline-content")
uploadID := s3a.generateUploadID("dst")
filerImpl.entries["/buckets/b"] = &filer_pb.Entry{Name: "b", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
filerImpl.entries["/buckets/b/src"] = &filer_pb.Entry{
Name: "src",
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(len(content))},
Content: content,
}
filerImpl.entries["/buckets/b/.uploads/"+uploadID] = &filer_pb.Entry{
Name: uploadID,
IsDirectory: true,
Extended: map[string][]byte{s3_constants.ExtMultipartObjectKey: []byte("dst")},
}
r := httptest.NewRequest(http.MethodPut, "/b/dst?partNumber=1&uploadId="+uploadID, nil)
r = mux.SetURLVars(r, map[string]string{"bucket": "b", "object": "dst"})
r.Header.Set("X-Amz-Copy-Source", "/b/src")
r.Header.Set("x-amz-copy-source-range", "bytes=2-5")
w := httptest.NewRecorder()
s3a.CopyObjectPartHandler(w, r)
if w.Code != http.StatusOK {
t.Fatalf("CopyObjectPartHandler = %d %s, want 200", w.Code, w.Body.String())
}
wantMd5 := fmt.Sprintf("%x", md5.Sum(content[2:6]))
if !strings.Contains(w.Body.String(), wantMd5) {
t.Errorf("response %q does not contain copied-range ETag %q", w.Body.String(), wantMd5)
}
var partEntry *filer_pb.Entry
for key, e := range filerImpl.entries {
if strings.Contains(key, uploadID) && !e.IsDirectory {
partEntry = e
}
}
if partEntry == nil || len(partEntry.Chunks) == 0 {
t.Fatalf("no part entry with chunks stored: %+v", filerImpl.entries)
}
found := false
for _, stored := range volume.stored {
if bytes.Contains(stored, content[2:6]) {
found = true
}
}
if !found {
t.Errorf("destination volume does not hold the copied content bytes")
}
}
// An empty part must carry the empty-body MD5 on the stored entry too, so
// ListParts and complete's composite ETag see the same digest CopyPartResult
// reported.
func TestWriteEmptyCopyPartStoresMd5(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
etag, code := s3a.writeEmptyCopyPart("b", "u1", 3)
if code != s3err.ErrNone {
t.Fatalf("writeEmptyCopyPart: %v", code)
}
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
if etag != emptyMD5Hex {
t.Fatalf("writeEmptyCopyPart etag = %q, want %q", etag, emptyMD5Hex)
}
var partEntry *filer_pb.Entry
for key, e := range filerImpl.entries {
if strings.Contains(key, "u1") {
partEntry = e
}
}
if partEntry == nil {
t.Fatalf("no part entry stored: %+v", filerImpl.entries)
}
if got := string(partEntry.Extended[s3_constants.ExtETagKey]); got != emptyMD5Hex {
t.Errorf("stored ExtETagKey = %q, want %q", got, emptyMD5Hex)
}
if got := fmt.Sprintf("%x", partEntry.Attributes.Md5); got != emptyMD5Hex {
t.Errorf("stored Attributes.Md5 = %q, want %q", got, emptyMD5Hex)
}
}
// A whole-range chunk copy normally reuses the source chunk's ETag as the
// part MD5; TUS and mount writes leave chunks without one, so the part has
// to hash the copied bytes instead of saving an empty digest.
func TestCopyChunksForRangeMissingChunkETag(t *testing.T) {
volume := startFakeVolumeServer(t)
filerImpl := &ambiguousPutFiler{volume: volume, entries: map[string]*filer_pb.Entry{}}
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
util_http.InitGlobalHttpClient()
fid := fmt.Sprintf("3,%016x%08x", 111, 212)
volume.stored[fid] = []byte("etagless")
entry := &filer_pb.Entry{
Name: "src",
Attributes: &filer_pb.FuseAttributes{FileSize: 8},
Chunks: []*filer_pb.FileChunk{
{FileId: fid, Offset: 0, Size: 8},
},
}
dstChunks, partMd5, err := s3a.copyChunksForRange(entry, 0, 7, "/dst")
if err != nil {
t.Fatalf("copyChunksForRange: %v", err)
}
if len(dstChunks) != 1 {
t.Fatalf("copyChunksForRange returned %d chunks, want 1", len(dstChunks))
}
wantMd5 := md5.Sum([]byte("etagless"))
if !bytes.Equal(partMd5, wantMd5[:]) {
t.Errorf("part Md5 = %x, want %x (hashed copied range)", partMd5, wantMd5)
}
}
@@ -2,6 +2,7 @@ package s3api
import (
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"errors"
@@ -419,14 +420,19 @@ func (s3a *S3ApiServer) writeEmptyCopyPart(dstBucket, uploadID string, partID in
return "", s3err.ErrInternalError
}
}
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
if err := s3a.mkFile(uploadDir, partName, nil, func(e *filer_pb.Entry) {
if e.Attributes == nil {
e.Attributes = &filer_pb.FuseAttributes{}
}
e.Attributes.FileSize = 0
e.Attributes.Md5 = md5.New().Sum(nil)
if e.Extended == nil {
e.Extended = map[string][]byte{}
}
e.Extended[s3_constants.ExtETagKey] = []byte(emptyMD5Hex)
}); err != nil {
return "", s3err.ErrInternalError
}
const emptyMD5Hex = "d41d8cd98f00b204e9800998ecf8427e"
return emptyMD5Hex, s3err.ErrNone
}
@@ -73,12 +73,19 @@ func canStreamCopyChunk(chunk *filer_pb.FileChunk) bool {
// Trusting a hint that disagrees with the wire bytes (e.g. caller says
// "compressed" but the volume returned raw bytes for a partial range)
// would label raw bytes as gzip and corrupt reads on the destination.
//
// digestWriter, when non-nil, receives the plaintext bytes as they are
// forwarded so the caller can derive the destination chunk's ETag. It is
// not fed when the wire bytes are gzip — only possible on the full-chunk
// pass-through, whose logical content still matches the source chunk's
// own ETag and needs no recomputation.
func (s3a *S3ApiServer) streamCopyChunkRange(
ctx context.Context,
srcUrl, srcFileId string,
offset, size int64,
isFullChunk bool,
assignResult *filer_pb.AssignVolumeResponse,
digestWriter io.Writer,
) error {
// Range header takes int64, but mirror the int32 bound from
// downloadChunkData so the two paths reject the same inputs.
@@ -166,7 +173,11 @@ func (s3a *S3ApiServer) streamCopyChunkRange(
return
}
if _, err := io.Copy(fw, srcResp.Body); err != nil {
src := io.Reader(srcResp.Body)
if digestWriter != nil && !bodyIsGzipped {
src = io.TeeReader(srcResp.Body, digestWriter)
}
if _, err := io.Copy(fw, src); err != nil {
if shouldLogStreamError(err) {
glog.V(2).Infof("stream copy %s offset=%d size=%d: %v",
srcUrl, offset, size, err)
+2 -2
View File
@@ -210,7 +210,7 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque
// The filer ops below each retry, and a failover walk runs the whole set
// once per filer, so the backoff comes out of one allowance held here.
r = r.WithContext(withFilerRetryBudget(r.Context(), filerRetryRequestBudget))
if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil {
if _, err := s3a.validateTableBucketObjectPathOrVerify(bucket, object); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
@@ -479,7 +479,7 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h
deleteErrors = append(deleteErrors, deleteErrorFromCode(validationCode, object.Key, object.VersionId))
continue
}
if err := s3a.validateTableBucketObjectPath(bucket, object.Key); err != nil {
if _, err := s3a.validateTableBucketObjectPathOrVerify(bucket, object.Key); err != nil {
deleteErrors = append(deleteErrors, deleteErrorFromCode(s3err.ErrAccessDenied, object.Key, object.VersionId))
continue
}
+7 -46
View File
@@ -540,18 +540,6 @@ func (s3a *S3ApiServer) listFilerEntries(ctx context.Context, req listObjectsReq
}
dirName, entryName, _ := entryUrlEncode(dir, entry.Name, encodingTypeUrl)
if entry.IsDirectory {
if cursor.emitDirAsCommonPrefix {
cursor.emitDirAsCommonPrefix = false
dirPrefix := fmt.Sprintf("%s/%s/", dir, entry.Name)[len(bucketPrefix):]
commonPrefixes = append(commonPrefixes, PrefixEntry{
Prefix: dirPrefix,
})
cursor.maxKeys--
lastEntryWasCommonPrefix = true
lastCommonPrefix = dirPrefix
lastPrefixNullBackers, lastPrefixConfirmed = nil, true
return
}
if originalPrefix != "" {
normalizedPrefix := strings.TrimPrefix(strings.TrimSuffix(originalPrefix, "/"), "/")
if normalizedPrefix != "" {
@@ -806,9 +794,6 @@ type ListingCursor struct {
// resolvePendingNulls settles trailing null objects whose .versions sibling
// has not streamed yet before a page is declared full.
resolvePendingNulls func() error
// emitDirAsCommonPrefix marks the next directory entry passed to the entry
// callback for reporting as a CommonPrefix rather than a Contents key.
emitDirAsCommonPrefix bool
// prefixObjectKeyOnly limits the next prefix-object entry passed to the entry
// callback to its plain key; its "<name>/" CommonPrefix is deferred to the
// directory's own block position.
@@ -1024,10 +1009,8 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
}
isKeyObject := e.IsDirectoryKeyObject() && !e.IsPrefixObject()
if delimiter != "/" || cursor.prefixEndsOnDelimiter {
// A trailing-slash prefix (e.g. "logs/") names one directory and asks
// whether it exists, so a real but empty directory must be reported for
// that probe.
explicitDirProbe := cursor.prefixEndsOnDelimiter
// A trailing-slash prefix (e.g. "logs/") names one directory, so the
// recursion below answers whether it exists and what it holds.
if cursor.prefixEndsOnDelimiter {
cursor.prefixEndsOnDelimiter = false
}
@@ -1036,13 +1019,7 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
// appear as regular keys in recursive listing mode.
eachEntryFn(dir, emitEntry)
}
// Recurse into subdirectory to list any children, noting whether the
// subtree produced any entries.
childEmitted := false
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + e.Name, marker: subMarker, delimiter: delimiter, bucket: bucket}, cursor, func(d string, ent *filer_pb.Entry) {
childEmitted = true
eachEntryFn(d, ent)
})
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + e.Name, marker: subMarker, delimiter: delimiter, bucket: bucket}, cursor, eachEntryFn)
if subErr != nil {
return fmt.Errorf("doListFilerEntries2: %w", subErr)
}
@@ -1052,26 +1029,10 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
// keep a resume position that re-enters it.
cursor.isTruncated = true
}
// A real but empty directory (created out of band via mount, mkdir or
// the filer API, so it carries no MIME) is otherwise invisible to S3
// clients that detect directories by listing it under its own "<dir>/"
// prefix. Answer that explicit probe so tools like hadoop-aws can find
// the directory: a delimited listing reports it as a CommonPrefix --
// a real key in Contents would look like an object a client can read --
// while a recursive listing keeps the directory marker key it used.
// Plain listings are left untouched, so empty directories left behind
// by deleted objects are not shown as keys.
// A directory that still holds version history no longer names anything,
// so it gets no marker either.
if explicitDirProbe && !isKeyObject && !childEmitted && !cursor.isTruncated && e.Attributes != nil &&
!s3a.dirHoldsOnlyHiddenEntries(ctx, client, bucket, dir+"/"+e.Name, cursor) {
if delimiter == "/" {
cursor.emitDirAsCommonPrefix = true
} else {
emitEntry.Attributes.Mime = s3_constants.FolderMimeType
}
eachEntryFn(dir, emitEntry)
}
// An empty directory gets no marker under its own "<dir>/" prefix:
// that listing enumerates children, where S3 returns nothing for an
// empty prefix, and a fake entry is read back as a real object by
// clients that enumerate locks or similar.
// println("doListFilerEntries2 dir", dir+"/"+e.Name, "subNextMarker", subNextMarker)
if cursor.isTruncated || matchedPrefixDir {
stopped = true
@@ -65,12 +65,11 @@ func TestDirectoryListedAsCommonPrefix(t *testing.T) {
assert.Empty(t, seenFiles, "No files should be seen, only the directory")
}
// TestEmptyDirectorySurfacedAsMarker reproduces the hadoop-aws / Spark case where a
// real but empty directory (created via mount, mkdir or the filer API, so it has no
// MIME) must be visible to S3 clients that detect directories by listing under the
// "<dir>/" prefix. Under a delimiter it is reported as a CommonPrefix, which the
// probe accepts, instead of a key a client might try to read.
func TestEmptyDirectorySurfacedAsMarker(t *testing.T) {
// TestEmptyDirectoryNotListedUnderOwnPrefix covers the explicit "<dir>/" probe
// on a real but empty directory (created via mount, mkdir or the filer API, so
// it has no MIME): it is listed under its own prefix in neither delimited nor
// recursive mode, matching S3 where an empty prefix has no key to return.
func TestEmptyDirectoryNotListedUnderOwnPrefix(t *testing.T) {
s3a := &S3ApiServer{
option: &S3ApiServerOption{BucketsPath: "/buckets"},
}
@@ -87,20 +86,15 @@ func TestEmptyDirectorySurfacedAsMarker(t *testing.T) {
},
}
// Mirrors the getFileStatus probe: prefix "logs/" with delimiter "/".
cursor := &ListingCursor{maxKeys: 1000, prefixEndsOnDelimiter: true}
var seen []*filer_pb.Entry
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/test", prefix: "logs", delimiter: "/", bucket: "test"}, cursor,
func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry)
})
assert.NoError(t, err)
if assert.Len(t, seen, 1, "empty directory must be surfaced under the logs/ prefix") {
assert.True(t, cursor.emitDirAsCommonPrefix,
"a delimited probe reports the empty directory as a common prefix")
assert.Empty(t, seen[0].Attributes.Mime,
"the entry must not be dressed as a directory key object")
for _, delimiter := range []string{"/", ""} {
cursor := &ListingCursor{maxKeys: 1000, prefixEndsOnDelimiter: true}
var seen []*filer_pb.Entry
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/test", prefix: "logs", delimiter: delimiter, bucket: "test"}, cursor,
func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry)
})
assert.NoError(t, err)
assert.Empty(t, seen, "an empty directory reports nothing under its own prefix (delimiter %q)", delimiter)
}
}
@@ -185,10 +179,10 @@ func TestEmptyDirectoryHiddenInFlatListing(t *testing.T) {
assert.Equal(t, []string{"foo"}, seen, "flat listing must hide the empty directory and return only real objects")
}
// A trailing-slash delimited listing of an empty directory reports the
// directory as a CommonPrefix, not as a Contents key: clients that treat
// every returned key as an object (e.g. lock listings) skip prefixes.
func TestEmptyDirectoryListedAsCommonPrefix(t *testing.T) {
// A trailing-slash delimited listing of an empty directory enumerates its
// children, so it returns nothing: the directory is neither a readable Contents
// key nor a CommonPrefix of its own.
func TestEmptyDirectoryHiddenInDelimitedSelfListing(t *testing.T) {
filer := &fakeBucketDeleteFiler{entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/test": {
{Name: "locks", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: ""}},
@@ -208,6 +202,5 @@ func TestEmptyDirectoryListedAsCommonPrefix(t *testing.T) {
})
require.NoError(t, err)
assert.Empty(t, response.Contents, "an empty directory is not a readable key")
require.Len(t, response.CommonPrefixes, 1)
assert.Equal(t, "locks/", response.CommonPrefixes[0].Prefix)
assert.Empty(t, response.CommonPrefixes, "an empty directory has no prefixes to fold into")
}
+18 -4
View File
@@ -82,7 +82,7 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R
// Verify policy signature.
identity, errCode := s3a.iam.doesPolicySignatureMatch(formValues)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, errCode))
return
}
if identity != nil {
@@ -140,7 +140,7 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R
// Authorize the object like the PUT path; the coarse Write check above does not consult the bucket policy.
if errCode := s3a.iam.AuthorizeObjectWrite(r, identity, bucket, object); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, errCode))
return
}
@@ -362,10 +362,24 @@ func getRedirectPostRawQuery(bucket, key, etag string) string {
}
// Check to see if Policy is signed correctly, returning the signing identity.
// The identity's Write permission on the bucket is checked only once the
// signature verifies, as on the header-signed path, so a form that fails
// verification learns nothing about the key's permissions. A verified identity
// that may not write is returned with ErrAccessDenied.
func (iam *IdentityAccessManagement) doesPolicySignatureMatch(formValues http.Header) (*Identity, s3err.ErrorCode) {
var identity *Identity
var errCode s3err.ErrorCode
// For SignV2 - Signature field will be valid
if _, ok := formValues["Signature"]; ok {
return iam.doesPolicySignatureV2Match(formValues)
identity, errCode = iam.doesPolicySignatureV2Match(formValues)
} else {
identity, errCode = iam.doesPolicySignatureV4Match(formValues)
}
return iam.doesPolicySignatureV4Match(formValues)
if errCode != s3err.ErrNone {
return nil, errCode
}
if !identity.CanDo(s3_constants.ACTION_WRITE, formValues.Get("bucket"), "") {
return identity, s3err.ErrAccessDenied
}
return identity, s3err.ErrNone
}
+6 -2
View File
@@ -112,7 +112,7 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
s3err.WriteErrorResponse(w, r, s3err.ErrKeyTooLongError)
return
}
if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil {
if isVerify, err := s3a.validateTableBucketObjectPathOrVerify(bucket, object); err != nil || (isVerify && !volumeVerifyPutAllowed(r, object)) {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
@@ -991,7 +991,11 @@ func (s3a *S3ApiServer) putToFiler(r *http.Request, filePath string, dataReader
}
glog.V(3).Infof("putToFiler: Calling CreateEntry for %s", filePath)
err := filer_pb.CreateEntry(context.Background(), client, req)
if errors.Is(err, filer_pb.ErrExistingIsDirectory) && !isReservedDirectoryName(entry.Name) {
// A verification key in a table bucket never becomes a prefix
// object: the directory it meets is a namespace, created after the
// handler looked.
if errors.Is(err, filer_pb.ErrExistingIsDirectory) && !isReservedDirectoryName(entry.Name) &&
!(isVolumeVerifyKey(object) && s3a.isTableBucket(bucket)) {
// Other keys are nested under this one. S3 keys are flat, so the key is
// stored on the directory they live under rather than refused.
entry.MarkPrefixObject()
@@ -87,7 +87,7 @@ func TestCopyChunksReadOnlyIsAccessDenied(t *testing.T) {
t.Fatalf("copyChunks err %v maps to %v, want %v", err, code, s3err.ErrAccessDenied)
}
_, err = s3a.copyChunksForRange(entry, 0, 7, "/buckets/b/dst")
_, _, err = s3a.copyChunksForRange(entry, 0, 7, "/buckets/b/dst")
if code := s3a.mapCopyErrorToS3Error(err); code != s3err.ErrAccessDenied {
t.Fatalf("copyChunksForRange err %v maps to %v, want %v", err, code, s3err.ErrAccessDenied)
}
+30
View File
@@ -40,3 +40,33 @@ func TestParseAndValidateRangeRejectsMalformedNumericOffsets(t *testing.T) {
})
}
}
func TestHeadObjectRange(t *testing.T) {
s3a, f := newAnonymousReadTestServer(t)
f.entries["/buckets/b/o"] = anonymousReadEntry(anonymousReadGrant("READ"))
for _, tc := range []struct {
rangeHeader, contentRange, contentLength string
code int
}{
{"", "", "11", http.StatusOK},
{"bytes=1-4", "bytes 1-4/11", "4", http.StatusPartialContent},
{"bytes=-3", "bytes 8-10/11", "3", http.StatusPartialContent},
{"bytes=5-", "bytes 5-10/11", "6", http.StatusPartialContent},
{"bytes=20-", "bytes */11", "", http.StatusRequestedRangeNotSatisfiable},
} {
headers := map[string]string{}
if tc.rangeHeader != "" {
headers["Range"] = tc.rangeHeader
}
rr := serveAnonymousRead(s3a, http.MethodHead, "o", "", headers)
if rr.Code != tc.code {
t.Errorf("Range %q: status %d, want %d", tc.rangeHeader, rr.Code, tc.code)
}
if got := rr.Header().Get("Content-Range"); got != tc.contentRange {
t.Errorf("Range %q: Content-Range %q, want %q", tc.rangeHeader, got, tc.contentRange)
}
if got := rr.Header().Get("Content-Length"); tc.contentLength != "" && got != tc.contentLength {
t.Errorf("Range %q: Content-Length %q, want %q", tc.rangeHeader, got, tc.contentLength)
}
}
}
+4 -1
View File
@@ -661,7 +661,10 @@ func (s3a *S3ApiServer) checkPolicyWithEntry(r *http.Request, bucket, object, ac
// Returns s3err.ErrNone if allowed, or an error code if denied or on error.
func (s3a *S3ApiServer) recheckPolicyWithObjectEntry(r *http.Request, bucket, object, action string, objectEntry map[string][]byte, handlerName string) s3err.ErrorCode {
if isAnonymousObjectRead(r) {
return s3a.authorizeAnonymousObjectRead(r, bucket, object, objectEntry)
// Refined like writeAnonymousAwareNotFound's masked miss, so a denied
// existing key and a missing key answer alike on a malformed name.
identity, _ := GetIdentityFromContext(r).(*Identity)
return refineDenialForBucketName(r, identity, s3a.authorizeAnonymousObjectRead(r, bucket, object, objectEntry))
}
identityRaw := GetIdentityFromContext(r)
var identity *Identity
+110 -1
View File
@@ -127,6 +127,10 @@ func (h *S3TablesHandler) HandleRequest(w http.ResponseWriter, r *http.Request,
glog.V(3).Infof("S3Tables: handling operation %s", operation)
// the status the handler answers decides how its error is logged (logRequestError)
sw := &statusRecorder{ResponseWriter: w}
w = sw
var err error
switch operation {
// Table Bucket operations
@@ -223,10 +227,51 @@ func (h *S3TablesHandler) HandleRequest(w http.ResponseWriter, r *http.Request,
}
if err != nil {
glog.Errorf("S3Tables: error handling %s: %v", operation, err)
logRequestError(operation, sw.status, err)
}
}
// A refusal the handler answered with a 4xx (access denied, not authorized,
// not found, already exists, not empty) is the API working as intended, not a
// server fault; it is logged at V(1). A 5xx, or an error returned without any
// status written, stays at ERROR.
var (
logClientRefusal = func(format string, args ...interface{}) { glog.V(1).Infof(format, args...) }
logServerError = glog.Errorf
)
func logRequestError(operation string, status int, err error) {
if status >= 400 && status < 500 {
logClientRefusal("S3Tables: %s refused (%d): %v", operation, status, err)
return
}
logServerError("S3Tables: error handling %s: %v", operation, err)
}
// statusRecorder remembers the first status written through it.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
if s.status == 0 {
s.status = code
}
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
if s.status == 0 {
s.status = http.StatusOK
}
return s.ResponseWriter.Write(b)
}
// Unwrap lets http.NewResponseController reach the underlying writer, so it
// flushes (or reports ErrNotSupported) exactly as that writer does.
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
// Principal/authorization helpers
// getAccountID returns a stable caller identifier for ownership and permission checks.
@@ -459,6 +504,70 @@ func isAuthError(err error) bool {
return errors.As(err, &authErr) || errors.Is(err, ErrAccessDenied)
}
// authorizeAbsentTarget reports whether the caller may learn the target
// resolved to nothing, so a caller without a grant gets the same response a
// denied existing target would give. The bucket named by the target still
// applies whatever metadata and policy load; with nothing readable to
// evaluate, only an identity-level grant on the operation, the admin, or a
// default-allow deployment passes.
func (h *S3TablesHandler) authorizeAbsentTarget(r *http.Request, filerClient FilerClient, operation, bucketName, namespace, name, resourceARN string) bool {
principal := h.getAccountID(r)
if principal == "" {
return false
}
var bucketOwner, bucketPolicy string
var bucketTags map[string]string
if bucketName != "" {
bucketPath := GetTableBucketPath(bucketName)
_ = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
if data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata); err == nil {
var meta tableBucketMetadata
if json.Unmarshal(data, &meta) == nil {
bucketOwner = meta.OwnerAccountID
}
}
if policy, err := h.readPolicy(r, client, bucketPath); err == nil {
bucketPolicy = policy
}
bucketTags, _ = h.readTags(r.Context(), client, bucketPath)
return nil
})
}
bucketARN := h.generateTableBucketARN(bucketOwner, bucketName)
// Judge the target by the canonical ARN the surviving bucket resolves to:
// the request ARN's account and region are the caller's to choose and must
// not decide whether a policy Deny applies.
canonicalARN := resourceARN
if bucketOwner != "" {
if name != "" {
canonicalARN = h.generateTableARN(bucketOwner, bucketName, namespace+"/"+name)
} else {
canonicalARN = bucketARN
}
}
ctx := &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
IsLocalAccountID: h.localAccountIDs,
TableBucketName: bucketName,
Namespace: namespace,
TableName: name,
TableBucketTags: bucketTags,
IdentityActions: getIdentityActions(r),
DefaultAllow: h.defaultAllowFor(r),
}
if resourceARN != "" &&
matchesDenyStatement(operation, principal, bucketPolicy, canonicalARN, ctx) {
return false
}
if CheckTableOrBucketPermission(operation, principal, "", "", canonicalARN,
bucketOwner, bucketPolicy, bucketARN, ctx) {
return true
}
return bucketOwner == "" && checkPermission(operation, principal, "", "", resourceARN, ctx)
}
func (h *S3TablesHandler) readTags(ctx context.Context, client filer_pb.SeaweedFilerClient, path string) (map[string]string, error) {
data, err := h.getExtendedAttribute(ctx, client, path, ExtendedKeyTags)
if err != nil {
@@ -58,7 +58,7 @@ func (h *S3TablesHandler) handleGetTableBucket(w http.ResponseWriter, r *http.Re
})
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
} else {
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to get table bucket: %v", err))
@@ -3,6 +3,7 @@ package s3tables
import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"testing"
@@ -211,3 +212,165 @@ func TestDeniedTableWritesDoNotRevealViews(t *testing.T) {
})
}
}
// A caller without a grant must get the same answer whether a list target
// exists or not, and whether the bucket behind it exists at all.
func TestAbsentListTargetsMatchDeniedAnswers(t *testing.T) {
existing, manager := startRenameManager(t)
missing := s3tablestest.Start(t)
manager.SetTrusted(false)
manager.SetDefaultAllow(false)
for _, tc := range []struct {
name string
fs *s3tablestest.MemFiler
operation string
denied interface{}
request interface{}
}{
{"ListTables missing namespace", existing, "ListTables",
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ghost"}}},
{"ListTables missing bucket", missing, "ListTables",
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}}},
{"ListTables missing bucket, all namespaces", missing, "ListTables",
&ListTablesRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
&ListTablesRequest{TableBucketARN: mustBucketARN(t)}},
{"ListViews missing namespace", existing, "ListViews",
&ListViewsRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
&ListViewsRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"ghost"}}},
{"CreateNamespace missing bucket", missing, "CreateNamespace",
&CreateNamespaceRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"newns"}},
&CreateNamespaceRequest{TableBucketARN: mustBucketARN(t), Namespace: []string{"newns"}}},
} {
t.Run(tc.name, func(t *testing.T) {
denied := runUnauthorizedRequest(t, manager, existing, tc.operation, tc.denied)
assert.Equal(t, 403, denied.status)
got := runUnauthorizedRequest(t, manager, tc.fs, tc.operation, tc.request)
assert.Equal(t, denied, got)
})
}
}
// A denied tag operation and the same operation on a missing table must answer
// alike; a granted caller still gets the truthful not-found.
func TestAbsentTagTargetsMatchDeniedAnswers(t *testing.T) {
existing, manager := startRenameManager(t)
manager.SetTrusted(false)
manager.SetDefaultAllow(false)
existingARN, err := BuildTableARN(DefaultRegion, DefaultAccountID, renameTestBucket, "ns", "t")
require.NoError(t, err)
missingARN, err := BuildTableARN(DefaultRegion, DefaultAccountID, renameTestBucket, "ns", "ghost")
require.NoError(t, err)
requestFor := func(operation, arn string) interface{} {
switch operation {
case "TagResource":
return &TagResourceRequest{ResourceARN: arn, Tags: map[string]string{"k": "v"}}
case "UntagResource":
return &UntagResourceRequest{ResourceARN: arn, TagKeys: []string{"k"}}
}
return &ListTagsForResourceRequest{ResourceARN: arn}
}
for _, operation := range []string{"TagResource", "ListTagsForResource", "UntagResource"} {
t.Run(operation, func(t *testing.T) {
denied := runUnauthorizedRequest(t, manager, existing, operation, requestFor(operation, existingARN))
assert.Equal(t, 403, denied.status)
got := runUnauthorizedRequest(t, manager, existing, operation, requestFor(operation, missingARN))
assert.Equal(t, denied, got)
})
}
}
// A directory that holds a plain S3 bucket name is not a table bucket: every
// caller gets the same 404 an absent name gets, not a 500 that reveals the
// name resolves in the filer.
func TestPlainBucketReadsAsAbsent(t *testing.T) {
existing, manager := startRenameManager(t)
manager.SetTrusted(false)
manager.SetDefaultAllow(false)
existing.Put(TablesPath, "plainbkt", nil)
plainARN, err := BuildBucketARN(DefaultRegion, DefaultAccountID, "plainbkt")
require.NoError(t, err)
for _, tc := range []struct {
operation string
request interface{}
}{
{"GetTableBucket", &GetTableBucketRequest{TableBucketARN: plainARN}},
{"ListNamespaces", &ListNamespacesRequest{TableBucketARN: plainARN}},
} {
t.Run(tc.operation, func(t *testing.T) {
got := runUnauthorizedRequest(t, manager, existing, tc.operation, tc.request)
assert.Equal(t, 404, got.status)
assert.Equal(t, ErrCodeNoSuchBucket, got.body.Type)
})
}
}
// A caller with an identity-level grant still learns that a name is absent.
func TestGrantedCallerLearnsAbsence(t *testing.T) {
missing := s3tablestest.Start(t)
manager := NewManager()
manager.SetTrusted(false)
manager.SetDefaultAllow(false)
got := runRequestAs(t, manager, missing, "ListTables", &ListTablesRequest{
TableBucketARN: mustBucketARN(t), Namespace: []string{"ns"}},
[]string{"s3tables:ListTables"})
assert.Equal(t, 200, got.status)
missingTableARN, err := BuildTableARN(DefaultRegion, DefaultAccountID, renameTestBucket, "ns", "ghost")
require.NoError(t, err)
got = runRequestAs(t, manager, missing, "ListTagsForResource",
&ListTagsForResourceRequest{ResourceARN: missingTableARN},
[]string{"s3tables:ListTagsForResource"})
assert.Equal(t, 404, got.status)
assert.Equal(t, ErrCodeNoSuchTable, got.body.Type)
}
// A request ARN that names a foreign account still resolves to the local
// names, so the absent-target answer must match the denial the canonical ARN
// gets; otherwise a reformatted ARN restores the existence oracle.
func TestAbsentTagTargetDenySurvivesForeignAccountARN(t *testing.T) {
fs, manager := startRenameManager(t)
manager.SetTrusted(false)
manager.SetDefaultAllow(false)
bucketMeta, _ := json.Marshal(tableBucketMetadata{Name: renameTestBucket, OwnerAccountID: DefaultAccountID})
fs.Put(TablesPath, renameTestBucket, map[string][]byte{
ExtendedKeyTableBucket: []byte("{}"),
ExtendedKeyMetadata: bucketMeta,
ExtendedKeyPolicy: []byte(fmt.Sprintf(
`{"Statement":[{"Effect":"Deny","Principal":"*","Action":"s3tables:*","Resource":%q}]}`,
mustBucketARN(t)+"/table/*")),
})
foreignARN := func(table string) string {
return fmt.Sprintf("arn:aws:s3tables:%s:999999999999:bucket/%s/table/ns/%s",
DefaultRegion, renameTestBucket, table)
}
requestFor := func(operation, arn string) interface{} {
switch operation {
case "TagResource":
return &TagResourceRequest{ResourceARN: arn, Tags: map[string]string{"k": "v"}}
case "UntagResource":
return &UntagResourceRequest{ResourceARN: arn, TagKeys: []string{"k"}}
}
return &ListTagsForResourceRequest{ResourceARN: arn}
}
for _, operation := range []string{"TagResource", "ListTagsForResource", "UntagResource"} {
t.Run(operation, func(t *testing.T) {
actions := []string{"s3tables:" + operation}
denied := runRequestAs(t, manager, fs, operation, requestFor(operation, foreignARN("t")), actions)
assert.Equal(t, 403, denied.status)
got := runRequestAs(t, manager, fs, operation, requestFor(operation, foreignARN("ghost")), actions)
assert.Equal(t, denied, got)
})
}
}
@@ -0,0 +1,123 @@
package s3tables
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// captureLogs swaps the two log sinks for recorders and restores them when the test ends.
func captureLogs(t *testing.T) (refusals, serverErrors *[]string) {
t.Helper()
var r, s []string
oldR, oldS := logClientRefusal, logServerError
logClientRefusal = func(f string, a ...interface{}) { r = append(r, fmt.Sprintf(f, a...)) }
logServerError = func(f string, a ...interface{}) { s = append(s, fmt.Sprintf(f, a...)) }
t.Cleanup(func() { logClientRefusal, logServerError = oldR, oldS })
return &r, &s
}
// A refusal the API answers with a 4xx is not a server error.
func TestRequestRefusedWith4xxIsNotLoggedAsError(t *testing.T) {
refusals, serverErrors := captureLogs(t)
h := NewS3TablesHandler()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("not json"))
req.Header.Set("X-Amz-Target", "S3Tables.ListNamespaces")
rec := httptest.NewRecorder()
h.HandleRequest(rec, req, nil)
if rec.Code < 400 || rec.Code >= 500 {
t.Fatalf("the probe request must be refused with a 4xx for this test to mean anything, got %d", rec.Code) // positive control
}
if len(*serverErrors) != 0 {
t.Fatalf("a %d refusal was logged at ERROR: %v", rec.Code, *serverErrors)
}
if len(*refusals) != 1 || !strings.Contains((*refusals)[0], "ListNamespaces refused") {
t.Fatalf("the refusal must still be logged once (at V(1)), got %v", *refusals)
}
}
func TestLogRequestErrorByStatus(t *testing.T) {
for _, c := range []struct {
status int
wantError bool
}{{http.StatusForbidden, false}, {http.StatusNotFound, false}, {http.StatusConflict, false},
{http.StatusInternalServerError, true}, {http.StatusServiceUnavailable, true}, {0, true}} {
refusals, serverErrors := captureLogs(t)
logRequestError("Op", c.status, errors.New("boom"))
if gotError := len(*serverErrors) == 1 && len(*refusals) == 0; gotError != c.wantError {
t.Errorf("status %d: ERROR=%v, want %v (refusals %v, errors %v)", c.status, gotError, c.wantError, *refusals, *serverErrors)
}
}
}
func TestStatusRecorderKeepsTheFirstStatus(t *testing.T) {
rec := httptest.NewRecorder()
sw := &statusRecorder{ResponseWriter: rec}
_, _ = sw.Write([]byte("x"))
sw.WriteHeader(http.StatusInternalServerError)
if sw.status != http.StatusOK {
t.Fatalf("an implicit 200 by Write must win, got %d", sw.status)
}
sw2 := &statusRecorder{ResponseWriter: httptest.NewRecorder()}
sw2.WriteHeader(http.StatusForbidden)
if sw2.status != http.StatusForbidden {
t.Fatalf("got %d", sw2.status)
}
}
func TestStatusRecorderUnwrapsForResponseController(t *testing.T) {
sw := &statusRecorder{ResponseWriter: httptest.NewRecorder()}
if err := http.NewResponseController(sw).Flush(); err != nil {
t.Fatalf("ResponseController through the recorder: %v", err)
}
if sw.Unwrap() == nil {
t.Fatal("Unwrap must return the wrapped writer")
}
}
// A malformed CreateNamespace body is answered with a 400; neither the
// dispatcher nor the handler itself may log it at ERROR.
func TestMalformedCreateNamespaceIsNotLoggedAsError(t *testing.T) {
refusals, serverErrors := captureLogs(t)
before := glog.Stats.Error.Lines()
glog.Errorf("S3Tables test: positive control for the ERROR line counter")
if glog.Stats.Error.Lines() != before+1 {
t.Fatal("the ERROR line counter did not see a direct glog.Errorf") // positive control
}
h := NewS3TablesHandler()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("not json"))
req.Header.Set("X-Amz-Target", "S3Tables.CreateNamespace")
rec := httptest.NewRecorder()
before = glog.Stats.Error.Lines()
h.HandleRequest(rec, req, nil)
errorLines := glog.Stats.Error.Lines() - before
if rec.Code != http.StatusBadRequest {
t.Fatalf("a malformed body must be refused with 400, got %d", rec.Code)
}
if errorLines != 0 || len(*serverErrors) != 0 {
t.Fatalf("a 400 refusal was logged at ERROR: %d direct line(s), dispatcher %v", errorLines, *serverErrors)
}
if len(*refusals) != 1 || !strings.Contains((*refusals)[0], "CreateNamespace refused") {
t.Fatalf("the refusal must still be logged once (at V(1)), got %v", *refusals)
}
}
// The recorder must not claim to flush a writer that cannot.
func TestStatusRecorderFlushFollowsTheWrappedWriter(t *testing.T) {
type plainWriter struct{ http.ResponseWriter }
sw := &statusRecorder{ResponseWriter: plainWriter{httptest.NewRecorder()}}
if err := http.NewResponseController(sw).Flush(); !errors.Is(err, http.ErrNotSupported) {
t.Fatalf("Flush through a recorder over a non-flushing writer: %v, want ErrNotSupported", err)
}
rec := httptest.NewRecorder()
if err := http.NewResponseController(&statusRecorder{ResponseWriter: rec}).Flush(); err != nil || !rec.Flushed {
t.Fatalf("Flush through a recorder over a flushing writer: err=%v flushed=%v", err, rec.Flushed) // positive control
}
}
+6 -3
View File
@@ -36,10 +36,9 @@ func (h *S3TablesHandler) mayRepairOwnerlessBucket(r *http.Request) bool {
// handleCreateNamespace creates a new namespace in a table bucket
func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
glog.Errorf("S3Tables: handleCreateNamespace called")
glog.V(3).Infof("S3Tables: handleCreateNamespace called")
var req CreateNamespaceRequest
if err := h.readRequestBody(r, &req); err != nil {
glog.Errorf("S3Tables: handleCreateNamespace failed to read request body: %v", err)
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
@@ -140,6 +139,10 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
return ErrAccessDenied
}
if errors.Is(err, filer_pb.ErrNotFound) {
if !h.authorizeAbsentTarget(r, filerClient, "CreateNamespace", bucketName, namespaceName, "", "") {
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create namespace in this bucket")
return ErrAccessDenied
}
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
} else {
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to check table bucket: %v", err))
@@ -481,7 +484,7 @@ func (h *S3TablesHandler) handleListNamespaces(w http.ResponseWriter, r *http.Re
})
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
h.writeError(w, http.StatusNotFound, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
} else {
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, fmt.Sprintf("failed to list namespaces: %v", err))
@@ -0,0 +1,39 @@
package s3tables
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// glog.Errorf with a constant message and no format verb is a trace, not an
// error report: every CreateNamespace call logged
// "S3Tables: handleCreateNamespace called" at ERROR.
func TestNoTraceLoggedAtErrorLevel(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil || len(files) == 0 {
t.Fatalf("no sources found (%v)", err) // positive control: the scan must see the package
}
re := regexp.MustCompile(`glog\.Errorf\("([^"]*)"\)`)
scanned := 0
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
scanned++
for _, m := range re.FindAllStringSubmatch(string(b), -1) {
if !strings.Contains(m[1], "%") {
t.Errorf("%s: glog.Errorf(%q) logs a constant trace at ERROR; use glog.V(n).Infof", f, m[1])
}
}
}
if scanned < 5 {
t.Fatalf("scanned only %d source files", scanned)
}
}
+25
View File
@@ -659,6 +659,9 @@ func (h *S3TablesHandler) handleTagResource(w http.ResponseWriter, r *http.Reque
// Read metadata for ownership check
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
return h.absentTagTargetError(r, client, "TagResource", "not authorized to tag resource", req.ResourceARN, rType)
}
return err
}
@@ -793,6 +796,9 @@ func (h *S3TablesHandler) handleListTagsForResource(w http.ResponseWriter, r *ht
// Read metadata for ownership check
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
return h.absentTagTargetError(r, client, "ListTagsForResource", "not authorized to list tags for resource", req.ResourceARN, rType)
}
return err
}
@@ -913,6 +919,9 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
// Read metadata for ownership check
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMetadata)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) || errors.Is(err, ErrAttributeNotFound) {
return h.absentTagTargetError(r, client, "UntagResource", "not authorized to untag resource", req.ResourceARN, rType)
}
return err
}
@@ -1017,6 +1026,22 @@ func (h *S3TablesHandler) handleUntagResource(w http.ResponseWriter, r *http.Req
return nil
}
// absentTagTargetError resolves a tag operation when the resource's catalog
// metadata did not load: a caller the operation would refuse gets its denial,
// everyone else the same not-found an absent name gets.
func (h *S3TablesHandler) absentTagTargetError(r *http.Request, client filer_pb.SeaweedFilerClient, operation, deniedMessage, resourceARN string, rType ResourceType) error {
var bucketName, namespace, name string
if rType == ResourceTypeTable {
bucketName, namespace, name, _ = parseTableFromARN(resourceARN)
} else {
bucketName, _ = parseBucketNameFromARN(resourceARN)
}
if !h.authorizeAbsentTarget(r, &singleClient{client}, operation, bucketName, namespace, name, resourceARN) {
return NewAuthError(operation, h.getAccountID(r), deniedMessage)
}
return filer_pb.ErrNotFound
}
// resolveResourcePath determines the resource path and extended attribute key from a resource ARN
func (h *S3TablesHandler) resolveResourcePath(resourceARN string) (path string, key string, rType ResourceType, err error) {
// Try parsing as table ARN first
+27 -7
View File
@@ -515,11 +515,14 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
bucketTags := map[string]string{}
// Fetch namespace metadata and policy
targetAbsent := false
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
if err != nil {
return err // Not Found handled by caller
}
if err := json.Unmarshal(data, &nsMeta); err != nil {
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
return err
}
targetAbsent = true
} else if err := json.Unmarshal(data, &nsMeta); err != nil {
return err
}
@@ -527,7 +530,7 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
policyData, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyPolicy)
if err == nil {
namespacePolicy = string(policyData)
} else if !errors.Is(err, ErrAttributeNotFound) {
} else if !errors.Is(err, ErrAttributeNotFound) && !errors.Is(err, filer_pb.ErrNotFound) {
return fmt.Errorf("failed to fetch namespace policy: %w", err)
}
@@ -537,6 +540,8 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
if err := json.Unmarshal(data, &bucketMeta); err != nil {
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
}
} else if errors.Is(err, filer_pb.ErrNotFound) {
targetAbsent = true
} else if !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
}
@@ -544,15 +549,24 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
policyData, err = h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy)
if err == nil {
bucketPolicy = string(policyData)
} else if !errors.Is(err, ErrAttributeNotFound) {
} else if !errors.Is(err, ErrAttributeNotFound) && !errors.Is(err, filer_pb.ErrNotFound) {
return fmt.Errorf("failed to fetch bucket policy: %w", err)
}
if tags, err := h.readTags(r.Context(), client, bucketPath); err != nil {
return fmt.Errorf("failed to read bucket tags: %w", err)
if !errors.Is(err, filer_pb.ErrNotFound) {
return fmt.Errorf("failed to read bucket tags: %w", err)
}
} else if tags != nil {
bucketTags = tags
}
if targetAbsent {
if !h.authorizeAbsentTarget(r, &singleClient{client}, "ListTables", bucketName, namespaceName, "", "") {
return ErrAccessDenied
}
return filer_pb.ErrNotFound
}
bucketARN := h.generateTableBucketARN(bucketMeta.OwnerAccountID, bucketName)
identityActions := getIdentityActions(r)
if !CheckTableOrBucketPermission("ListTables", accountID,
@@ -583,7 +597,13 @@ func (h *S3TablesHandler) handleListTables(w http.ResponseWriter, r *http.Reques
// Fetch bucket metadata and policy
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
if err != nil {
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
return fmt.Errorf("failed to fetch bucket metadata: %w", err)
}
if !h.authorizeAbsentTarget(r, &singleClient{client}, "ListTables", bucketName, "", "", "") {
return ErrAccessDenied
}
return filer_pb.ErrNotFound
}
if err := json.Unmarshal(data, &bucketMeta); err != nil {
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
+7 -1
View File
@@ -279,7 +279,13 @@ func (h *S3TablesHandler) handleListViews(w http.ResponseWriter, r *http.Request
var nsMeta namespaceMetadata
data, err := h.getExtendedAttribute(r.Context(), client, namespacePath, ExtendedKeyMetadata)
if err != nil {
return err
if !errors.Is(err, filer_pb.ErrNotFound) && !errors.Is(err, ErrAttributeNotFound) {
return err
}
if !h.authorizeAbsentTarget(r, &singleClient{client}, "ListViews", bucketName, namespaceName, "", "") {
return ErrAccessDenied
}
return filer_pb.ErrNotFound
}
if err := json.Unmarshal(data, &nsMeta); err != nil {
return err
@@ -0,0 +1,163 @@
package s3api
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/assert"
)
const verifyTestBucket = "tables"
// verifyTestNow is the server clock the tests pin; keys are dated around it.
const verifyTestNow = 1790000000000
func newVerifyTestServer(t *testing.T) *S3ApiServer {
t.Helper()
saved := volumeVerifyNow
volumeVerifyNow = func() time.Time { return time.UnixMilli(verifyTestNow) }
savedLookup := volumeVerifyLookup
volumeVerifyLookup = func(*S3ApiServer, string, string) (*filer_pb.Entry, error) { return nil, filer_pb.ErrNotFound }
t.Cleanup(func() { volumeVerifyNow, volumeVerifyLookup = saved, savedLookup })
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
s3a.bucketRegistry = NewBucketRegistry(s3a)
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{Name: verifyTestBucket, IsTableBucket: true})
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{Name: "plain", IsTableBucket: false})
return s3a
}
func TestTableBucketAcceptsExternalVolumeVerifyKey(t *testing.T) {
s3a := newVerifyTestServer(t)
for _, key := range []string{
"verify_1790000000000_1081846483",
"/verify_1790000000000_1",
"verify_1790003300000_42", // 55 minutes later: inside the window
"verify_1789996700000_42", // 55 minutes earlier
// reads and deletes take a key of any date (PUT limits it)
"verify_1789992000000_1",
"verify_1790008000000_1",
} {
isVerify, err := s3a.validateTableBucketObjectPathOrVerify(verifyTestBucket, key)
assert.True(t, isVerify, key)
assert.NoError(t, err, key)
// the handlers that keep the plain rule still refuse it
assert.Error(t, s3a.validateTableBucketObjectPath(verifyTestBucket, key), key)
}
// the Iceberg layout is unchanged: the positive control
isVerify, err := s3a.validateTableBucketObjectPathOrVerify(verifyTestBucket, "ns/t/data/part-00000.parquet")
assert.False(t, isVerify)
assert.NoError(t, err)
}
func TestTableBucketRefusesVerifyKeyLookalikes(t *testing.T) {
s3a := newVerifyTestServer(t)
for _, key := range []string{
// the bucket root only
"base/verify_1790000000000_1",
"ns/verify_1790000000000_1",
".uploads/verify_1790000000000_1",
".uploads/abc/verify_1790000000000_1",
// the exact shape only
"verify_1790000000000_1.txt",
"verify_1790000000000",
"verify_179000000000_1", // 12-digit timestamp
"verify_1790000000000_12345678901", // 11-digit number
"verify_abc_123",
"verify_1790000000000_1/x",
"xverify_1790000000000_1",
// ordinary non-Iceberg keys
"notes.txt",
"ns/t/data/notes.txt",
} {
isVerify, err := s3a.validateTableBucketObjectPathOrVerify(verifyTestBucket, key)
assert.False(t, isVerify, key)
assert.Error(t, err, key)
}
// a bucket that is not a table bucket is unaffected
isVerify, err := s3a.validateTableBucketObjectPathOrVerify("plain", "verify_1790000000000_1")
assert.False(t, isVerify)
assert.NoError(t, err)
}
func verifyPutRequest(key string) *http.Request {
req := httptest.NewRequest(http.MethodPut, "/"+verifyTestBucket+"/"+key, strings.NewReader("x"))
return mux.SetURLVars(req, map[string]string{"bucket": verifyTestBucket, "object": key})
}
// A PUT to the verification key cannot store data: a small declared body and
// nothing stored beside it.
func TestTableBucketVerifyKeyPutLimits(t *testing.T) {
s3a := newVerifyTestServer(t)
const key = "verify_1790000000000_1081846483"
for name, mutate := range map[string]func(*http.Request){
"too large": func(r *http.Request) { r.ContentLength = volumeVerifyMaxBytes + 1 },
"content-language": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("Content-Language", "fr") },
"content-encoding": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("Content-Encoding", "gzip") },
"response-content-disposition": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("Response-Content-Disposition", "attachment") },
"extract query": func(r *http.Request) { r.ContentLength = 0; r.URL.RawQuery = "extract=zip" },
"extract header": func(r *http.Request) { r.ContentLength = 0; r.Header.Set(s3_constants.SeaweedFSExtract, "zip") },
"undeclared length": func(r *http.Request) { r.ContentLength = -1 },
"chunked": func(r *http.Request) { r.ContentLength = 0; r.TransferEncoding = []string{"chunked"} },
"user metadata": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("X-Amz-Meta-Note", "x") },
"tagging": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("X-Amz-Tagging", "a=b") },
"content-disposition": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("Content-Disposition", "attachment") },
"website redirect": func(r *http.Request) { r.ContentLength = 0; r.Header.Set("X-Amz-Website-Redirect-Location", "/x") },
} {
req := verifyPutRequest(key)
mutate(req)
assert.False(t, volumeVerifyPutAllowed(req, key), name)
rec := httptest.NewRecorder()
s3a.PutObjectHandler(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code, name)
}
ok := verifyPutRequest(key)
ok.ContentLength = 21
ok.Header.Set("Content-Type", "application/octet-stream")
ok.Header.Set("Content-Encoding", "aws-chunked")
assert.True(t, volumeVerifyPutAllowed(ok, key), "the verification object itself")
// a PUT is dated within the window of the server's clock
for _, old := range []string{"verify_1789992000000_1", "verify_1790008000000_1"} {
req := verifyPutRequest(old)
req.ContentLength = 21
assert.False(t, volumeVerifyPutAllowed(req, old), old)
rec := httptest.NewRecorder()
s3a.PutObjectHandler(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code, old)
}
}
// A namespace may carry a name of the key's shape; the key then names its
// directory and gets the plain layout rule, as does a key whose entry cannot
// be read.
func TestTableBucketVerifyKeyNamingDirectoryIsRefused(t *testing.T) {
s3a := newVerifyTestServer(t)
const key = "verify_1790000000000_7"
for name, lookup := range map[string]func(*S3ApiServer, string, string) (*filer_pb.Entry, error){
"namespace directory": func(*S3ApiServer, string, string) (*filer_pb.Entry, error) {
return &filer_pb.Entry{Name: key, IsDirectory: true}, nil
},
"lookup failure": func(*S3ApiServer, string, string) (*filer_pb.Entry, error) {
return nil, errors.New("filer unavailable")
},
} {
volumeVerifyLookup = lookup
isVerify, err := s3a.validateTableBucketObjectPathOrVerify(verifyTestBucket, key)
assert.False(t, isVerify, name)
assert.Error(t, err, name)
}
// the control: an existing verification object is a file
volumeVerifyLookup = func(*S3ApiServer, string, string) (*filer_pb.Entry, error) {
return &filer_pb.Entry{Name: key}, nil
}
isVerify, err := s3a.validateTableBucketObjectPathOrVerify(verifyTestBucket, key)
assert.True(t, isVerify)
assert.NoError(t, err)
}
+59 -17
View File
@@ -54,6 +54,22 @@ func (vs *VolumeServer) lookupRaftLeaderMaster(ctx context.Context) (pb.ServerAd
return leader, nil
}
// refreshLeaderFromPeers publishes the leader the configured masters report,
// so lookups stop going to a master the heartbeat just lost while the loop
// retries. Without an answer within timeout no leader is published: the lost
// master is dead or no longer leader, and a follower answers lookups from its
// own cache rather than refusing them, so failing fast is the safer answer.
func (vs *VolumeServer) refreshLeaderFromPeers(timeout time.Duration) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
leader, err := vs.lookupRaftLeaderMaster(ctx)
if err != nil {
vs.setCurrentMaster("")
return
}
vs.setCurrentMaster(leader)
}
func isContextDoneErr(err error) bool {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
@@ -74,7 +90,10 @@ func (vs *VolumeServer) getCurrentMaster() pb.ServerAddress {
}
// setCurrentMaster updates vs.currentMaster under a write lock. The
// heartbeat goroutine calls this whenever it (re)connects to a master.
// heartbeat goroutine calls this only with a master known to be the leader:
// one that answered a heartbeat as leader, or the one a follower named, on
// the heartbeat stream or, after the stream failed, in its configuration.
// After a failed stream with no leader named, it is cleared to "".
func (vs *VolumeServer) setCurrentMaster(master pb.ServerAddress) {
vs.currentMasterLock.Lock()
vs.currentMaster = master
@@ -122,7 +141,6 @@ func (vs *VolumeServer) heartbeat() {
time.Sleep(3 * time.Second)
master = newLeader
}
vs.store.MasterAddress = master
newLeader, err = vs.doHeartbeatWithRetry(master, grpcDialOption, vs.pulsePeriod, duplicateRetryCount)
if err != nil {
glog.V(0).Infof("heartbeat to %s error: %v", master, err)
@@ -136,12 +154,15 @@ func (vs *VolumeServer) heartbeat() {
} else {
// Regular error, reset duplicate retry count
duplicateRetryCount = 0
time.Sleep(vs.pulsePeriod)
// The lookup spends part of the retry delay rather than
// adding to it.
retryAt := time.Now().Add(vs.pulsePeriod)
vs.refreshLeaderFromPeers(vs.pulsePeriod)
time.Sleep(time.Until(retryAt))
}
stats.VolumeServerMasterDisconnections.WithLabelValues(master.String()).Inc()
newLeader = ""
vs.store.MasterAddress = ""
} else {
// Successful connection, reset retry count
duplicateRetryCount = 0
@@ -180,17 +201,42 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
return "", err
}
glog.V(0).Infof("Heartbeat to: %v", masterAddress)
vs.setCurrentMaster(masterAddress)
doneChan := make(chan error, 1)
// The receiver asks for a fresh heartbeat here rather than sending it,
// since a gRPC stream allows only one sender at a time.
resendChan := make(chan struct{}, 1)
go func() {
confirmedLeader := false
for {
in, err := stream.Recv()
if err != nil {
doneChan <- err
return
}
// A reply names the leader or comes from it, so it settles where
// lookups go before anything else on the reply is handled: a
// follower reply carries no other fields, and a leader stays the
// lookup target even when it rejects this registration.
if in.GetLeader() != "" {
// Only a follower names the leader. Publish the named leader right
// away, so lookups stop landing on this follower while the loop
// reconnects.
leader := pb.ServerAddress(in.GetLeader())
if !masterAddress.Equals(leader) {
glog.V(0).Infof("Volume Server found a new master newLeader: %v instead of %v", leader, masterAddress)
vs.setCurrentMaster(leader)
newLeader = leader
doneChan <- nil
return
}
} else if !confirmedLeader {
// The leader answers every new heartbeat stream without naming a
// leader, so this peer is the one lookups should go to.
confirmedLeader = true
vs.setCurrentMaster(masterAddress)
}
if len(in.DuplicatedUuids) > 0 {
var duplicateDir []string
for _, loc := range vs.store.Locations {
@@ -231,9 +277,9 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
}
if volumeOptsChanged {
if vs.store.MaybeAdjustVolumeMax() {
if err = stream.Send(vs.store.CollectHeartbeat()); err != nil {
glog.V(0).Infof("Volume Server Failed to talk with master %s: %v", vs.getCurrentMaster(), err)
return
select {
case resendChan <- struct{}{}:
default:
}
}
}
@@ -244,15 +290,6 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
glog.V(0).Infof("master %s asked for the full volume list", masterAddress)
vs.store.RequestFullVolumeList()
}
if in.GetLeader() != "" {
current := vs.getCurrentMaster()
if !current.Equals(pb.ServerAddress(in.GetLeader())) {
glog.V(0).Infof("Volume Server found a new master newLeader: %v instead of %v", in.GetLeader(), current)
newLeader = pb.ServerAddress(in.GetLeader())
doneChan <- nil
return
}
}
}
}()
@@ -359,6 +396,11 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
glog.V(0).Infof("Volume Server Failed to update to master %s: %v", masterAddress, err)
return "", err
}
case <-resendChan:
if err = stream.Send(vs.store.CollectHeartbeat()); err != nil {
glog.V(0).Infof("Volume Server Failed to talk with master %s: %v", masterAddress, err)
return "", err
}
case <-volumeTickChan.C:
glog.V(4).Infof("volume server %s:%d heartbeat", vs.store.Ip, vs.store.Port)
vs.store.MaybeAdjustVolumeMax()
@@ -4,11 +4,17 @@ import (
"context"
"fmt"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/storage"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
@@ -60,3 +66,279 @@ func startFakeMasterServerForLeaderLookup(t *testing.T, srv master_pb.SeaweedSer
_, port, _ := net.SplitHostPort(lis.Addr().String())
return pb.ServerAddress(fmt.Sprintf("127.0.0.1:0.%s", port))
}
// fakeHeartbeatMaster answers SendHeartbeat the way a master does: a follower
// names the leader on every heartbeat, the leader answers the first one with
// its volume options and never names itself.
type fakeHeartbeatMaster struct {
master_pb.UnimplementedSeaweedServer
leader string // set on a follower
release chan struct{} // a follower waits on it before answering
heartbeats chan *master_pb.Heartbeat
}
func (m *fakeHeartbeatMaster) SendHeartbeat(stream master_pb.Seaweed_SendHeartbeatServer) error {
answered := false
for {
hb, err := stream.Recv()
if err != nil {
return err
}
if m.heartbeats != nil {
m.heartbeats <- hb
}
if m.leader != "" {
if m.release != nil {
<-m.release
}
if err := stream.Send(&master_pb.HeartbeatResponse{Leader: m.leader}); err != nil {
return err
}
continue
}
if !answered {
answered = true
if err := stream.Send(&master_pb.HeartbeatResponse{VolumeSizeLimit: 1 << 30}); err != nil {
return err
}
}
}
}
func newHeartbeatTestVolumeServer(t *testing.T) *VolumeServer {
t.Helper()
store := storage.NewStore(nil, "127.0.0.1", 8080, 18080, "http://127.0.0.1:8080", "store-id",
[]string{t.TempDir()},
[]int32{0},
[]util.MinFreeSpace{{}},
"",
storage.NeedleMapInMemory,
[]types.DiskType{types.HardDriveType},
nil,
3,
stats.DefaultDiskIOProbeConfig(),
)
t.Cleanup(store.Close)
vs := &VolumeServer{
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
store: store,
stopChan: make(chan bool),
isHeartbeating: true,
}
store.MasterFn = vs.getCurrentMaster
return vs
}
func waitForCurrentMaster(t *testing.T, vs *VolumeServer, want pb.ServerAddress) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !vs.getCurrentMaster().Equals(want) {
if time.Now().After(deadline) {
t.Fatalf("currentMaster = %q, want %q", vs.getCurrentMaster(), want)
}
time.Sleep(10 * time.Millisecond)
}
}
// heartbeatRun is one doHeartbeatWithRetry call running in the background.
type heartbeatRun struct {
done chan heartbeatResult // receives the call's result once
exited chan struct{}
stop func()
}
type heartbeatResult struct {
newLeader pb.ServerAddress
err error
}
// startHeartbeat runs doHeartbeatWithRetry against master until it returns or
// is stopped. Its cleanup stops the heartbeat and waits for it, and runs before
// the cleanups registered earlier, so a failed assertion cannot leave the stream
// open for the fake master's GracefulStop to wait on.
func startHeartbeat(t *testing.T, vs *VolumeServer, master pb.ServerAddress) *heartbeatRun {
t.Helper()
var stopOnce sync.Once
run := &heartbeatRun{
done: make(chan heartbeatResult, 1),
exited: make(chan struct{}),
stop: func() { stopOnce.Do(func() { close(vs.stopChan) }) },
}
go func() {
defer close(run.exited)
newLeader, err := vs.doHeartbeatWithRetry(master, vs.grpcDialOption, time.Hour, 0)
run.done <- heartbeatResult{newLeader, err}
}()
t.Cleanup(func() {
run.stop()
select {
case <-run.exited:
case <-time.After(5 * time.Second):
t.Error("heartbeat did not stop")
}
})
return run
}
// A follower must never become the master lookups go to: until it names the
// leader the previous one stays, and then the named leader takes over before
// the heartbeat loop has reconnected.
func TestHeartbeatToFollowerPublishesNamedLeaderOnly(t *testing.T) {
const leader = pb.ServerAddress("leader.example.com:9333.19333")
follower := &fakeHeartbeatMaster{
leader: string(leader),
release: make(chan struct{}),
heartbeats: make(chan *master_pb.Heartbeat, 8),
}
followerAddr := startFakeMasterServerForLeaderLookup(t, follower)
// cleanups run last-in first-out, so a failed assertion still lets the
// follower answer before its server stops gracefully
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(follower.release) }) }
t.Cleanup(release)
vs := newHeartbeatTestVolumeServer(t)
previous := pb.ServerAddress("previous.example.com:9333.19333")
vs.setCurrentMaster(previous)
run := startHeartbeat(t, vs, followerAddr)
select {
case <-follower.heartbeats:
case <-time.After(5 * time.Second):
t.Fatal("follower got no heartbeat")
}
if got := vs.getCurrentMaster(); got != previous {
t.Fatalf("currentMaster = %q while connected to follower %q, want unchanged %q", string(got), string(followerAddr), string(previous))
}
if got := vs.store.MasterFn(); got != previous {
t.Fatalf("store master = %q, want %q", got, previous)
}
release()
select {
case r := <-run.done:
if r.err != nil {
t.Fatalf("heartbeat: %v", r.err)
}
if r.newLeader != leader {
t.Fatalf("newLeader = %q, want %q", r.newLeader, leader)
}
case <-time.After(5 * time.Second):
t.Fatal("heartbeat did not return after the follower named the leader")
}
if got := vs.getCurrentMaster(); got != leader {
t.Fatalf("currentMaster = %q, want named leader %q", got, leader)
}
}
// The leader's first answer confirms it, and the size limit that answer carries
// makes the receiver ask for a fresh heartbeat, which the send loop delivers.
func TestHeartbeatToLeaderPublishesItAndResendsFromSendLoop(t *testing.T) {
leader := &fakeHeartbeatMaster{heartbeats: make(chan *master_pb.Heartbeat, 8)}
leaderAddr := startFakeMasterServerForLeaderLookup(t, leader)
vs := newHeartbeatTestVolumeServer(t)
run := startHeartbeat(t, vs, leaderAddr)
waitForCurrentMaster(t, vs, leaderAddr)
if got := vs.store.MasterFn(); got != leaderAddr {
t.Fatalf("store master = %q, want %q", got, leaderAddr)
}
// the initial volume and ec heartbeats, then the resend
for i := 0; i < 3; i++ {
select {
case <-leader.heartbeats:
case <-time.After(5 * time.Second):
t.Fatalf("leader got %d heartbeats, want 3", i)
}
}
run.stop()
select {
case r := <-run.done:
if r.err != nil {
t.Fatalf("heartbeat: %v", r.err)
}
case <-time.After(5 * time.Second):
t.Fatal("heartbeat did not stop")
}
}
// duplicateUUIDMaster answers every heartbeat the way a leader holding a stale
// registration does: a duplicate-uuid response and nothing else.
type duplicateUUIDMaster struct {
master_pb.UnimplementedSeaweedServer
}
func (m *duplicateUUIDMaster) SendHeartbeat(stream master_pb.Seaweed_SendHeartbeatServer) error {
for {
if _, err := stream.Recv(); err != nil {
return err
}
if err := stream.Send(&master_pb.HeartbeatResponse{DuplicatedUuids: []string{"uuid-1"}}); err != nil {
return err
}
}
}
// A leader that rejects the registration over duplicate uuids still answers
// lookups, so it is published before the retry error goes out.
func TestHeartbeatPublishesLeaderBeforeDuplicateUuidError(t *testing.T) {
masterAddr := startFakeMasterServerForLeaderLookup(t, &duplicateUUIDMaster{})
vs := newHeartbeatTestVolumeServer(t)
vs.setCurrentMaster("previous.example.com:9333.19333")
_, err := vs.doHeartbeatWithRetry(masterAddr, vs.grpcDialOption, time.Hour, 0)
if err == nil || !strings.Contains(err.Error(), "duplicate UUIDs") {
t.Fatalf("err = %v, want duplicate UUIDs", err)
}
if got := vs.getCurrentMaster(); got != masterAddr {
t.Fatalf("currentMaster = %q, want the answering leader %q", got, masterAddr)
}
}
// After the heartbeat loses its master, lookups move to the leader the other
// masters report instead of dialing the lost one until a new stream answers.
func TestRefreshLeaderFromPeersPublishesReportedLeader(t *testing.T) {
const leader = pb.ServerAddress("leader.example.com:9333.19333")
peer := startFakeMasterServerForLeaderLookup(t, &fakeLeaderConfigServer{leader: string(leader)})
vs := &VolumeServer{
SeedMasterNodes: []pb.ServerAddress{peer},
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
store: &storage.Store{Ip: "127.0.0.1", Port: 8080},
}
vs.store.MasterFn = vs.getCurrentMaster
vs.setCurrentMaster("lost.example.com:9333.19333")
vs.refreshLeaderFromPeers(5 * time.Second)
if got := vs.getCurrentMaster(); got != leader {
t.Fatalf("currentMaster = %q, want reported leader %q", string(got), string(leader))
}
if got := vs.store.MasterFn(); got != leader {
t.Fatalf("store master = %q, want %q", string(got), string(leader))
}
}
// Mid-election no master names a leader. The lost master is dead or now a
// follower that would answer lookups from its own cache, so none is published.
func TestRefreshLeaderFromPeersClearsLeaderWithoutAnswer(t *testing.T) {
peer := startFakeMasterServerForLeaderLookup(t, &fakeLeaderConfigServer{})
vs := &VolumeServer{
SeedMasterNodes: []pb.ServerAddress{peer},
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
store: &storage.Store{Ip: "127.0.0.1", Port: 8080},
}
vs.store.MasterFn = vs.getCurrentMaster
vs.setCurrentMaster("lost.example.com:9333.19333")
vs.refreshLeaderFromPeers(5 * time.Second)
if got := vs.getCurrentMaster(); got != "" {
t.Fatalf("currentMaster = %q, want none", string(got))
}
if got := vs.store.MasterFn(); got != "" {
t.Fatalf("store master = %q, want none", string(got))
}
}
+2
View File
@@ -110,6 +110,8 @@ func (vs *VolumeServer) VolumeTierMoveDatToRemote(req *volume_server_pb.VolumeTi
if err := v.LoadRemoteFile(); err != nil {
return fmt.Errorf("volume %d failed to load remote file: %v", v.Id, err)
}
// Deletes must reach the index now, not only after the next restart.
v.MarkTieredDeletable()
if !req.KeepLocalDatFile {
os.Remove(v.FileName(".dat"))
@@ -0,0 +1,77 @@
package weed_server
import (
"bytes"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/storage"
"github.com/seaweedfs/seaweedfs/weed/storage/backend"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// tierUploadForDeletes writes 10 needles into volume 1 of a new store, marks it
// read-only as volume.tier.upload does, and moves its .dat to the tier.
func tierUploadForDeletes(t *testing.T, replication string) *storage.Store {
t.Helper()
backend.BackendStorages[tierTimestampTestBackendName] = &tierTimestampTestBackend{root: t.TempDir()}
t.Cleanup(func() { delete(backend.BackendStorages, tierTimestampTestBackendName) })
store := storage.NewStore(nil, "localhost", 8080, 18080, "http://localhost:8080", "store-id",
[]string{t.TempDir()}, []int32{10}, []util.MinFreeSpace{{}}, "", storage.NeedleMapInMemory,
[]types.DiskType{types.HardDriveType}, nil, 0, stats.DefaultDiskIOProbeConfig())
t.Cleanup(store.Close)
if err := store.AddVolume(1, "", storage.NeedleMapInMemory, replication, "", 0, needle.Version3, 0, types.HardDriveType, 0); err != nil {
t.Fatalf("add volume: %v", err)
}
for i := 1; i <= 10; i++ {
n := &needle.Needle{Id: types.Uint64ToNeedleId(uint64(i)), Cookie: 7, Data: bytes.Repeat([]byte{byte(i)}, 1024)}
n.Checksum = needle.NewCRC(n.Data)
if _, err := store.WriteVolumeNeedle(1, n, false, false); err != nil {
t.Fatalf("write %d: %v", i, err)
}
}
if err := store.MarkVolumeReadonly(1, false, false); err != nil {
t.Fatalf("mark read-only: %v", err)
}
vs := &VolumeServer{store: store}
if err := vs.VolumeTierMoveDatToRemote(&volume_server_pb.VolumeTierMoveDatToRemoteRequest{
VolumeId: 1, DestinationBackendName: tierTimestampTestBackendName,
}, &discardServerStream[volume_server_pb.VolumeTierMoveDatToRemoteResponse]{}); err != nil {
t.Fatalf("tier upload: %v", err)
}
return store
}
// A single-copy volume takes deletes as soon as it is tiered, as it would
// after a restart. Before, they were refused ("volume 1 is read only") until
// then, leaving the needles live in the index.
func TestTierUploadAcceptsDeletesOnASingleCopyVolume(t *testing.T) {
store := tierUploadForDeletes(t, "000")
if _, err := store.DeleteVolumeNeedle(1, &needle.Needle{Id: 2}); err != nil {
t.Fatalf("delete on the freshly tiered volume: %v", err)
}
if _, err := store.ReadVolumeNeedle(1, &needle.Needle{Id: 2}, nil, nil); err == nil {
t.Fatalf("the deleted needle still reads")
}
if _, err := store.ReadVolumeNeedle(1, &needle.Needle{Id: 3}, nil, nil); err != nil {
t.Fatalf("a live needle does not read: %v", err)
}
n := &needle.Needle{Id: 99, Cookie: 7, Data: []byte("x")}
n.Checksum = needle.NewCRC(n.Data)
if _, err := store.WriteVolumeNeedle(1, n, false, false); err == nil {
t.Fatalf("a write to a tiered volume succeeded")
}
}
// A replicated volume keeps refusing deletes: the shell copies the uploaded
// replica's .idx and .vif to the others after the upload, and a tombstone
// written before that copy would reach one replica only.
func TestTierUploadKeepsRefusingDeletesOnAReplicatedVolume(t *testing.T) {
store := tierUploadForDeletes(t, "001")
if _, err := store.DeleteVolumeNeedle(1, &needle.Needle{Id: 2}); err == nil {
t.Fatalf("a replicated volume accepted a delete right after the tier upload")
}
}
+1
View File
@@ -148,6 +148,7 @@ func NewVolumeServer(adminMux, publicMux *http.ServeMux, ip string,
vs.checkWithMaster()
vs.store = storage.NewStore(vs.grpcDialOption, ip, port, grpcPort, publicUrl, id, folders, maxCounts, minFreeSpaces, idxFolder, vs.needleMapKind, diskTypes, diskTags, ldbTimeout, diskProbeConfig)
vs.store.MasterFn = vs.getCurrentMaster
vs.guard = security.NewGuard(whiteList, signingKey, expiresAfterSec, readSigningKey, readExpiresAfterSec)
handleStaticResources(adminMux)
+47 -1
View File
@@ -3,6 +3,7 @@ package s3_backend
import (
"fmt"
"io"
"math"
"os"
"strconv"
"strings"
@@ -44,6 +45,7 @@ type S3BackendStorage struct {
forcePathStyle bool
uploadConcurrency int
downloadConcurrency int
httpOptions httpOptions
conn s3iface.S3API
}
@@ -59,11 +61,16 @@ func newS3BackendStorage(configuration backend.StringProperties, configPrefix st
s.forcePathStyle = util.ParseBool(configuration.GetString(configPrefix+"force_path_style"), true)
s.uploadConcurrency = parseConcurrency(configuration.GetString(configPrefix+"upload_concurrency"), defaultUploadConcurrency)
s.downloadConcurrency = parseConcurrency(configuration.GetString(configPrefix+"download_concurrency"), defaultDownloadConcurrency)
s.httpOptions = httpOptions{
disableHTTP2: util.ParseBool(configuration.GetString(configPrefix+"disable_http2"), false),
maxConnsPerHost: parseNonNegative(configuration.GetString(configPrefix+"max_connections"), "max_connections"),
idleConnTimeout: parseIdleConnTimeout(configuration.GetString(configPrefix + "idle_connection_timeout_seconds")),
}
if s.storageClass == "" {
s.storageClass = "STANDARD_IA"
}
s.conn, err = createSession(s.aws_access_key_id, s.aws_secret_access_key, s.region, s.endpoint, s.forcePathStyle)
s.conn, err = createSession(s.aws_access_key_id, s.aws_secret_access_key, s.region, s.endpoint, s.forcePathStyle, s.httpOptions)
glog.V(0).Infof("created backend storage s3.%s for region %s bucket %s", s.id, s.region, s.bucket)
return
@@ -80,6 +87,9 @@ func (s *S3BackendStorage) ToProperties() map[string]string {
m["force_path_style"] = util.BoolToString(s.forcePathStyle)
m["upload_concurrency"] = strconv.Itoa(s.uploadConcurrency)
m["download_concurrency"] = strconv.Itoa(s.downloadConcurrency)
m["disable_http2"] = util.BoolToString(s.httpOptions.disableHTTP2)
m["max_connections"] = strconv.Itoa(s.httpOptions.maxConnsPerHost)
m["idle_connection_timeout_seconds"] = strconv.FormatInt(int64(s.httpOptions.idleConnTimeout/time.Second), 10)
return m
}
@@ -99,6 +109,42 @@ func parseConcurrency(value string, def int) int {
return def
}
// parseNonNegative reads an optional non-negative integer setting; empty or
// invalid means 0 (the default).
func parseNonNegative(value, name string) int {
if value == "" {
return 0
}
if n, err := strconv.Atoi(value); err == nil && n >= 0 {
return n
}
glog.Warningf("invalid %s value %q, using the default", name, value)
return 0
}
// maxIdleConnTimeoutSeconds is the largest number of seconds a time.Duration
// can hold.
const maxIdleConnTimeoutSeconds = math.MaxInt64 / int64(time.Second)
// parseIdleConnTimeout reads idle_connection_timeout_seconds; empty, invalid
// or too large to represent as a time.Duration means 0 (the default). It
// parses 64-bit seconds so the range is the same on 32-bit platforms.
func parseIdleConnTimeout(value string) time.Duration {
if value == "" {
return 0
}
seconds, err := strconv.ParseInt(value, 10, 64)
if err != nil || seconds < 0 {
glog.Warningf("invalid idle_connection_timeout_seconds value %q, using the default", value)
return 0
}
if seconds > maxIdleConnTimeoutSeconds {
glog.Warningf("idle_connection_timeout_seconds %q is out of range, using the default", value)
return 0
}
return time.Duration(seconds) * time.Second
}
func (s *S3BackendStorage) NewStorageFile(key string, tierInfo *volume_server_pb.VolumeInfo) backend.BackendStorageFile {
if strings.HasPrefix(key, "/") {
key = key[1:]
+44 -1
View File
@@ -1,8 +1,11 @@
package s3_backend
import (
"crypto/tls"
"fmt"
"net/http"
"sync"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
@@ -18,12 +21,49 @@ var (
sessionsLock sync.Mutex
)
func createSession(awsAccessKeyId, awsSecretAccessKey, region, endpoint string, forcePathStyle bool) (s3iface.S3API, error) {
// httpOptions tunes the HTTP transport of the tier backend's S3 client. The
// zero value keeps the SDK's default client.
type httpOptions struct {
// disableHTTP2 keeps the client on HTTP/1.1. Over HTTP/2 every upload and
// range read to the endpoint shares one connection and its flow-control
// window, which some S3-compatible services keep small.
disableHTTP2 bool
// maxConnsPerHost caps the connections to the endpoint (0 = no cap), for
// services that limit parallel connections per client address.
maxConnsPerHost int
// idleConnTimeout closes idle connections sooner than Go's default 90s,
// for services that drop idle connections earlier (0 = default).
idleConnTimeout time.Duration
}
// newTransport returns the transport for the options, or nil for the default.
func (o httpOptions) newTransport() *http.Transport {
if o == (httpOptions{}) {
return nil
}
t := http.DefaultTransport.(*http.Transport).Clone()
if o.disableHTTP2 {
t.ForceAttemptHTTP2 = false
t.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
}
if o.maxConnsPerHost > 0 {
t.MaxConnsPerHost = o.maxConnsPerHost
}
if o.idleConnTimeout > 0 {
t.IdleConnTimeout = o.idleConnTimeout
}
return t
}
func createSession(awsAccessKeyId, awsSecretAccessKey, region, endpoint string, forcePathStyle bool, httpOpts httpOptions) (s3iface.S3API, error) {
sessionsLock.Lock()
defer sessionsLock.Unlock()
cacheKey := fmt.Sprintf("%s|%s", region, endpoint)
if httpOpts != (httpOptions{}) {
cacheKey = fmt.Sprintf("%s|%+v", cacheKey, httpOpts)
}
if t, found := s3Sessions[cacheKey]; found {
return t, nil
}
@@ -34,6 +74,9 @@ func createSession(awsAccessKeyId, awsSecretAccessKey, region, endpoint string,
S3ForcePathStyle: aws.Bool(forcePathStyle),
S3DisableContentMD5Validation: aws.Bool(true),
}
if t := httpOpts.newTransport(); t != nil {
config.HTTPClient = &http.Client{Transport: t}
}
if awsAccessKeyId != "" && awsSecretAccessKey != "" {
config.Credentials = credentials.NewStaticCredentials(awsAccessKeyId, awsSecretAccessKey, "")
}
@@ -0,0 +1,129 @@
package s3_backend
import (
"math"
"net/http"
"strconv"
"testing"
"time"
"github.com/aws/aws-sdk-go/service/s3"
)
func backendTransport(t *testing.T, s *S3BackendStorage) *http.Transport {
t.Helper()
client, ok := s.conn.(*s3.S3)
if !ok {
t.Fatalf("unexpected client type %T", s.conn)
}
httpClient := client.Client.Config.HTTPClient
if httpClient == nil {
return nil
}
transport, _ := httpClient.Transport.(*http.Transport)
return transport
}
func TestS3BackendHTTPOptionsReachTheClient(t *testing.T) {
s, err := newS3BackendStorage(testProperties{
"endpoint": "http://127.0.0.1:1",
"disable_http2": "true",
"max_connections": "64",
"idle_connection_timeout_seconds": "50",
}, "", "http-options")
if err != nil {
t.Fatal(err)
}
transport := backendTransport(t, s)
if transport == nil {
t.Fatal("configured HTTP options did not reach the S3 client")
}
if transport.ForceAttemptHTTP2 || transport.TLSNextProto == nil || len(transport.TLSNextProto) != 0 {
t.Errorf("HTTP/2 not disabled: ForceAttemptHTTP2=%v TLSNextProto=%v", transport.ForceAttemptHTTP2, transport.TLSNextProto)
}
if transport.MaxConnsPerHost != 64 {
t.Errorf("MaxConnsPerHost = %d, want 64", transport.MaxConnsPerHost)
}
if transport.IdleConnTimeout != 50*time.Second {
t.Errorf("IdleConnTimeout = %v, want 50s", transport.IdleConnTimeout)
}
props := s.ToProperties()
if props["disable_http2"] != "true" || props["max_connections"] != "64" || props["idle_connection_timeout_seconds"] != "50" {
t.Errorf("ToProperties: %v", props)
}
}
func TestS3BackendDefaultHTTPClientUnchanged(t *testing.T) {
s, err := newS3BackendStorage(testProperties{"endpoint": "http://127.0.0.1:2"}, "", "http-defaults")
if err != nil {
t.Fatal(err)
}
if transport := backendTransport(t, s); transport != nil && transport != http.DefaultTransport {
t.Fatalf("no options set, yet the client got a custom transport %+v", transport)
}
if (httpOptions{}).newTransport() != nil {
t.Fatal("zero options must keep the default client")
}
}
func TestS3BackendSessionsDifferByHTTPOptions(t *testing.T) {
a, err := createSession("", "", "us-east-1", "http://127.0.0.1:3", true, httpOptions{})
if err != nil {
t.Fatal(err)
}
b, err := createSession("", "", "us-east-1", "http://127.0.0.1:3", true, httpOptions{disableHTTP2: true})
if err != nil {
t.Fatal(err)
}
if a == b {
t.Fatal("backends with different HTTP options share one client")
}
c, err := createSession("", "", "us-east-1", "http://127.0.0.1:3", true, httpOptions{disableHTTP2: true})
if err != nil {
t.Fatal(err)
}
if b != c {
t.Fatal("the same endpoint and options must reuse the cached client")
}
}
func TestParseNonNegative(t *testing.T) {
for _, c := range []struct {
in string
want int
}{{"", 0}, {"0", 0}, {"12", 12}, {"-1", 0}, {"x", 0}} {
if got := parseNonNegative(c.in, "test"); got != c.want {
t.Errorf("parseNonNegative(%q) = %d, want %d", c.in, got, c.want)
}
}
}
func TestS3BackendIdleTimeoutOverflowFallsBackToDefault(t *testing.T) {
maxSeconds := int64(math.MaxInt64 / int64(time.Second))
for i, c := range []struct {
in string
want time.Duration
}{
{strconv.FormatInt(maxSeconds, 10), time.Duration(maxSeconds) * time.Second},
{strconv.FormatInt(maxSeconds+1, 10), 0},
{"10000000000", 0},
{"-1", 0},
{"x", 0},
} {
s, err := newS3BackendStorage(testProperties{
"endpoint": "http://127.0.0.1:4",
"idle_connection_timeout_seconds": c.in,
}, "", "idle-overflow-"+strconv.Itoa(i))
if err != nil {
t.Fatal(err)
}
if s.httpOptions.idleConnTimeout != c.want {
t.Errorf("idle_connection_timeout_seconds=%s: timeout %v, want %v", c.in, s.httpOptions.idleConnTimeout, c.want)
}
want := strconv.FormatInt(int64(c.want/time.Second), 10)
if got := s.ToProperties()["idle_connection_timeout_seconds"]; got != want {
t.Errorf("idle_connection_timeout_seconds=%s: ToProperties exports %s, want %s", c.in, got, want)
}
}
}
+24 -10
View File
@@ -61,14 +61,25 @@ type ReadOption struct {
ReadBufferSize int
}
// masterAddress returns the master to ask for volume and shard locations, or
// "" while no leader is known.
func (s *Store) masterAddress() pb.ServerAddress {
if s.MasterFn == nil {
return ""
}
return s.MasterFn()
}
/*
* A VolumeServer contains one Store
*/
type Store struct {
MasterAddress pb.ServerAddress
// MasterFn reports the master the volume server currently takes as leader.
// The heartbeat loop owns that answer; the store only reads it.
MasterFn func() pb.ServerAddress
grpcDialOption grpc.DialOption
volumeSizeLimit uint64 // read from the master
preallocate atomic.Bool // read from the master
volumeSizeLimit atomic.Uint64 // read from the master
preallocate atomic.Bool // read from the master
Ip string
Port int
GrpcPort int
@@ -869,7 +880,10 @@ func (s *Store) WriteVolumeNeedle(i needle.VolumeId, n *needle.Needle, checkCook
func (s *Store) DeleteVolumeNeedle(i needle.VolumeId, n *needle.Needle) (Size, error) {
if v := s.findVolume(i); v != nil {
if v.noWriteOrDelete {
v.noWriteLock.RLock()
noWriteOrDelete := v.noWriteOrDelete
v.noWriteLock.RUnlock()
if noWriteOrDelete {
return 0, fmt.Errorf("volume %d is read only", i)
}
return v.deleteNeedle2(n)
@@ -975,10 +989,10 @@ func (s *Store) MarkVolumeWritable(i needle.VolumeId) error {
prevNoWriteOrDelete := v.noWriteOrDelete
prevNoWriteCanDelete := v.noWriteCanDelete
v.noWriteOrDelete = false
// Remote-tiered volumes must stay noWriteCanDelete regardless of marks.
if !v.HasRemoteFile() {
v.noWriteCanDelete = false
}
// A remote-tiered volume keeps the delete-only mode load() derives: its
// .dat cannot take appends, so "writable" resolves to can-delete rather
// than advertising writable with no local data file.
v.noWriteCanDelete = v.HasRemoteFile()
persistErr := v.PersistReadOnly(false, false)
if persistErr != nil {
var ndErr *volume_info.NotCrashDurableError
@@ -1302,11 +1316,11 @@ func (s *Store) ConfigureVolume(i needle.VolumeId, replication string) error {
}
func (s *Store) SetVolumeSizeLimit(x uint64) {
atomic.StoreUint64(&s.volumeSizeLimit, x)
s.volumeSizeLimit.Store(x)
}
func (s *Store) GetVolumeSizeLimit() uint64 {
return atomic.LoadUint64(&s.volumeSizeLimit)
return s.volumeSizeLimit.Load()
}
func (s *Store) SetPreallocate(x bool) {
+2 -2
View File
@@ -568,7 +568,7 @@ const ecIntervalReadConcurrency = 8
func (s *Store) readEcShardIntervals(needleId types.NeedleId, ecVolume *erasure_coding.EcVolume, intervals []erasure_coding.Interval) (data []byte, is_deleted bool, err error) {
if err = s.cachedLookupEcShardLocations(ecVolume); err != nil {
return nil, false, fmt.Errorf("failed to locate shard via master grpc %s: %v", s.MasterAddress, err)
return nil, false, fmt.Errorf("failed to locate shard via master grpc %s: %v", s.masterAddress(), err)
}
var totalSize int
@@ -721,7 +721,7 @@ func (s *Store) cachedLookupEcShardLocations(ecVolume *erasure_coding.EcVolume)
glog.V(3).Infof("lookup and cache ec volume %d locations", ecVolume.VolumeId)
err = operation.WithMasterServerClient(context.Background(), false, s.MasterAddress, s.grpcDialOption, func(masterClient master_pb.SeaweedClient) error {
err = operation.WithMasterServerClient(context.Background(), false, s.masterAddress(), s.grpcDialOption, func(masterClient master_pb.SeaweedClient) error {
req := &master_pb.LookupEcVolumeRequest{
VolumeId: uint32(ecVolume.VolumeId),
}
+4 -4
View File
@@ -78,7 +78,7 @@ func rewindShardLocationsRefresh(ecVolume *erasure_coding.EcVolume, d time.Durat
func TestCachedLookupEcShardLocationsRefreshesAfterAFailedRead(t *testing.T) {
master, masterAddr := startCountingMaster(t)
store := &Store{
MasterAddress: masterAddr,
MasterFn: func() pb.ServerAddress { return masterAddr },
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
}
ecVolume := &erasure_coding.EcVolume{
@@ -127,7 +127,7 @@ func TestCachedLookupEcShardLocationsRefreshesAfterAFailedRead(t *testing.T) {
func TestCachedLookupEcShardLocationsSettlesAfterRefresh(t *testing.T) {
master, masterAddr := startCountingMaster(t)
store := &Store{
MasterAddress: masterAddr,
MasterFn: func() pb.ServerAddress { return masterAddr },
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
}
ecVolume := &erasure_coding.EcVolume{
@@ -257,7 +257,7 @@ func TestReadOneEcShardIntervalMarksTheMapAfterADirectReadFails(t *testing.T) {
func TestCachedLookupEcShardLocationsKeepsAMarkRaisedDuringTheLookup(t *testing.T) {
master, masterAddr := startCountingMaster(t)
store := &Store{
MasterAddress: masterAddr,
MasterFn: func() pb.ServerAddress { return masterAddr },
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
}
ecVolume := &erasure_coding.EcVolume{
@@ -295,7 +295,7 @@ func TestCachedLookupEcShardLocationsKeepsAMarkRaisedDuringTheLookup(t *testing.
func TestCachedLookupEcShardLocationsKeepsTheMarkWhenTheLookupFails(t *testing.T) {
master, masterAddr := startCountingMaster(t)
store := &Store{
MasterAddress: masterAddr,
MasterFn: func() pb.ServerAddress { return masterAddr },
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
}
ecVolume := &erasure_coding.EcVolume{
+2 -2
View File
@@ -23,7 +23,7 @@ func (s *Store) ScrubEcVolume(vid needle.VolumeId, mode volume_server_pb.VolumeS
return 0, nil, []error{fmt.Errorf("EC volume id %d not found", vid)}
}
if err := s.cachedLookupEcShardLocations(ecv); err != nil {
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.MasterAddress, err)}
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.masterAddress(), err)}
}
return s.scrubEcVolumeWalk(ecv, mode, forceDeletedNeedlesCheck)
}
@@ -56,7 +56,7 @@ func (s *Store) ScrubEcVolumeMerged(merged *erasure_coding.MergedEcRuntimes, mod
}
}
if err := s.cachedLookupEcShardLocations(ecv); err != nil {
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.MasterAddress, err)}
return 0, nil, []error{fmt.Errorf("failed to locate shard via master grpc %s: %v", s.masterAddress(), err)}
}
files, shardInfos, errs := s.scrubEcVolumeWalk(ecv, mode, forceDeletedNeedlesCheck)
+9 -1
View File
@@ -238,7 +238,7 @@ func (v *Volume) load(alsoLoadIndex bool, createDatIfMissing bool, needleMapKind
glog.V(2).Infof("readSuperBlock volume %d version %v", v.Id, v.SuperBlock.Version)
if v.HasRemoteFile() {
// maybe temporary network problem
glog.Errorf("readSuperBlock remote volume %d: %v", v.Id, err)
logRemoteSuperBlockErr(v.Id, err)
err = nil
}
} else {
@@ -434,3 +434,11 @@ func datFileLoadError(fileName string, err error) error {
}
return fmt.Errorf("cannot load volume data %s: %v", fileName, err)
}
var logRemoteSuperBlockFault = glog.Errorf
func logRemoteSuperBlockErr(id needle.VolumeId, err error) {
if err != nil {
logRemoteSuperBlockFault("readSuperBlock remote volume %d: %v", id, err)
}
}
+27
View File
@@ -68,6 +68,33 @@ func TestMarkVolumeWritable_ReopensPersistedReadOnly(t *testing.T) {
}
}
// A remote-tiered volume marked writable keeps the delete-only mode load()
// derives — the remote .dat cannot take appends, so "writable" resolves to
// can-delete even when the mark left noWriteOrDelete without noWriteCanDelete.
func TestMarkVolumeWritableOnATieredVolumeKeepsDeletesOnly(t *testing.T) {
b := newLocalDirBackend(t)
registerTestBackend(t, b)
v, _ := tierUpVolumeLive(t, t.TempDir(), 86, b)
defer v.Close()
// the upload-time mark as a refused MarkTieredDeletable leaves it
v.noWriteOrDelete = true
v.noWriteCanDelete = false
location := newTestLocation(10, false, 0)
location.SetVolume(86, v)
s := &Store{Locations: []*DiskLocation{location}}
if err := s.MarkVolumeWritable(86); err != nil {
t.Fatalf("mark writable: %v", err)
}
readOnly, orDelete, canDelete, _ := v.ReadOnlyReasons()
if orDelete || !canDelete || !readOnly {
t.Fatalf("a tiered volume resolves writable to delete-only, got readOnly=%v orDelete=%v canDelete=%v",
readOnly, orDelete, canDelete)
}
}
// reopenIdxForWrite must be a no-op when the volume already has a writable
// needle map — otherwise repeated MarkVolumeWritable calls would churn the
// index file handle for no reason.
@@ -0,0 +1,21 @@
package storage
import (
"errors"
"testing"
)
func TestRemoteSuperBlockLogOnlyOnError(t *testing.T) {
var logged int
old := logRemoteSuperBlockFault
logRemoteSuperBlockFault = func(string, ...interface{}) { logged++ }
defer func() { logRemoteSuperBlockFault = old }()
logRemoteSuperBlockErr(219, nil)
if logged != 0 {
t.Fatalf("a nil error was logged at ERROR (the \"readSuperBlock remote volume 219: <nil>\" line)")
}
logRemoteSuperBlockErr(219, errors.New("timeout")) // positive control
if logged != 1 {
t.Fatalf("a real error must still be logged, got %d", logged)
}
}
+35
View File
@@ -87,6 +87,41 @@ func (v *Volume) LoadRemoteFile() error {
return v.loadRemoteFileLocked()
}
// MarkTieredDeletable gives a volume whose data has just moved to the remote
// tier the flags a restart gives it (load(): a tiered volume refuses writes and
// accepts deletes). volume.tier.upload marks the volume read-only without
// deletes before the upload; left that way, every delete until the next
// restart is refused, the filer drops it, and the needle's bytes stay in the
// remote object where no vacuum can find them.
//
// Only for a single-copy volume: the shell copies the uploaded replica's .idx
// and .vif to the other replicas after the upload, and a tombstone written
// before that copy would reach one replica only; a replicated volume keeps
// refusing deletes until its next load, as before. An I/O quarantine is left
// alone, and so is a volume past the I/O error tolerance that the next
// heartbeat will quarantine. Otherwise the flags are the ones load() gives
// every tiered volume.
func (v *Volume) MarkTieredDeletable() bool {
if rp := v.SuperBlock.ReplicaPlacement; rp != nil && rp.GetCopyCount() > 1 {
return false
}
// Check the I/O state under noWriteLock: CollectHeartbeat marks the
// quarantine before it takes noWriteLock to refuse deletes, so either the
// check below sees the quarantine, or the heartbeat's refusal lands after
// this change and stays.
v.noWriteLock.Lock()
defer v.noWriteLock.Unlock()
if !v.HasRemoteFile() {
return false
}
if ioErr, ioCount, quarantined := v.getIoErrorState(); quarantined || (ioErr != nil && ioCount >= IoErrorTolerance) {
return false
}
v.noWriteOrDelete = false
v.noWriteCanDelete = true
return true
}
// loadRemoteFileLocked swaps the data backend to the remote tier object. The
// caller must hold dataFileAccessLock or be single-threaded (load() during
// construction or a compaction-commit reload). It marks the volume tiered in the
+123
View File
@@ -0,0 +1,123 @@
package storage
import (
"syscall"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/stretchr/testify/require"
)
// MarkTieredDeletable gives a freshly tiered single-copy volume the flags a
// restart gives it, and leaves an I/O quarantine alone.
func TestMarkTieredDeletable(t *testing.T) {
b := newLocalDirBackend(t)
registerTestBackend(t, b)
v, _ := tierUpVolumeLive(t, t.TempDir(), 81, b)
defer v.Close()
v.noWriteOrDelete = true // as volume.tier.upload's mark leaves it
require.True(t, v.MarkTieredDeletable())
require.False(t, v.noWriteOrDelete)
require.True(t, v.noWriteCanDelete)
w, _ := tierUpVolumeLive(t, t.TempDir(), 82, b)
defer w.Close()
w.noWriteOrDelete = true
w.markIoQuarantined()
require.False(t, w.MarkTieredDeletable())
require.True(t, w.noWriteOrDelete, "an I/O quarantine is not the tier upload's to lift")
}
// MarkTieredDeletable is only for a tiered volume: a stray call on a local
// volume must not pin it read-only-can-delete.
func TestMarkTieredDeletableLeavesANonTieredVolume(t *testing.T) {
dir := t.TempDir()
v, err := NewVolume(dir, dir, "", 86, NeedleMapInMemory, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)
require.NoError(t, err)
defer v.Close()
require.False(t, v.MarkTieredDeletable())
require.False(t, v.noWriteOrDelete)
require.False(t, v.noWriteCanDelete)
}
// A heartbeat that quarantines the volume while MarkTieredDeletable waits for
// noWriteLock must keep its refusal: the quarantine check and the flag change
// happen under the same lock.
func TestMarkTieredDeletableKeepsAConcurrentQuarantine(t *testing.T) {
b := newLocalDirBackend(t)
registerTestBackend(t, b)
v, _ := tierUpVolumeLive(t, t.TempDir(), 83, b)
defer v.Close()
v.noWriteOrDelete = true
v.noWriteLock.Lock()
done := make(chan bool)
go func() { done <- v.MarkTieredDeletable() }()
time.Sleep(100 * time.Millisecond) // let it reach noWriteLock
// What CollectHeartbeat does: mark the quarantine, then refuse writes and deletes.
v.markIoQuarantined()
v.noWriteOrDelete = true
v.noWriteLock.Unlock()
require.False(t, <-done, "a volume quarantined before the flags changed must not be made deletable")
v.noWriteLock.RLock()
defer v.noWriteLock.RUnlock()
require.True(t, v.noWriteOrDelete, "the heartbeat's refusal was overwritten")
}
// A volume past the I/O error tolerance is about to be quarantined by the next
// heartbeat; it is not made deletable in the meantime.
func TestMarkTieredDeletableRefusesAVolumePastIoErrorTolerance(t *testing.T) {
b := newLocalDirBackend(t)
registerTestBackend(t, b)
v, _ := tierUpVolumeLive(t, t.TempDir(), 84, b)
defer v.Close()
v.noWriteOrDelete = true
for i := 0; i < IoErrorTolerance; i++ {
v.noteIoError(syscall.EIO)
}
require.False(t, v.MarkTieredDeletable())
require.True(t, v.noWriteOrDelete)
v.clearIoError() // positive control: below the tolerance it is made deletable
require.True(t, v.MarkTieredDeletable())
require.False(t, v.noWriteOrDelete)
}
// DeleteVolumeNeedle reads the read-only flags under noWriteLock, so a delete
// that races the tier upload's flag change is not a data race (run with -race).
func TestDeleteVolumeNeedleRacesMarkTieredDeletable(t *testing.T) {
b := newLocalDirBackend(t)
registerTestBackend(t, b)
v, _ := tierUpVolumeLive(t, t.TempDir(), 85, b)
defer v.Close()
v.noWriteOrDelete = true
location := newTestLocation(10, false, 0)
location.SetVolume(85, v)
s := &Store{Locations: []*DiskLocation{location}}
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 100; i++ {
v.MarkTieredDeletable()
}
}()
for i := 0; i < 100; i++ {
_, _ = s.DeleteVolumeNeedle(85, &needle.Needle{Id: types.Uint64ToNeedleId(1000)})
}
<-done
// Positive control: once deletable, a delete of a live needle goes through.
size, err := s.DeleteVolumeNeedle(85, &needle.Needle{Id: types.Uint64ToNeedleId(1)})
require.NoError(t, err)
require.NotZero(t, size)
}