Compare commits

...
Author SHA1 Message Date
Chris Lu 55010be19b 4.33 2026-06-11 00:52:31 -07:00
Chris LuandGitHub 79ac279fe1 fix(ec): don't mix EC shards from different encode runs (#9880)
* feat(ec): add encode_ts_ns to EC shard metadata and the shard read RPC

EcShardConfig and VolumeEcShardReadRequest gain an int64 encode_ts_ns
(encode time in unix nanos). It rides in .vif and the read request so a
read can be scoped to the encode run that produced the index.

* fix(ec): stamp each encode and reject cross-run shard reads

Generate stamps EncodeTsNs into the volume's .vif. Reads carry it to the
shard's owning volume (resolved together via FindEcVolumeWithShard, so a
multi-disk server validates the disk that actually serves the bytes) and
reject a shard from a different encode run, recovering from parity. A
zero on either side (pre-upgrade volume) skips the guard.

* fix(ec): stamp the encode identity on the worker-generated .vif

The worker-local encode path now writes EncodeTsNs (and the resolved EC
ratio) into the .vif, so the read guard is not silently off for volumes
encoded by the maintenance worker.

* fix(ec): wipe stale EC artifacts before re-encoding

VolumeEcShardsGenerate evicts any in-memory EcVolume for the volume and
removes its on-disk shard/index/sidecar files before writing fresh ones,
so a retried encode never builds on a partial prior run and the unlink
frees the inodes instead of leaving open fds serving old bytes.

* fix(ec): unmount EC shards across all disks

UnmountEcShards walked only the first disk holding the shard, leaving a
duplicate copy mounted on a sibling disk (split-disk reconciled volumes)
still serving and heartbeating. Traverse every disk and emit one
deletion delta per disk.

* fix(ec): delete orphan shards without a local .ecx

deleteEcShardIdsForEachLocation gated shard-file removal on a local .ecx,
so it could not clean an orphan .ecNN left by a failed copy on a disk
with no index. Delete the requested shard files unconditionally; the
index-file (.ecx/.ecj/.vif) routing stays gated as before.

* fix(ec): clear stale EC shards cluster-wide before re-encoding

ec.encode unmounts and deletes EC shards for the target volumes on every
node before regenerating: fatal for the shards the topology reports
(mounted leftovers), best-effort for the rest (a sweep that catches
unmounted failed-copy orphans). A down node is a no-op.

* fix(ec): don't nil EC fds on close so reads can't race eviction

A reader resolves an EcVolume/shard under the lock then reads after it is
released, so an eviction that nils ecxFile/ecdFile would race that read
and panic. Close the fds without nilling the fields: the field is now
write-once (no data race) and a concurrent read hits a closed fd, getting
a clean error that the caller recovers from parity.

* fix(ec): wipe stale EC artifacts on every disk and surface failures

The pre-encode wipe only deleted beside the source volume, so a stale
shard on a sibling disk survived and could be mounted against the new
index at reconcile. Sweep every disk. Removal also ignored os.Remove
errors, reporting a failed cleanup as success and letting a stale shard
join the next generation; surface the first real failure (treating
already-gone as success) from removeStaleEcArtifacts and the shard delete.

* fix(ec): log when a local shard is skipped for a different encode run

The cross-run guard returned errShardNotLocal, indistinguishable in logs
from a genuinely-absent shard. Add a V(1) line naming both EncodeTsNs so
operators can tell "wrong encode generation" from "shard not here".

* fix(ec): surface metadata removal failures in the shard delete path

deleteEcShardIdsForEachLocation still dropped os.Remove errors on the
.ecx/.ecj/.vif/sidecar cleanup. A surviving stale .ecx is the orphan-index
condition this path prevents, so route those through removeFileIfExists and
return the first real failure instead of reporting cleanup as success.

* fix(ec): fail orphan cleanup when a reachable node's delete fails

The pre-encode orphan sweep swallowed every error for unreported (node,
volume) pairs. That is only safe for an unreachable node, which cannot
receive this encode's new generation. A reachable node whose delete
genuinely failed (permission/IO) keeps an orphan shard that a later copy
re-stamps with the new run's volume-level .vif identity, so the read guard
would accept stale data. Surface those; stay best-effort only for
unreachable nodes (gRPC Unavailable / no status).

* fix(ec): guard ecjFile under its lock in the EC delete path

EcVolume.Close nils ecjFile under ecjFileAccessLock; a delete that resolved
its .ecx lookup before a concurrent eviction (the generate-time
UnloadEcVolume) could then reach the journal append with a nil fd. Bail
with a clear "volume closed" error under the lock instead.

* fix(ec): reject an unstamped shard when the caller has an encode identity

The read guard required both identities nonzero, so a current (stamped)
caller accepted a holder with identity 0 and could be served a stale
pre-upgrade shard. Reject when the caller is stamped and the holder
differs (including unstamped); stay lenient only when the caller itself
has no identity (pre-upgrade reader). A skipped shard recovers from parity.

* fix(ec): full-teardown delete so cluster cleanup wipes a whole generation

The pre-encode cluster sweep deleted only the listed canonical shards on
remote nodes, leaving index/sidecar (and, on builds with versioned
generations, those too) behind. Add a full_teardown flag to
VolumeEcShardsDelete that evicts the volume and wipes every EC artifact for
it on every disk via removeStaleEcArtifacts; the shell and worker pre-encode
cleanup paths set it. Other delete callers (balance/decode/repair) are
unchanged.

* fix(ec): take ecjFileAccessLock before the nil-check in Sync and Close

Sync and Close read ev.ecjFile before acquiring ecjFileAccessLock while
Close nils it under the lock, a data race on the field. Take the lock
first, then nil-check inside, in both.

* fix(ec): acknowledge full_teardown so a pre-upgrade server can't fake success

An old volume server silently ignores full_teardown and returns success
for an ordinary delete, so the caller wrongly believes the generation was
wiped and copies a fresh gen-0 onto an unwiped node. Echo full_teardown_done
in the response; the worker destination cleanup fails when it is absent, and
the shell cluster sweep fails for a reported (mounted) leftover while staying
best-effort for an unreported node. encode_ts_ns stays an accepted transient
(an old server just skips the new read guard, no regression).

* fix(ec): fail the pre-encode sweep for any reachable node that can't ack teardown

A reachable pre-upgrade server ignores full_teardown and returns success
without wiping an orphan, which a later copy then folds into the new
generation. Treat a missing full_teardown_done ack as fatal for every
reachable node (best-effort only for a gRPC-unreachable one), not just for
topology-reported pairs.

* fix(ec): return the served shard identity and validate it client-side

The encode identity was only enforced server-side, so a pre-upgrade server
ignored the request field and served bytes unchecked. Echo the served
shard's EncodeTsNs on every read response chunk and have the client reject a
mismatch (including 0 from an old server), so the guard holds regardless of
server version; a rejected read recovers from parity.

* fix(ec): reject a short/empty remote shard read instead of serving zeros

doReadRemoteEcShardInterval accepted an immediate EOF or a short stream and
returned success with a partly zero-filled, unvalidated buffer (the server
stamps the identity only on chunks that carry bytes). A non-deleted interval
must arrive whole: require n == len(buf), exempting the is_deleted
short-circuit (n=0), matching readLocalEcShardInterval's local check. A short
read now fails so the caller recovers from parity.

* test(ec): fake volume server echoes the full_teardown acknowledgement

The worker now fails a teardown delete that isn't acknowledged (so a
pre-upgrade server can't silently skip the wipe). The fake server's no-op
VolumeEcShardsDelete returned an empty response, which the worker read as a
skipped teardown and aborted the encode. Echo full_teardown_done.

* feat(ec): mirror the encode-run identity guard + full_teardown into the Rust volume server

The Go volume server stamps an encode-run identity (encode_ts_ns) into the .vif
and rejects a read served from a shard of a different run; full_teardown wipes a
whole generation and acknowledges it. The Rust volume server had none of it.
Mirror the shared logic: load encode_ts_ns from the .vif onto the EcVolume,
stamp it on every read response, and reject a request/response mismatch on both
the server and the distributed-read client (recovering from parity); handle
full_teardown by evicting the volume and wiping every EC artifact on each disk,
echoing full_teardown_done so the caller can detect a server that ignored it.

* fix(ec): remove a stale .vif on full teardown of a shard-only node

A shard copy installs shards + .ecx before .vif, so an interrupted copy after a
teardown could mount the new files under the previous run's identity / version /
shard ratio / dat_file_size carried by the surviving .vif. Remove .vif during
full teardown, gated on .idx absence so a source-volume holder keeps its live
.vif. In Rust this lives in a teardown-only helper so the reconcile / load-
fallback paths (which share the base removal) still preserve .vif.

* fix(ec): treat a missing teardown ack as fatal, not as an unreachable node

isNodeUnreachable returned true for any non-gRPC-status error, so a reachable
pre-upgrade server's missing full_teardown_done ack (a plain error) was
classified unreachable and the unreported pair was silently skipped. Classify
only a real codes.Unavailable as unreachable, and wrap the missing ack in a
sentinel the sweep treats as fatal regardless. A genuinely down node still
surfaces as Unavailable from the RPC and stays best-effort.

* fix(ec): reject a short shard read in the local EC needle reader

read_ec_shard_needle ignored the byte count from shard.read_at and appended the
whole pre-sized buffer, so a truncated shard's zero-filled tail passed the later
length check and parsed as garbage. Require n == buf.len() per interval, erroring
on a short read like the local interval reader already does.

* fix(ec): probe reachability before skipping a node that returns Unavailable

The pre-encode sweep skipped any node whose teardown delete returned
codes.Unavailable, but a reachable volume server in maintenance mode also
returns that code for the maintenance-gated delete, so its stale EC files were
left behind on a node that can still receive the new generation. Confirm with a
non-maintenance-gated empty-target Ping: skip only when the node fails the probe
too (genuinely unreachable).

* fix(ec): use try_exists for the teardown .vif .idx guard

The teardown-only .vif removal gated on Path::exists(), which returns false on a
permission/IO stat error, so a stat failure on a present .idx would read as a
shard-only node and delete the live source volume's .vif. Gate on
try_exists() == Ok(false) instead, preserving the sidecar on any stat error.

* fix(ec): only skip a sweep node when a Ping confirms it is transport-down

The pre-encode sweep skipped a node whenever its teardown delete and a liveness
Ping both failed, but it treated ANY Ping error as down — an application-level
Internal/ResourceExhausted, or Unimplemented from a pre-Ping server, left a
reachable node's stale generation in place. Classify the Ping tri-state and skip
only when it transport-fails with codes.Unavailable; a reachable or inconclusive
node stays fatal.

* fix(ec): exclude sweep-skipped nodes from the encode's rebalance

The pre-encode sweep skips a genuinely-down node best-effort, but the rebalance
then recollected the current topology — a node that recovered between the two
could become a copy target and receive the new generation while still holding
its stale, never-cleared shards. Have the sweep return the skipped set and
exclude those nodes from the rebalance for this encode, so a node we could not
clean cannot receive the new generation. Standalone ec.balance is unaffected.

* fix(ec): re-sweep recovered nodes before generation so they aren't stranded

A node skipped as down by the pre-encode sweep is excluded from the rebalance,
but it can recover and become the generation host — mounting all shards locally,
then being excluded from distribution. Union-only verification accepts all
shards on one node and deletes the originals: a single point of failure. Re-sweep
the skipped nodes just before generation; one whose teardown now succeeds leaves
the skipped set and rebalances normally, while a node still down stays skipped.

* fix(ec): abort the encode if a selected source is still skipped after re-sweep

The re-sweep un-skips a recovered node, but the source was selected before it and
a node can stay down through the re-sweep then recover just in time to be the
generation host — mounting all shards locally while still excluded from the
rebalance, which union-only verification accepts before deleting the originals.
Abort the encode when a selected source remains skipped after the re-sweep.

* fix(ec): batch delete returns retriable 503 when a volume became EC mid-batch

If a volume is not EC at the batch-delete classification but is encoded to EC and
its .dat deleted before the regular-volume mutation, the mutation returns an exact
"not found" that the filer chunk-GC treats as completed, dropping the delete.
Recheck EC presence under the mutation lock and return a retriable 503 with the
"try again" token so the filer requeues it onto the EC path.

* fix(ec): recheck EC state before the regular batch-delete mutation

ec.encode mounts EC shards (copied from the .dat) before deleting the originals,
so a volume can be EC while its .dat still exists. The batch delete only rechecked
EC after a NotFound, so a successful regular-volume delete in that window wrote a
tombstone to the soon-removed .dat — the delete was lost and the needle resurrected
from the pre-tombstone shards. Recheck has_ec_volume under the write lock before
delete_volume_needle and return a retriable 503 so the filer requeues onto the EC path.

* fix(volume): make the metrics push test independent of test order

test_push_metrics_once asserted the pushed body contains the request-counter
family without ever touching the counter — a CounterVec with no children emits
nothing, so the assertion only held when another test had already created a
labelset in the shared registry. Create one in the test itself.
2026-06-10 22:31:18 -07:00
Bruce ZouandGitHub 1dd292fb84 batch drain delta heartbeat messages (#9914) 2026-06-10 13:33:45 -07:00
Lisandro PinandGitHub 6b4d20a6f3 volume.scrub and ec.scrub shell commands: make the display of scrub details optional. (#9911)
On volumes failing scrubs, the detail output can get very verbose, which makes
reading results difficult. Most users won't care about this information to
begin with - just whether or not volumes pass scrub tests.

This MR gates the display of scrub result details behind a `--details` flag.
2026-06-10 13:29:07 -07:00
Chris LuandGitHub caadd6ca79 ci(s3tables): stop Lakekeeper flaking on Docker Hub pull timeouts (#9920)
* ci(s3tables): drop docker pre-pull from Lakekeeper job

The lakekeeper repro is pure Go against the local weed binary; the job
kept failing on Docker Hub timeouts pulling python:3 and localstack
images the test never runs. Also drop the stale python-in-docker
comments left from the old harness.

* ci(s3tables): serve python:3 from GHA cache in the STS job

Retried pulls still die when both mirror.gcr.io and registry-1.docker.io
are unreachable from the runner. Cache the saved image tarball under a
weekly key: an exact hit skips the registry entirely, a miss pulls fresh
and refreshes the cache, and a stale tarball from a previous week is the
fallback when Docker Hub is down.

* ci(spark): pre-pull the spark tag the test actually runs

The workflow warmed apache/spark:3.5.8 with retries while the
testcontainers setup runs apache/spark:3.5.1, so the real image was
pulled at test time with no retry at all.
2026-06-10 13:26:30 -07:00
Chris LuandGitHub 594fc667d5 Cut per-subscriber replay decode and widen replay concurrency (#9917)
* Filter metadata events before unmarshaling them per subscriber

Every subscriber unmarshaled every log entry into a full event just to
run the path filter, and entries carry complete chunk lists, so a fleet
of path-filtered subscribers spends almost all replay CPU materializing
events it then discards. A shallow wire scan now extracts just the
directory, entry names and rename destination into a skeleton event,
feeds the same matcher, and skips the decode for entries the subscriber
cannot match. Any scan surprise (malformed bytes, merged duplicate
message fields) falls back to the full decode, and the unsynced-events
heartbeat keeps firing for skipped entries.

* Raise the legacy replay cap

The cap was sized when every replay pinned a private chunk reader per
source filer. Replays now share decoded chunks, so sixteen needlessly
serializes subscriber catch-up; the expensive part stays bounded by the
cache's load gate.

* Weight concurrent log-chunk loads by size

The flat eight-load gate let eight tiny chunks through as reluctantly as
eight full ones. Charge each load's chunk size against a 128MB in-flight
budget instead: small chunks decode wide open while full-size ones still
serialize enough to cap the transient peak. Oversized weights clamp to
the budget so they can always acquire.

* Propagate heartbeat send failures and reset the skip counter

A failed heartbeat send means the stream is gone, so end the replay
instead of scanning on. A delivered event also resets the skip counter,
keeping the heartbeat cadence relative to the last thing the client
actually received.

* Share the unsynced-events counter across the prefilter and delivery

Two independent counters could starve the heartbeat: alternating drops
reset each side before either reached its threshold. One shared counter
increments on every dropped entry, prefiltered or not, and only an
actual delivery resets it, restoring the original cadence exactly.

* Tighten comments

* Benchmark the subscription match paths

For a thousand-chunk event that the subscriber filters out, the shallow
scan matches in 10us and 9 allocations against 175us and 4031
allocations for the full decode.
2026-06-10 13:08:34 -07:00
Chris LuandGitHub e56a1c4c05 admin: pre-gzip embedded static assets, add cache headers (#9918)
The admin UI served embedded static files uncompressed and without
cache headers: embed.FS has zero mod times, so no Last-Modified, no
ETag, no 304s -- every page load re-downloaded ~700KB of css/js in
full, which gets painful over slow or tunneled links.

Gzip the static tree at generation time (go generate ./weed/admin)
and embed only the compressed mirror, shrinking the binary ~1.5MB.
The handler hands the pre-compressed bytes to gzip-capable clients,
decompresses for the rest, and sets Cache-Control, per-variant
content-hash ETags and Vary so repeat loads revalidate with a 304.
bootstrap.min.css goes 232KB -> 30KB on the wire.

A drift test keeps static_gz/ in sync with static/.
2026-06-10 12:54:36 -07:00
Chris LuandGitHub c2271d59bb log_buffer: stop dumping the whole log entry on callback errors (#9919)
The eachLogDataFn error path printed the full LogEntry proto. For an
entry carrying a large chunk manifest that is hundreds of KB of escaped
bytes in a single log line, burying the actual error -- often just a
subscriber disconnect -- at the very end. Log the key, timestamp,
offset and data size instead.
2026-06-10 12:47:35 -07:00
Chris LuandGitHub 2ac5aa72c7 add elastic8 filer store for Elasticsearch 8 (#9916)
* elastic: fix listing against a missing or empty directory index

The refresh 404 leaked into the named return, so the first listing of a
directory whose index does not exist yet returned an error instead of an
empty result. Sorting also fails on an index with no documents
("No mapping found for [_id] in order to sort on"); unmapped_type
keeps the resumed-listing path working there.

* add elastic8 filer store for Elasticsearch 8

Elasticsearch 8 disables _id fielddata by default, so the elastic7
store's directory listings fail with "Fielddata access on the _id
field is disallowed". elastic8 uses the same client and configuration
options, but also indexes the document id as an Id field and sorts
listings on Id.keyword.
2026-06-10 12:10:49 -07:00
689b5b61bf fix(s3api): reject empty v4 signed header names (#9910)
Problem: Signature V4 SignedHeaders parsing accepted empty header name segments such as host; or ;host. Malformed Authorization headers could continue into signature verification instead of failing during header parsing.

Root cause: parseSignedHeader only checked that the SignedHeaders value was non-empty, then split it on semicolons without validating each element.

Fix: reject empty or whitespace-only signed header elements with ErrMissingFields before returning the parsed header list.

Reproduction: go test ./weed/s3api -run TestParseSignedHeaderRejectsEmptyHeaderNames -count=1 failed before the fix because SignedHeaders=host; returned ErrNone.

Validation: gofmt -w weed/s3api/auth_signature_v4.go weed/s3api/auth_signature_v4_test.go; git diff --check; go test ./weed/s3api -run TestParseSignedHeaderRejectsEmptyHeaderNames -count=1; go test ./weed/s3api -count=1

Co-authored-by: Codex <noreply@openai.com>
2026-06-10 11:00:35 -07:00
Chris LuandGitHub 7bf2dfc9ab Bound the metadata-log flush queue (#9907)
* Bound the metadata-log flush queue

A stalled flush, e.g. slow volume servers under a reconnect storm, let up
to 256 queued 8MB buffer copies pin two gigabytes per log buffer while
producers kept filling the queue. Cap the queue at 16 so a sustained
stall backpressures writers instead of growing the heap. The flush
goroutine never feeds back into the buffer (system-log paths skip event
notification), so blocked producers cannot deadlock the consumer.

* Don't drop a force-flushed buffer on a full queue

ForceFlush enqueued with a two-second timeout, but by then the live
buffer was already sealed and reset, so a timed-out send silently lost
the copy. Block until the flush is queued; the wait for completion stays
bounded since the data is durable once the flush loop drains it.

* Never close the flush channel

ShutdownLogBuffer closed flushChan while producers could still be
blocked sending into it, which panics. Terminate loopFlush with a nil
sentinel instead, so the channel is never closed, and give every
producer-side send a shutdown escape so none parks forever once the
flush loop exits. Everything queued before the sentinel still drains,
preserving IsAllFlushed semantics.

* Copy the shutdown flush under the buffer lock

Every other copyToFlush call site holds the lock; the shutdown path read
the live buffer unlocked while producers could still be appending.
2026-06-10 10:57:30 -07:00
Chris LuandGitHub bf76040046 Share metadata-log replays per chunk instead of per file (#9906)
* Share metadata-log replays per chunk instead of per file

Log file chunks are immutable: each metadata-log flush uploads one whole
buffer of complete records as a new chunk, and appends only add chunks.
So cache decoded entries per chunk, with no age gate and no fingerprint
revalidation. The per-file cache excluded files younger than two flush
intervals, which is exactly the hot tail that every tailing or
reconnecting subscriber replays — each through a private chunk reader
holding an 8MB buffer and decoding the whole file from byte zero.

A chunk's flush time also upper-bounds every record timestamp inside it,
so a tail replay now skips cold chunks without reading them at all.

If a chunk does not decode standalone (records spanning chunk
boundaries, or a corrupt size prefix), fall back to streaming the whole
file as one byte stream, resuming after the last yielded entry.

* Evict idle metadata-log cache entries

The replay cache only evicted on insert, so once filled it held its full
budget forever. Stamp entries on use and sweep the LRU tail every minute,
dropping anything untouched for five minutes; the cache now holds memory
only while subscribers actually replay.

* Reject implausible records when decoding log chunks

proto.Unmarshal is permissive: empty payloads and unknown-field garbage
parse without error, so a chunk starting mid-record could decode by
coincidence and get cached instead of falling back to the byte stream.
Enforce what the writer guarantees - records are never empty and carry
strictly increasing positive timestamps within one flushed buffer.

* Gate the singleflight test on an open flight

The sleep alone only probabilistically created concurrent misses; a
started channel now proves the loader holds the flight before callers
are released.
2026-06-10 10:57:11 -07:00
Lisandro PinandGitHub 5150c86934 Make shell command ec.scrub return shard details upon scrub failures in LOCAL mode. (#9913)
This is useful information to deal with issues requiring EC shard rebuilding,
such as https://github.com/seaweedfs/seaweedfs/issues/9872.
2026-06-10 10:55:16 -07:00
7c0a9acb30 fix(s3api): normalize checksum trailer header names (#9905)
Problem: SigV4 chunked upload checksum trailer parsing rejected mixed-case checksum header names even though HTTP header field names are case-insensitive.

Root cause: extractChecksumAlgorithm compared the x-amz-trailer value and trailer header key against exact lowercase strings.

Fix: Trim and lowercase checksum trailer header names before matching supported checksum algorithms.

Reproduction: go test ./weed/s3api -run TestExtractChecksumAlgorithmIsCaseInsensitive -count=1 with X-Amz-Checksum-Crc32; before the fix it returned unsupported checksum algorithm.

Validation: gofmt -w weed/s3api/chunked_reader_v4.go weed/s3api/chunked_reader_v4_test.go; git diff --check; go test ./weed/s3api -run TestExtractChecksumAlgorithmIsCaseInsensitive -count=1; go test ./weed/s3api -count=1

Co-authored-by: Codex <noreply@openai.com>
2026-06-10 00:30:43 -07:00
Chris LuandGitHub 0c2576c3d0 ci: route Docker Hub pulls through a mirror to cut registry timeouts (#9904)
* ci(s3tables): route Docker Hub pulls through mirror, drop unused buildx

The integration jobs set up docker/setup-buildx-action only to docker
pull/run images; the buildx bootstrap pulls moby/buildkit from
registry-1.docker.io, which times out and fails the whole job before any
test runs. These jobs never docker build with buildx, so the setup is
pure overhead and an extra registry dependency.

Replace it with a daemon registry-mirror pointing at mirror.gcr.io (a
pull-through cache for Docker Hub) and retry the pre-pulls a few times.
That removes the buildkit pull entirely and routes the rest through the
cache, with graceful fallback to Docker Hub on a miss.

* ci: route Docker Hub through mirror in remaining docker test workflows

Same registry-1.docker.io timeout fix for the other integration jobs.

s3-spark only docker pulls/runs an image, so drop the vestigial buildx
setup and pull through the mirror with retries, matching s3-tables.

kafka-quicktest, s3-proxy-signature, e2e and postgres build/compose and
genuinely need buildx (e2e/postgres export a local layer cache, which the
default driver can't), so keep it and just configure the mirror first —
that way even the moby/buildkit bootstrap pull is served from the cache.

Left samba/pjdfstest alone: they build-push to a local registry and pull
from localhost, so buildx is required and there's no Docker Hub runtime
pull to mirror.
2026-06-09 17:12:42 -07:00
Chris LuandGitHub 9e98ec4b2e Share decoded metadata-log entries across subscriber replays (#9903)
perf(filer): share decoded log entries across metadata replays

Concurrent SubscribeMetadata replays of the same persisted log history each
opened a chunk reader per source filer and re-decoded the same files, so a
reconnect storm multiplied into many GB of buffers. Cache the decoded entries
of completed log files in a bounded LRU, coalescing concurrent loads with
single-flight and bounding concurrent decodes. Each hit is validated against
the file's current chunk set, so a file that received a late append is
reloaded rather than served stale; reads that stop on an unreachable chunk are
delivered but not cached so a transient outage re-probes on the next replay.
2026-06-09 13:34:11 -07:00
Chris LuandGitHub e12052ee6b fix(filer.sync): replicate a rename as an atomic move, not a no-op update (#9895)
* fix(filer.sync): replicate a rename as create-then-delete, not an in-place update

A rename arrives as a single metadata event carrying both the old and new
entry. The filer sink was routed to UpdateEntry, which looks up the old
path but issues the update against the new parent without changing the
name — and the filer UpdateEntry RPC cannot move an entry. So the rename
was dropped: the old path lingered and the new path never appeared
(same-dir renames rewrote the old name in place).

Route a real move (the sink path changed) through CreateEntry(new) then
DeleteEntry(old) in both the replicator and the filer.sync/backup driver,
the way the other sinks already handle it; reach UpdateEntry only for true
in-place updates. Create before delete so a crash between the two leaves
the entry visible rather than lost.

* fix(filer.sync): derive the rename delete key like the create key, guard the watched root

The rename delete leg rebuilt the old key with a raw util.Join, bypassing the
sink-side key normalization the create leg gets from buildKey — so a rename
could create the new entry and then fail to delete the old one under a
transformed key. Build the old key through buildKey too, and skip the delete
when the moved entry is the watched root itself (where the old key would
resolve to the target root and recursively delete the whole sink tree).

* test(filer.sync): cover the in-place update delete-then-create fallback order

The recording sinks always reported foundExisting, so the fallback that an
in-place update takes when the entry is missing on the sink was never run.
Make it configurable and assert the fallback deletes before it recreates the
same key, in both the replicator and the filer.sync drivers.

* feat(filer.sync): move filer-sink renames natively via AtomicRenameEntry

create-then-delete is unsafe for the filer sink: CreateEntry returns nil
without creating on a transient chunk-copy error, so the paired delete could
remove the only valid destination copy; a directory rename also deleted the
old subtree before descendants were recreated, and left old chunks behind.

Add an optional EntryMover sink capability and implement it on the filer sink
via AtomicRenameEntry — one atomic, metadata-only move that relocates a whole
subtree in a single transaction. Renames prefer it; sinks without a native
move keep create-then-delete. When the old path is already gone (a descendant
the parent rename moved, or one never replicated) MoveEntry creates the new
path instead, re-checking existence with a lookup so a rolled-back move that
left the old entry intact is retried rather than mistaken for gone.

* docs(filer.sync): note entryMissing's gRPC not-found string fallback is deliberate
2026-06-09 12:54:28 -07:00
7y-9GitHubCodexgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
a9e4995d76 fix(http): accept no content delete responses (#9893)
* fix(http): accept no content delete responses

Problem: util/http.Delete reports an error for a successful HTTP 204 No Content response.

Root cause: Delete only treats 200 OK, 202 Accepted, and 404 Not Found as non-error responses, omitting the standard 204 status commonly returned by DELETE endpoints.

Fix: Include http.StatusNoContent in the Delete success status set.

Reproduction: go test ./weed/util/http -run TestDeleteTreatsNoContentAsSuccess -count=1 fails before the fix with an empty error for a 204 response.

Validation: go test ./weed/util/http -run TestDeleteTreatsNoContentAsSuccess -count=1; go test ./weed/util/http -count=1; git diff --check; git diff --cached --check
Co-authored-by: Codex <noreply@openai.com>

* Update weed/util/http/http_global_client_util_test.go

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-06-09 11:45:14 -07:00
Chris LuandGitHub 048f9ece2d Fix filer metadata-replay OOM under mount reconnect storms (#9901)
* fix(filer): propagate multi-filer metadata log read errors

A genuine (non not-found) read error in one filer's log stream was logged
and skipped, then the merged cursor advanced past the gap, silently
dropping that file's events. Abort the whole replay so the subscriber
re-reads from the unchanged position; chunk-not-found still skips.

* perf(mount): read persisted metadata log chunks directly from volume servers

Set LogFileReaderFn so the filer returns log file references and the mount
reads the chunk data itself, instead of the filer reading, decoding, and
streaming every persisted entry. Keeps a reconnect storm of many mounts
from concentrating hundreds of concurrent log replays in filer memory.

* perf(filer): pre-size chunk stream reader buffer to view size

The chunk size is known up front, so grow the buffer once instead of
letting bytes.Buffer double as the streamed pieces arrive (which
transiently overshoots to ~2x per reader).

* fix(filer): bound concurrent persisted-log replays

Each server-side replay holds an open chunk reader per source filer plus a
readahead buffer, so a reconnect storm of clients that predate the
metadata-chunks offload multiplies into many GB. Gate replays with a
semaphore; abort the acquire when the subscriber's stream is gone so
cancelled clients do not pile up parked goroutines.
2026-06-09 11:43:12 -07:00
Chris LuandGitHub 8776b9d311 feat(filer): object size distribution metric and dashboard panels (#9902)
* feat(filer): record object size distribution histogram

Add SeaweedFS_filer_object_size_bytes, a histogram sampled when an
object is first created in the filer namespace, covering every write
protocol (S3, WebDAV, FUSE mount, direct HTTP). Buckets follow the
1KB/100KB/1MB/100MB/1GB ranges operators use to size collections.
Directories, overwrites, and metadata-only updates are not sampled, so
the bucket counts track the size distribution of distinct objects.

* feat(metrics): add filer object size distribution dashboard panels

Add a write-rate-by-size-range graph and a size-distribution bar gauge,
driven by SeaweedFS_filer_object_size_bytes, to the standalone and Helm
Grafana dashboards. Per-range subtractions are clamped at zero so
transient negative rate() samples do not render below the axis.
2026-06-09 10:41:11 -07:00
Chris LuandGitHub 7b07d8177a fix(filer.sync): scope filesystem key sanitization to the local sink (#9894)
* fix(filer.sync): scope filesystem key sanitization to the local sink

destKey ran every sink key through escapeKey, whose Windows build strips
colons. Colons are illegal in NTFS filenames so the local sink needs that,
but s3/filer/azure/gcs/b2 accept them as ordinary key bytes — stripping
them silently diverged the destination key (a source a:b replicated as ab).

Move the sanitization into the local sink behind a Windows build tag,
applied at every entry point so the previously-unescaped in-place-update
paths stay consistent. Non-local sinks now keep the raw key; non-Windows
builds are unchanged; a leading drive-letter colon is preserved.

* test(filer.sync): cover incremental destKey and localsink update/delete sanitization

Lock the colon-preserving behavior for the incremental destKey branch, and
extend the Windows local-sink test to assert UpdateEntry and DeleteEntry also
sanitize the key, not just CreateEntry.
2026-06-09 10:18:49 -07:00
202517c02a fix(filer.backup): skip replay events whose source chunk was superseded or deleted (#9886)
* fix(filer.backup): skip replay events whose chunk no longer exists on the source

"Source" is the filer we replicate FROM (e.g. green in a green->blue backup).

Replaying the metadata log from a checkpoint can hit an event whose chunk was
since overwritten/deleted and garbage-collected on the source volume. Fetching
it returns 0 bytes (a permanent size mismatch), which the sink propagated to the
subscription — so the same offset retried forever and replication stalled.

Skip the event only when proven stale; otherwise keep refusing so genuine loss
of a live file still halts loudly:

- onCorruptChunk centralizes the three errChunkSizeMismatch sites.
- getEntryMtimeNs compares mtime at nanosecond precision so same-second rewrites
  (git's config.lock dance) are ordered correctly.
- sourceSupersedes re-reads the entry's current state on the source: gone
  (ErrNotFound) or a strictly-newer mtime than the replayed version -> skip;
  any other lookup error keeps the entry.

Skipping is lossless: events are full-entry snapshots, so a later event
re-carries the current chunks and a delete event reconciles a removed file.

* test(filer.backup): cover the superseded-chunk skip decision

- TestSourceSupersedes: not-found (sentinel / wrapped / gRPC string) and nil
  entry -> skip; network error -> keep; source newer -> skip; same/older -> keep.
- TestGetEntryMtimeNs: nanosecond precision, same-second ordering, nil safety.
- TestOnCorruptChunkRefusesWhenSupersessionUnconfirmed: never skip silently when
  supersession cannot be confirmed.

* fix(filer.backup): don't infer supersession for incremental sinks

In incremental mode the sink key carries a date prefix
(sinkDir/YYYY-MM-DD/relPath) that cannot be reversed to a real source path, so a
source lookup would always be ErrNotFound and wrongly classify a live entry as
deleted — skipping it. Make targetPathToSourcePath report "unmappable" in
incremental mode; hasSourceNewerVersion already declines to skip when the source
path cannot be mapped.

Found in code review. Non-incremental sinks (filer.backup green->blue) are
unaffected.

* refactor(filer.backup): name the mtime param sourceMtimeNs; note ns overflow bound

- Rename the threaded sourceMtime parameter to sourceMtimeNs across the internal
  replicate/fetch helpers so the unit is explicit (it only feeds
  hasSourceNewerVersion, which compares in nanoseconds).
- Document that getEntryMtimeNs's int64 ns arithmetic is safe until ~year 2262.

No behavior change.

* fix(filer.backup): order same-second versions in the CreateEntry skip and update gates

The CreateEntry already-replicated short-circuit and chooseUpdateAction
still compared second-grained mtime, so a newer version written within
the same second could be skipped as already-replicated or overwritten by
an older same-second replay. Route both through getEntryMtimeNs, matching
the precision the chunk-replication path already uses.

* test(filer.backup): cover same-second update-action ordering

* docs(filer.backup): trim verbose comments to terse why

* fix(filer.backup): check supersession against the rename's new path

For a rename the filer sink updates in place (the delete+create branch is
skipped for sink name "filer"), so the corrupt-chunk supersession check
queried the pre-rename key. Its source-side ErrNotFound was read as
"superseded", silently advancing the checkpoint without applying the rename.
Map the incoming entry's new path (newParentPath/newEntry.Name) for both
update branches.

* fix(filer.backup): detect a deleted source even when the replayed mtime is epoch

hasSourceNewerVersion returned early when sourceMtimeNs <= 0, skipping the
source lookup, so a deleted entry with mtime 0 (a valid epoch timestamp) never
got the gone verdict and wedged on permanent retries. Always look up; gate only
the newer-mtime comparison on a valid replayed mtime.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-06-09 08:53:29 -07:00
1cf92f6c2e fix(s3api): clear stale object lock years (#9890)
Problem: Re-storing object-lock default retention with Days left a previous Years extended attribute in place, so later loads could see both Days and stale Years.

Root cause: StoreObjectLockConfigurationInExtended only wrote period fields that were set on the new configuration and did not delete old Days or Years keys before writing the replacement rule.

Fix: Clear stored default-retention Days and Years keys before writing the current default retention period fields.

Reproduction: go test ./weed/s3api -run TestStoreObjectLockConfigurationClearsStaleYears -count=1 failed before the fix because the stale years key remained.

Validation: go test ./weed/s3api -run TestStoreObjectLockConfigurationClearsStaleYears -count=1; go test ./weed/s3api -count=1; git diff --check; git diff --cached --check

Co-authored-by: Codex <noreply@openai.com>
2026-06-09 00:48:38 -07:00
Chris LuandGitHub 7aba10fa1a fix(mongodb): merge URI auth fields with username/password override (#9889)
* fix(mongodb): merge URI auth fields with username/password override

SetAuth replaced the whole Credential parsed from the URI, dropping
AuthSource and AuthMechanism. Start from the URI-parsed Auth and only
override the username and password so credentials scoped to a specific
auth database keep working.

* fix(mongodb): set PasswordSet for explicit credentials

Required by GSSAPI auth when a password is supplied; ignored for other
mechanisms.
2026-06-09 00:18:33 -07:00
Chris LuandGitHub 2871e6552a fix(s3api): drop ancestor directory markers from prefixed ListObjectVersions (#9885)
processExplicitDirectory appended a directory-key object as a version
without checking it against the prefix. A versioned listing descends
through ancestor markers to reach a deeper prefix, so every ancestor
(Veeam/, Veeam/Backup/, ...) leaked into Versions even though none of
them match the prefix - which makes Veeam's immutable repository scan
abort on an unexpected key. Guard on the prefix so only keys at or under
it surface, matching ListObjectsV2 and AWS.
2026-06-09 00:01:06 -07:00
7y-9GitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
d569dd686f fix(shell): move files into existing destination directories (#9887)
* fix(shell): move files into existing destination directories

Problem: fs.mv /src/file /dst/dir treats an existing destination directory as a destination file path, so it renames the source to /dst/dir instead of moving it into /dst/dir/file.

Root cause: commandFsMv builds the destination LookupDirectoryEntryRequest with Directory and Name swapped, so the destination directory lookup misses.

Fix: Populate LookupDirectoryEntryRequest with Directory=destinationDir and Name=destinationName before deciding whether the destination is a directory.

Reproduction: env GOCACHE=/private/tmp/seaweedfs-go-cache go test ./weed/shell -run TestFsMvMovesIntoExistingDestinationDirectory -count=1

Validation: gofmt -w weed/shell/command_fs_mv.go weed/shell/command_fs_mv_test.go; git diff --check; git diff --cached --check; env GOCACHE=/private/tmp/seaweedfs-go-cache go test ./weed/shell -run TestFsMvMovesIntoExistingDestinationDirectory -count=1; env GOCACHE=/private/tmp/seaweedfs-go-cache go test ./weed/shell -count=1

* Update weed/shell/command_fs_mv_test.go

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-06-08 23:42:13 -07:00
Chris LuandGitHub 1c9039d3ac fix(seaweed-volume): stop EC shard deletion from phantom .dat on restart (#9874)
* fix(seaweed-volume): stop EC shard deletion from phantom .dat on restart

On startup load_existing_volumes() scans .vif/.idx entries (not just
.dat). For distributed EC, a volume's .vif can be mirrored onto a disk
whose .ecx lives on a sibling disk, so the per-disk ecx check is false
and the loader falls through to Volume::new, which always creates the
.dat if missing -> a phantom 8-byte superblock stub. The store-level
prune_incomplete_ec_with_sibling_dat then treats that stub as the
authoritative source and deletes the real EC shards on sibling disks. Go
guards the same case (disk_location.go: 'Without this guard NewVolume
below would create a phantom empty .dat') but only same-disk.

Fix A (root cause): in load_existing_volumes, don't create a .dat during
load. Skip the entry when there is no local .dat AND the .vif does not
reference remote files -- remote-tiered volumes have no local .dat but
must still load via the remote path. Uses the robust check_dat_file_exists
helper so a transient stat error doesn't skip a real volume. New volumes
go through create_volume(). Covers the cross-disk .vif/.ecx split Go's
same-disk hasEcxFile() misses.

Fix B (defense in depth, Go + Rust): when the EC .vif records no source
size (dat_file_size==0), require the sibling .dat to be strictly larger
than a bare superblock, so an empty 8-byte stub can't pass the
credibility gate. Previously it fell back to SUPER_BLOCK_SIZE, which an
8-byte stub exactly meets.

Adds regression tests reproducing the cross-disk lone-.vif phantom and
the 8-byte stub gate; updates an existing prune test to use a real
collection so its .ecx lookup matches the loaders.

* fix(storage): don't create phantom .dat from lone .vif on Go volume load

Mirror Fix A on the Go side. loadExistingVolume scans .vif/.idx entries,
and for distributed EC a .vif can be mirrored onto a disk whose .ecx is
on a sibling disk. The same-disk hasEcxFile() guard does not fire there,
so the loader falls through to NewVolume(createDatIfMissing=true) and
writes an 8-byte phantom .dat, which the sibling-.dat prune then uses to
delete the real EC shards on sibling disks. Skip the entry when there is
no local .dat AND the .vif has no remote file (via MaybeLoadVolumeInfo);
remote-tiered volumes have no local .dat but must still load.

Adds TestLoneVifDoesNotCreatePhantomDat (fails without the guard) and
TestRemoteTier_DiskScanLoadsRemoteOnlyVolume (fails if the guard skips a
remote-only volume).
2026-06-08 22:10:16 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a5f9c55479 build(deps): bump github.com/redis/go-redis/v9 from 9.19.0 to 9.20.0 (#9867)
Bumps [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) from 9.19.0 to 9.20.0.
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.19.0...v9.20.0)

---
updated-dependencies:
- dependency-name: github.com/redis/go-redis/v9
  dependency-version: 9.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 22:08:47 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
837c6f82a6 build(deps): bump io.netty:netty-codec-http2 from 4.2.13.Final to 4.2.15.Final in /test/java/spark (#9882)
build(deps): bump io.netty:netty-codec-http2 in /test/java/spark

Bumps [io.netty:netty-codec-http2](https://github.com/netty/netty) from 4.2.13.Final to 4.2.15.Final.
- [Release notes](https://github.com/netty/netty/releases)
- [Commits](https://github.com/netty/netty/compare/netty-4.2.13.Final...netty-4.2.15.Final)

---
updated-dependencies:
- dependency-name: io.netty:netty-codec-http2
  dependency-version: 4.2.15.Final
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 22:08:25 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fc6ea8c2da build(deps): bump io.netty:netty-transport-native-epoll from 4.2.13.Final to 4.2.15.Final in /test/java/spark (#9881)
build(deps): bump io.netty:netty-transport-native-epoll

Bumps [io.netty:netty-transport-native-epoll](https://github.com/netty/netty) from 4.2.13.Final to 4.2.15.Final.
- [Release notes](https://github.com/netty/netty/releases)
- [Commits](https://github.com/netty/netty/compare/netty-4.2.13.Final...netty-4.2.15.Final)

---
updated-dependencies:
- dependency-name: io.netty:netty-transport-native-epoll
  dependency-version: 4.2.15.Final
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 22:08:14 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2945f7e226 build(deps): bump io.netty:netty-handler from 4.2.13.Final to 4.2.15.Final in /test/java/spark (#9875)
build(deps): bump io.netty:netty-handler in /test/java/spark

Bumps [io.netty:netty-handler](https://github.com/netty/netty) from 4.2.13.Final to 4.2.15.Final.
- [Release notes](https://github.com/netty/netty/releases)
- [Commits](https://github.com/netty/netty/compare/netty-4.2.13.Final...netty-4.2.15.Final)

---
updated-dependencies:
- dependency-name: io.netty:netty-handler
  dependency-version: 4.2.15.Final
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 22:08:07 -07:00
7y-9andGitHub 7bbd28634a fix(util): return full uint64 randomness (#9864)
Problem: RandomUint64 generated eight random bytes but returned int32, truncating the value before mount file and directory handles converted it to uint64. This reduced handle entropy to 32 bits and produced sign-extended handle values.\n\nRoot cause: the helper cast BytesToUint64 to int32 and exposed int32 as its return type.\n\nFix: make RandomUint64 return uint64 and return the full BytesToUint64 result.\n\nReproduction: go test ./weed/util -run TestRandomUint64ReturnsUint64 -count=1 failed before the fix because RandomUint64() had kind int32.\n\nValidation: gofmt -w weed/util/bytes.go weed/util/bytes_test.go; git diff --check; go test ./weed/util -run TestRandomUint64ReturnsUint64 -count=1; go test ./weed/util -count=1; go test ./weed/mount -count=1; git diff --cached --check
2026-06-08 22:07:24 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4a96f624d9 build(deps): bump gocloud.dev/pubsub/rabbitpubsub from 0.45.0 to 0.46.0 (#9870)
Bumps [gocloud.dev/pubsub/rabbitpubsub](https://github.com/google/go-cloud) from 0.45.0 to 0.46.0.
- [Release notes](https://github.com/google/go-cloud/releases)
- [Commits](https://github.com/google/go-cloud/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: gocloud.dev/pubsub/rabbitpubsub
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 22:06:54 -07:00
Chris LuandGitHub 3fadbef3eb feat(admin): export full cluster volume list as JSON (#9876)
Adds an "Export All (JSON)" button on the Cluster Volumes page that pulls
the whole cluster's volume list from the master in one call, a superset of
volume.list. Beyond the table columns it carries garbage and fullness
ratios, modified time, compact revision, remote tiering keys, per-disk
capacity counts, EC shard sizes with file/delete counts, and a cluster-wide
duplicate-volume-id scan. Honors the active collection filter. The existing
per-page CSV export stays as "Export Page".
2026-06-08 15:01:02 -07:00
Chris Lu ed470dccb1 mini: grow volumes one at a time
Mini auto-sizes a few large volume slots, but the master pre-grows 7
volumes per new collection. Under a filer group each S3 bucket is its
own collection, so the first buckets claimed every slot and later
writes failed to assign a volume. Cap mini's volume_growth copy counts
to 1.
2026-06-08 14:51:40 -07:00
Chris LuandGitHub d67fc48fbd fix(filer.sync): guard batched events against nil EventNotification (#9877)
* fix(filer.sync): guard batched events against nil EventNotification

The server folds a backlog into one response: the first event in the
top-level fields, the rest in resp.Events, and the pipelined sender can
drain an idle heartbeat (nil EventNotification) into that tail. Only the
envelope got the freshness-signal guard, so a batched heartbeat reached
AddSyncJob and nil-derefed in IsEmpty while replaying a backlog buffered
during a peer outage.

Route every event, envelope and batched, through one handler that sends
freshness signals (nil heartbeat, empty marker) to OnIdleHeartbeat.

* fix(filer): guard MetaAggregator batched events against nil EventNotification

The peer subscription's envelope is nil-guarded but its batched tail was
not. The aggregator doesn't enable idle heartbeats today, so the server
can't fold a nil EventNotification into the batch yet, but make the two
loops consistent so it can't nil-deref if that changes.
2026-06-08 13:56:16 -07:00
Chris LuandGitHub 4c050ad76b Don't mangle filer paths with the OS separator on Windows (#9878)
fix: don't mangle filer paths with the OS separator on Windows

filepath.Dir/Join use the platform separator, so on Windows they rewrite
a forward-slash filer path like /buckets/x into \buckets\x. The mangled
value then goes into a filer RPC and operates on the wrong key, so the
op silently targets nothing.

The admin file browser hit this in New Folder (the entry landed under
\buckets\my-bucket and never showed up under /buckets/my-bucket), and
the same way in delete, view and properties. MQ topic retention and
consumer-offset listing, and the SFTP home dir plus create-permission
parent lookup, had the same bug.

Switch all of these to the path package, which always uses "/".
2026-06-08 13:56:02 -07:00
Chris LuandGitHub 8cc10460b4 fix(remote): correct content and permissions when syncing/caching remote objects (#9879)
* fix(remote): reject short reads when caching remote objects

A short read from the remote (stale listing size, truncated or flaky
response) was silently zero-padded: the S3 and Azure clients pre-size
the buffer and discard the downloaded byte count, and the chunk is
recorded with the requested size. The cached file then matched the
expected size but its tail was NULL, and the entry was marked cached
so it never re-fetched.

Check the byte count against the requested size in both clients, and
add a backend-agnostic guard in FetchAndWriteNeedle. The cache now
fails loudly and the entry stays remote-only for a later retry.

* fix(remote): match S3 default modes when syncing remote metadata

Remote object listings carry no POSIX mode, so synced entries were
created with a hardcoded 0644. Against a SeaweedFS remote, whose S3
layer writes objects as 0660 and auto-creates directories as 0771
(0660|0111), the mounted copy ended up 0644/0755 and the permissions
visibly diverged from the source.

Default to the S3 modes instead (files 0660, directories 0771). The
filer derives parent-dir modes from the child as fileMode|0111, so
fixing the file default also brings the directories into line.

Directory mtimes still reflect sync time: S3 listings don't enumerate
directories, so the remote's directory timestamps aren't available.
2026-06-08 13:55:53 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6475d22774 build(deps): bump github.com/apache/cassandra-gocql-driver/v2 from 2.1.0 to 2.1.1 (#9869)
build(deps): bump github.com/apache/cassandra-gocql-driver/v2

Bumps [github.com/apache/cassandra-gocql-driver/v2](https://github.com/apache/cassandra-gocql-driver) from 2.1.0 to 2.1.1.
- [Release notes](https://github.com/apache/cassandra-gocql-driver/releases)
- [Changelog](https://github.com/apache/cassandra-gocql-driver/blob/trunk/CHANGELOG.md)
- [Commits](https://github.com/apache/cassandra-gocql-driver/compare/v2.1.0...v2.1.1)

---
updated-dependencies:
- dependency-name: github.com/apache/cassandra-gocql-driver/v2
  dependency-version: 2.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 12:20:46 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
feb3adfc21 build(deps): bump github.com/aws/aws-sdk-go-v2 from 1.41.10 to 1.41.12 (#9871)
Bumps [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) from 1.41.10 to 1.41.12.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.41.10...v1.41.12)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2
  dependency-version: 1.41.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 12:20:11 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
70f18fa4d0 build(deps): bump golang.org/x/sync from 0.20.0 to 0.21.0 (#9868)
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.20.0 to 0.21.0.
- [Commits](https://github.com/golang/sync/compare/v0.20.0...v0.21.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 12:19:10 -07:00
Chris Lu 5a4ff2a122 fix(mq): don't cache topic non-existence on transient filer errors
TopicExists and getTopicConfFromCache negative-cached a topic for the full
30s TTL whenever a filer lookup failed for any reason, including timeouts.
A topic created earlier then looked gone until the TTL expired, and the
metadata auto-create path couldn't heal it (CreateTopic rejects an
already-persisted conf), so producers saw UNKNOWN_TOPIC_OR_PARTITION.

Only negative-cache on a definitive ErrNotFound; let transient errors fall
through and retry against the filer.
2026-06-08 12:04:48 -07:00
b408705f5b fix(s3api): accept HTTP-date conditionals (#9863)
* fix(s3api): accept HTTP-date conditionals

Problem: Object conditional headers rejected valid HTTP-date values in RFC850 or ANSIC format for If-Modified-Since and If-Unmodified-Since.

Root cause: parseConditionalHeaders used time.Parse(time.RFC1123), accepting only one HTTP-date representation instead of the standard formats accepted by net/http.ParseTime.

Fix: Parse conditional date headers with http.ParseTime so RFC1123, RFC850, and ANSIC HTTP-date forms are accepted.

Reproduction: go test ./weed/s3api -run TestParseConditionalHeadersAcceptsHTTPDateFormats -count=1 failed before the fix with ErrInvalidRequest for RFC850 and ANSIC date values.

Validation: env GOCACHE=/private/tmp/seaweedfs-go-cache go test ./weed/s3api -run TestParseConditionalHeadersAcceptsHTTPDateFormats -count=1; env GOCACHE=/private/tmp/seaweedfs-go-cache go test ./weed/s3api -count=1; git diff --check; git diff --cached --check

* fix(s3api): accept HTTP-date copy-source conditionals

Mirror the put-path http.ParseTime switch onto the copy-source If-Modified-Since / If-Unmodified-Since headers, which still rejected valid RFC850 and ANSIC dates.

* fix(s3api): keep RFC1123 UTC-zone dates working alongside http.ParseTime

http.ParseTime rejects the "UTC" zone that Go clients emit via t.UTC().Format(time.RFC1123), which the old RFC1123 parser accepted. Add a parseHTTPDate helper that tries http.ParseTime first and falls back to RFC1123, so the put and copy-source conditional date headers accept the union of HTTP-date formats plus the UTC zone.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-06-08 01:12:07 -07:00
Chris Lu 78da9572ae 4.32 2026-06-07 23:37:57 -07:00
1b5f1c1f3b feat(filer.backup): -initialSnapshot re-seeds a reinitialized destination (#9828)
* feat(filer.backup): add -resetCheckpoint to force a fresh sync

filer.backup resumes from a per-sink offset persisted in the source filer's KV.
There was no first-class way to discard that checkpoint and re-run from the
beginning short of guessing a large -timeAgo, which also skips -initialSnapshot.

Add -resetCheckpoint: before reading the offset, write 0 for this sink so
getOffset returns 0, isFreshSync stays true, and -initialSnapshot re-runs a full
walk. Effective only when -timeAgo is 0.

The flag is cleared after the first successful reset: runFilerBackup retries
doFilerBackup forever on error, so leaving it set would re-zero the checkpoint
on every retry and never make forward progress after a transient failure. Later
retries resume from the persisted checkpoint instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(filer.backup): keep fresh-sync intent when offset read fails after reset

After -resetCheckpoint writes offset 0, a transient getOffset read-back error
flipped isFreshSync to false, which skipped the -initialSnapshot walk the reset
explicitly requested. Track that the reset happened this iteration and, on a
getOffset error, preserve isFreshSync=true in that case (the non-reset path
keeps treating a read error as "not fresh" to avoid re-walking on transients).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(filer.backup): skip offset read-back on reset instead of tracking a flag

Replace the didReset bool by branching: on -resetCheckpoint, clear the offset and
start fresh without reading it back (we just wrote 0, so the state is known);
otherwise read the offset as before. This drops the redundant getOffset RPC after
a reset and removes the read-back error case entirely, so no separate flag is
needed to preserve isFreshSync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* filer.backup: -initialSnapshot re-seeds on every start; drop -resetCheckpoint

-initialSnapshot now walks the live tree whenever -timeAgo is 0, seeds the
destination, and overwrites the saved checkpoint, rather than running only on a
fresh sync. That re-seeds a reinitialized destination on its own, so the
separate -resetCheckpoint flag is gone.

The walk runs once per process: the in-memory flag is cleared after the
watermark is persisted, so the retry loop resumes from the persisted checkpoint
instead of re-walking on every transient error. A process restart re-walks, so
remove the flag once the backup is caught up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-06-07 23:35:53 -07:00
Chris LuandGitHub 9053d61504 rust release: fix large-disk/normal binary overwrite + publish md5 checksums (#9862)
* rust release: publish .md5 checksums alongside weed-volume binaries

The versioned rust volume release built and uploaded the tarballs/zips but
no checksum sidecars (the Go releases get .md5 automatically via
go-release-action; this workflow uses softprops/action-gh-release directly).
Generate an .md5 next to each asset (md5sum on linux/windows-bash, md5 -r on
macOS) and include them in the release/artifact uploads, so downloaders
(e.g. seaweed-up, which verifies md5 before installing weed-volume) can
check integrity. Covers linux amd64+arm64, darwin amd64+arm64, windows amd64.

* rust release: build large-disk and normal into separate target dirs

Both cargo builds wrote to target/<triple>/release/weed-volume, so the
second (normal, --no-default-features) overwrote the first, and the Package
step then copied that same binary into BOTH tarballs — the large-disk asset
actually shipped the normal binary. Build each variant into its own
--target-dir (target/large-disk and target/normal, both under target/ so the
existing cache still covers them) and copy each tarball's binary from its
own dir.
2026-06-07 23:20:33 -07:00
Chris LuandGitHub 8a4fdf06c0 admin/maintenance: reload in-flight tasks on startup instead of discarding them (#9857)
* admin/maintenance: reload in-flight tasks on startup instead of discarding

LoadTasksFromPersistence deleted all persisted task files on startup and
relied on the scanner to re-detect, so saved task state was never consumed
— the persistence was effectively write-only. Reload non-terminal tasks
(pending/assigned/in_progress) into the queue, resetting in-flight ones to
pending since their worker is gone after a restart (maintenance tasks are
idempotent). Terminal task files are dropped; the scanner still backfills
anything not persisted.

* address review: nil-guard reloaded tasks and SyncTask to ActiveTopology

- skip nil entries from LoadAllTaskStates (corrupted state)
- re-sync restored tasks with MaintenanceIntegration so ActiveTopology
  (in-memory, empty on startup) knows about them; otherwise GetNextTask's
  AssignTask rejects them as unknown and they never get assigned
2026-06-07 22:45:38 -07:00
Chris LuandGitHub 7c542128c7 vacuum: compact a read-only volume when an explicit volumeId is given (#9861)
* vacuum: compact a read-only volume when an explicit volumeId is given

The on-demand path no longer skips read-only volumes, so an operator can
reclaim a benignly read-only (full/oversized) volume without marking it
writable first. The background scan and all-volumes sweep still skip
read-only, where the flag usually signals an unhealthy disk.

* vacuum: copy locationList under lock for on-demand vacuum

The volumeId>0 path passed the live vid2location entry into the async
vacuum, where heartbeat-driven Register/UnRegister can mutate the slice
concurrently. Snapshot it under accessLock, matching the sweep path.
2026-06-07 22:42:51 -07:00
Chris LuandGitHub a549580e65 ec.balance: verify shard landed on destination before deleting the source (#9858)
* ec.balance: verify shard(s) landed on the destination before deleting source

The EC balance task copied/mounted a shard to the destination and then
immediately unmounted+deleted it from the source, reporting success as soon
as the RPCs returned. A copy/mount can return OK while the shard isn't
actually registered/loadable on the destination, so deleting the source
then loses the shard (and the scanner re-issues the same move every cycle).

Add a verification step (VolumeEcShardsInfo via VerifyShardsAcrossServers,
the same check the EC encode task uses before deleting originals): if the
destination doesn't report every moved shard, fail the task and keep the
source so the move is retried instead of losing data.

* address review: use comma-ok when reading destination shard inventory
2026-06-07 21:31:53 -07:00
7y-9andGitHub e6ab9e7b09 fix(s3api): reject zero default retention years (#9860)
Problem: Default object-lock retention accepted an explicitly provided Years value of zero, even though a default retention period must be positive when present.

Root cause: validateDefaultRetention rejected zero Days but only rejected negative Years, leaving YearsSet with Years=0 as a successful validation path.

Fix: Treat an explicitly provided zero Years value as ErrInvalidRetentionPeriod, matching the existing Days validation.

Reproduction: go test ./weed/s3api -run TestValidateDefaultRetention -count=1 failed before the fix because the Zero years case returned nil.

Validation: go test ./weed/s3api -run TestValidateDefaultRetention -count=1; go test ./weed/s3api -count=1; git diff --check; git diff --cached --check
2026-06-07 20:53:45 -07:00
Chris LuandGitHub f9d3105e80 ec placement: spread EC shards evenly across machines, not onto the lowest-id one (#9855)
* ec placement: steer shards to less-loaded machines, not the lowest id

EC encode places every volume against one shared topology snapshot (it reserves the
shards it assigns so later volumes see reduced capacity), but node selection ranked
only by this volume's shard count and broke ties by sorted id. So the lowest-id
machine won the first shard of every volume and accumulated far more total shards
than the rest -- on a 6-machine cluster the first machines drifted to ~1.5x.

Rank eligible nodes by the machine's shards of this volume, then the machine's free
capacity, then the node's shards of this volume, then the node's free capacity. Free
capacity reflects the load already placed, so ties steer toward the least-loaded
machine instead of the lowest id, keeping total EC shards even across machines.

* test: ec.balance converges to even per-machine load from a skew

Starts machine 10.0.0.1 at 4 shards/volume and the rest at 2, then runs repeated worker-style capped passes; asserts convergence to an even per-machine total (reaches exactly even in ~13 rounds).

* reduce comments on the placement fix

Trim narration to the non-obvious why.

* test: assert convergence and count zero-shard machines

Seed the per-machine map with every host so a fully drained machine still registers, and fail explicitly if balance doesn't converge before the round cap.
2026-06-07 20:45:17 -07:00
Chris LuandGitHub 89cbb1c558 admin: default -dataDir to "." so maintenance task state persists across restarts (#9856)
admin: default -dataDir to "." so maintenance task state persists

Previously -dataDir defaulted to empty, so the admin ran maintenance in
memory only: task state was never saved and maintenance tasks (notably EC
balance/rebuild) were re-issued every scan cycle without converging,
churning EC shards (moves landed shards without their .ecx index, leaving
EC volumes unloadable/missing shards).

Default -dataDir to "." (the process working directory, which under the
standard systemd unit is the admin's data dir) so state persists out of
the box.
2026-06-07 20:45:03 -07:00
Chris LuandGitHub f0d2a0d417 Treat co-located volume servers as one fault domain when balancing and allocating (#9854)
* admin/topology: carry the volume server address on DiskInfo

The planning DiskInfo exposed only the node id, which can be an opaque label rather than ip:port. Record the address too so callers can resolve the physical machine a disk sits on.

* ec.balance: spread a volume's shards across machines, not just nodes

Volume servers sharing a host are one fault domain, but the within-rack spread treated them as independent nodes, so one box could end up holding more shards of a volume than EC can afford to lose. Add a machine (host) tier between rack and node: the within-rack pass spreads each volume across machines, and the global load phase no longer re-concentrates a volume onto a machine it already sits on. Host defaults to the node id, so clusters with one server per host are unchanged.

* ec placement: prefer machines holding fewer of a volume's shards

EC allocation and repair picked the least-loaded node in a rack with no regard for which physical machine it sits on, so a volume's shards could pile onto several servers of one box. Rank candidate nodes by their machine's shard count first, then the node's own. The machine is derived from the volume server address carried on DiskInfo, falling back to the node id, matching how the balancer resolves it.

* volume.balance: don't move a replica onto a machine already holding one

isGoodMove only rejected a move onto the same data node, so two replicas could land on two volume servers of one box and a single machine failure would lose both. Reject a target whose host already holds another replica of the volume. Best-effort: balancing simply skips and tries the next target.

* volume allocation: spread same-rack replicas across machines

PickNodesByWeight filled the same-rack replica picks by weight alone, so replicas could co-locate on one box. Prefer candidates on not-yet-used hosts, falling back when too few distinct machines exist. Data-center and rack tiers have no host, so their ordering is unchanged.

* ec.balance: harden machine spread against re-concentration and capped machines

Two cases where the machine-aware spread could still leave a volume badly placed:

- The global load phase could move a shard of a volume onto a machine that
  already held it, raising that machine's count and undoing the within-rack
  spread (a 4/4/3/3 layout could become 3/5/3/3, past parity for 10+4). Limit
  the load-only fallback to same-machine moves, which leave a machine's count
  unchanged; cross-machine concentration is no longer allowed for load alone.

- The within-rack spread chose a destination machine by free slots alone, so if
  that machine's only nodes were already at the SameRackCount cap it skipped the
  move instead of trying another machine. Require a machine to have a node that
  can actually take the shard before selecting it.

* reduce comments across the machine-affinity change

Trim narration down to the non-obvious why; one terse line where a block was overkill.

* ec.balance: gate machine spread on fault-tolerance feasibility

Spreading a volume evenly across machines only helps when there are enough that
each can stay within EC's parity tolerance (numMachines >= ceil(total/parity)).
With fewer -- or wildly unequal -- machines it can't make a machine loss
survivable anyway, and forcing it fights capacity: e.g. a cluster of 12 volume
servers on one host and 2 on another would have half of every volume crammed onto
the 2-server box. So spread across machines only when it's achievable; otherwise
fall back to per-node spread and let capacity/global balancing decide.

The global load phase applies the same test: it protects a volume's machine spread
(no cross-machine move that raises a machine's count past the source's) only where
that spread is achievable, so heterogeneous clusters still level by fullness.

* ec.balance worker: group servers by host when planning

The worker built its planner topology without recording each server's host, so
automated ec.balance treated ports on one machine as independent nodes and could
concentrate a volume's shards on one physical box. Set the host from the volume
server address, matching the shell path.

* volume.balance worker: don't move a replica onto a machine holding one

The worker compared only node ids, and the replica map dropped the server address,
so it could move replicas onto different ports of one machine. Carry the host on
ReplicaLocation (from the server address) and reject a target whose host already
holds another replica of the volume. Best-effort, matching the shell.

* ec.balance: judge machine-spread feasibility by the rack's shards

The within-rack and global feasibility checks compared the whole volume's shard
count against a rack's machine count, so a rack holding only part of a volume after
cross-rack spreading -- e.g. 7 of a 10+4 volume across 2 machines -- was wrongly
judged infeasible and fell back to node spread, which could pile 6 shards onto one
host, past parity. Gate on the rack's own shard count of the volume instead.

* ec.balance: spread a volume's shards across machines by combined count

EC recovers from any loss within parity regardless of shard type, so what bounds a
machine's exposure is its total shards of the volume, not data and parity
separately. Spreading the two independently let each type's remainder land on the
same machine -- ceil(d/M)+ceil(p/M) can exceed ceil(total/M), e.g. a 5/3 split where
4/4 was achievable, past parity. Balance the combined count in one pass; disk-level
data/parity anti-affinity stays in pickBestDiskOnNode.

* ec.balance: don't let the imbalance threshold skip an over-parity machine

The within-rack spread gated on relative skew ((max-min)/avg > threshold), so a
worker threshold of 0.5 skipped an exactly-50%-skewed layout like 5/4/3 for a 10+4
volume, leaving 5 shards -- past parity -- on one machine. The even cap
(ceil(shards/groups)) is the real bound and the move loop already sheds only what
exceeds it, so drop the threshold gate from the within-rack phase (machine and node):
a balanced rack stays a no-op while any over-cap machine is always fixed.

* ec.balance: keep the imbalance threshold for the node fallback

Dropping the threshold from the whole within-rack phase made the node fallback too
eager: it runs only when machine fault tolerance is unachievable, so it is cosmetic
load distribution that should defer to the global utilization phase. Without the
gate it would, for a one-server-per-host 6/4 split at threshold 0.5, schedule a count
move that worsens utilization balance. Restore the threshold there; machine spreading
keeps bypassing it, since that bound is durability, not cosmetic skew.
2026-06-07 14:14:45 -07:00
7y-9GitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Chris Lugemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
25f36cd13d fix(s3api): require space in v2 auth prefix (#9852)
* fix(s3api): require space in v2 auth prefix

Problem: Signature V2 Authorization headers with a malformed algorithm token such as AWSX... are accepted as if they were AWS ... headers.

Root cause: validateV2AuthHeader checks HasPrefix("AWS") but then slices past an assumed trailing space, so an extra character after AWS is skipped and the rest is parsed as credentials.

Fix: Require the Authorization header to start with the exact AWS plus space prefix before parsing fields.

Reproduction: go test ./weed/s3api -run 'TestValidateV2AuthHeader/algorithm_prefix_without_space|TestDoesSignV2Match/malformed_auth_-_no_space_after_AWS' -count=1 fails before the fix because AWSXAKIA... is accepted.

Validation: go test ./weed/s3api -run 'TestValidateV2AuthHeader/algorithm_prefix_without_space|TestDoesSignV2Match/malformed_auth_-_no_space_after_AWS' -count=1; go test ./weed/s3api -count=1; git diff --check; git diff --cached --check

* Update weed/s3api/auth_signature_v2.go

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-06-07 11:52:09 -07:00
7y-9andGitHub 99bb5db1e3 fix(needle): use discovered file content type (#9851)
Problem: Multipart uploads where the first part was a form field and a later part contained the file used the first part's Content-Type for the file metadata.

Root cause: After finding a later part with a filename, parseUpload copied data and MD5 from part2 but read Content-Type from the original part variable.

Fix: Read Content-Type from the discovered file part.

Reproduction: go test ./weed/storage/needle -run TestParseUploadUsesDiscoveredFilePartContentType -count=1 failed before the fix because the parsed MIME type was text/plain instead of application/x-seaweed-test.

Validation: go test ./weed/storage/needle -run TestParseUploadUsesDiscoveredFilePartContentType -count=1; go test ./weed/storage/needle -count=1; git diff --check; git diff --cached --check
2026-06-07 11:50:34 -07:00
Chris LuandGitHub 058569c77b operation: index VidCache by map instead of slice (#9853)
VidCache.cache was a []VidInfo indexed directly by volume id, so caching
one volume with a large id grew the backing array to that many entries
(each 48 bytes), allocating a zeroed slot for every unused id below it. A
single id of 32M cost ~1.5GB resident, plus geometric realloc churn as the
append loop doubled the array.

Use map[uint32]VidInfo so memory scales with the number of volumes actually
cached rather than the largest id seen. Parse ids with ParseUint(.,32) so
values outside the uint32 volume-id range are rejected instead of silently
wrapping into a key.
2026-06-07 11:46:57 -07:00
Chris LuandGitHub 755af4adf4 s3: actually bind outbound connections when -ip.bind is set (#9849)
* s3: set outbound bind IP before the first filer dial

Standalone weed s3 dialed the filer for GetFilerConfiguration before
SetOutboundLocalIP ran, so that gRPC conn was created with the stock
dialer and no source address. gRPC caches conns by address and reuses
the original dialer on reconnect, so the s3->filer connection kept
leaving from the OS-chosen source for the life of the process even
after the bind IP was set a moment later.

* grpc: install the outbound-bind dialer unconditionally

The dialer was installed only when OutboundLocalAddr was already set at
GrpcDial time, baking the source-address decision into the cached conn,
so a conn dialed before the bind IP was configured never bound.

Install the context dialer always and decide per dial: bind through
OutboundDialContext once a source is set, otherwise fall back to the
stock net.Dialer so default deployments keep gRPC's dial timeout and
keepalive behavior. The bind now applies on the next reconnect
regardless of ordering, matching the HTTP transport's unconditional
DialContext.
2026-06-07 10:20:58 -07:00
Chris LuandGitHub 0e9fc6c5ba worker: drop ec.balance from the default admin script (#9848)
The dedicated ec_balance task worker handles EC shard balancing now,
so the periodic admin script no longer needs to run it.
2026-06-07 00:55:11 -07:00
Chris LuandGitHub b2127c86f4 admin: show S3 servers under Cluster (#9847)
* s3: register data center with master on startup

* admin: show S3 servers under Cluster

* admin: add S3 servers to the dashboard
2026-06-07 00:32:20 -07:00
Chris LuandGitHub 01637410e2 test(s3): address review feedback on the versioning suite (#9846)
- Different-users bucket test: use getNewBucketName() so the bucket carries the
  tracked prefix and run id and gets swept if the test leaks, instead of an
  untracked name.
- Makefile: clarify that '.' matches the opt-in stress tests but they self-skip
  without ENABLE_STRESS_TESTS, so they don't execute in the default run.
- Versioned list test: guard the Object.Size dereference with require.NotNil.
2026-06-06 20:50:09 -07:00
Chris LuandGitHub d321f9efb4 s3: collapse suspended-versioning deletes onto one null marker (#9845)
A suspended-versioning DELETE was recorded with createDeleteMarker, which mints a
fresh real version id each time, so repeated suspended deletes piled up delete
markers instead of overwriting a single null marker as S3 specifies. Record the
suspended delete as a 'null' marker with a fixed file name (v_null) and point the
latest-version pointer at it explicitly; putSuspendedVersioningObject's existing
null-version cleanup removes it on the next suspended PUT, so the object undeletes
cleanly and at most one null marker exists. Enabled-versioning deletes are
unchanged (still distinct historical markers).

Update TestSuspendedVersioningDeleteBehavior to the AWS-correct counts: one null
marker after a suspended delete, and the null marker plus one real marker after a
re-enabled delete.
2026-06-06 20:49:38 -07:00
Chris LuandGitHub fa9bf58c86 test(s3): make the whole versioning suite pass and gate it in CI (#9844)
* test(s3): correct bucket-recreate expectations and cover the different-owner case

A same-owner CreateBucket on an existing bucket returns BucketAlreadyOwnedByYou
(idempotent recreate); the suite expected BucketAlreadyExists, which only applies
when the name is owned by someone else. Fix the same-owner cases (plain and
Object-Lock) and implement the previously-skipped different-owner test, which now
exercises the BucketAlreadyExists path via a second identity.

* test(s3): assert the deletion invariant for suspended-versioning delete

A suspended-versioning DELETE removes the null version and records a delete marker
so the object reads as deleted; the test expected no marker, which would let an
older version resurface. Assert that a marker is recorded (and read DeleteMarker
through aws.ToBool) rather than an exact count, so it holds whether or not the
suspended-marker id/dedup is later collapsed to AWS's single null marker.

* test(s3): run the whole versioning suite by default

TEST_PATTERN was TestVersioning, which left bucket-creation, suspended-delete and
directory/version-listing tests ungated. Default to '.' so every test runs; opt-in
stress tests self-skip without ENABLE_STRESS_TESTS and keep their own targets.
2026-06-06 18:38:28 -07:00
Chris LuandGitHub 795349d796 test(s3): deref Object.Size in versioned list assertion (#9843)
TestVersionedObjectListBehavior compared int64 against listedObject.Size,
which is *int64, so the assertion always failed on a type mismatch once
reached. Dereference it (and in the log line).
2026-06-06 18:02:36 -07:00
Chris LuandGitHub 309cb32416 s3: list directory key objects in versioned bucket version listings (#9842)
ListObjectVersions gated explicit directory objects on Mime ==
FolderMimeType, but an SDK PutObject of "dir/" carries a default
Content-Type (e.g. application/octet-stream), so those directory keys
were dropped from the version listing while ListObjectsV2 - which keys
off IsDirectoryKeyObject (any non-empty mime) - still showed them. Use
the same IsDirectoryKeyObject check so the two listings agree.

The directory test's storage-class assertion compared an ObjectStorageClass
constant against ObjectVersion.StorageClass (ObjectVersionStorageClass);
the values matched but the SDK enum types did not, so it only surfaced
once the directories started appearing. Use the matching constant.
2026-06-06 18:02:33 -07:00
Chris LuandGitHub 6c1fd3aeab s3: rescan .versions when the cached latest pointer is missing on a list (#9841)
* s3: rescan .versions when the cached latest pointer is missing on a list

ListObjectsV2 resolves each versioned object's current version from the
latest-version pointer cached on the .versions directory entry. When that
pointer is absent on the filer serving the list, the object was dropped
from the listing. Fall back to a read-only rescan of .versions/ to pick
the newest version - the version files are present locally even when the
cached pointer is not - so the object still lists. This mirrors the read
path's recoverLatestVersionWithoutPointer; the scan loop is shared.

Read-only by design: a list can touch many objects, so it does not persist
a pointer.

* s3: copy scanned Extended before stamping the version id
2026-06-06 18:02:30 -07:00
Chris LuandGitHub 9ede92a7cc filer: replicate RECOMPUTE_LATEST pointer updates to peers (#9840)
applyRecomputeLatest wrote the .versions latest-version pointer and the
demoted prior version's stamp through UpdateEntry without a following
NotifyUpdateEvent, so neither change entered the metadata log. Across
filers the pointer then lived only on whichever filer ran the mutation,
and ListObjects served by any other filer dropped those objects from a
versioned bucket. Emit the events the way PATCH_EXTENDED already does,
keeping a pre-update image for the notification diff.
2026-06-06 18:02:28 -07:00
Chris LuandGitHub 6e16994615 s3: make lifecycle TTL fast path per-bucket opt-in (#9825)
Stamping an Expiration.Days rule as a volume TTL at write time bakes an
irreversible TTL into the object: removing or lengthening the rule later
can't un-expire it, unlike worker-driven expiration. The metadata-only
delete it enables also skips per-chunk DeleteFile, so dead bytes linger in
a not-yet-expired TTL volume with no deleted-byte accounting until the
whole volume ages out.

Gate the resolver on a per-bucket flag, off by default; toggle with the
s3.bucket.lifecycle.fastpath shell command. Default writes take the worker
path: real deletes that honor current policy and let vacuum reclaim space.
2026-06-06 11:20:15 -07:00
3688be82f5 fix(helm): deduplicate all-in-one extra environment variables (#9837)
* fix(helm): deduplicate all-in-one extra environment variables

The all-in-one Deployment looped global.seaweedfs.extraEnvironmentVars and
allInOne.extraEnvironmentVars in two separate ranges, so any key present in
both maps was emitted as two env entries with conflicting values. It also
computed a merged map for the cluster-default lookup but never used it for
the env loop.

Use the existing seaweedfs.mergeExtraEnvironmentVars helper (as the filer,
master and s3 templates already do) so a key set in both maps renders once
with the component value taking precedence, and add a chart-CI render
assertion covering it.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>

* ci(helm): drop checkmark glyphs from chart test output

---------

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-06-05 15:31:18 -07:00
ae4ad6859d fix(helm): suspend bucket versioning for YAML bool false (#9836)
* fix(helm): suspend bucket versioning for YAML bool false

createBuckets[].versioning accepts both a YAML bool and a string. The
string branch maps "false"/"disable"/"suspended" to Suspended, but the
bool branch only handled true (Enabled) and left false as a silent no-op.
The same logical value therefore behaved differently depending on its
YAML type: `versioning: false` did nothing while `versioning: "false"`
suspended the bucket.

Mirror the string behaviour in the bool branch so bool false suspends the
bucket, and add a chart-CI render assertion covering it.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>

* ci(helm): trim versioning regression-test comment

* chart: document bool false for createBuckets versioning

---------

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-06-05 15:18:10 -07:00
Chris LuandGitHub be7f417a03 ip.bind: bind outbound connections to the configured address (#9834)
* ip.bind: bind outbound connections to the configured address

-ip.bind only governed listeners; outbound gRPC and HTTP connections let
the OS pick the source IP, which may not even be able to reach the
target. Mirror the bind address into a process-global source address and
apply it to outbound TCP dials: the gRPC context dialer, the per-client
HTTP transports, and the default transport. Loopback targets and unix
sockets keep the OS-chosen source so same-host traffic still works.

* ip.bind: first-write-wins source IP, skip on address-family mismatch

Make SetOutboundLocalIP first-write-wins so a `weed server` component's own
bind setting (run in its goroutine) can't clobber the process-wide source
address the top-level -ip.bind already established for the other components.

Skip source binding when the target is a literal IP of a different family
than the bind address, since forcing a mismatched source fails the dial.
2026-06-05 12:44:21 -07:00
7f15a9fed4 fix(s3api): standardize ETag calculation in copy handlers (#9829)
* fix(s3api): standardize ETag calculation across S3 API handlers

* s3: make copyEntryETag nil-safe

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-06-05 12:41:18 -07:00
Chris LuandGitHub 6bd0091c72 master: grow rack-spanning volumes once per DC, capped at copy_N (#9835)
* master: grow rack-spanning volumes once per DC, capped at copy_N

The periodic rack-aware growth scan grew once per rack. For rack-spanning
replication (DiffRackCount > 0) a single logical volume already covers every
rack the placement needs, so a crowded volume made every rack report
should-grow and the scan created racks×step too many volumes: with "010"
across two racks that is 2 racks x step 2 = 4 logical (8 physical) volumes.

Plan one DC-wide grow for rack-spanning replication, and cap the per-event
step at master.volume_growth.copy_N so lowering it reduces periodic growth.

* master: distribute lastGrowCount evenly across uneven DCs

The non-rack-spanning grow divisor used the current DC's rack count, so DCs
with different rack counts each over-grew. Sum every rack up front and divide
lastGrowCount by that global count instead.
2026-06-05 12:39:59 -07:00
Chris LuandGitHub ab7be7867d security: hot-reload JWT signing keys on SIGHUP (#9826)
* security: reload JWT signing keys on SIGHUP

Signing keys were read once in the server constructors and never
refreshed. After a key rotation (Secret update, divergent reads) the
in-memory key stayed stale and every request kept failing "wrong jwt"
until the affected process was restarted.

Add Guard.UpdateSigningKeys and call it from the master, volume and
filer reload paths and the s3 reload hook, next to the existing
whitelist refresh. Make the global chunk-read JWT cache reloadable via
an atomic swap, and register the master's Reload with grace.OnReload --
it was never wired, so the master ignored SIGHUP entirely.

Mirror the same refresh in the Rust volume server's SIGHUP handler.

* security: swap signing keys behind an atomic pointer

Addresses review feedback on the in-place key swap: SigningKey is a
[]byte, so reassigning the Guard fields while a request handler reads
them is a data race that can tear the multi-word slice header and read
out of bounds.

Hold the four signing-key fields in an immutable signingConfig snapshot
behind atomic.Pointer; UpdateSigningKeys swaps the whole pointer, so a
reader sees either the old keys or the new ones. Reads go through new
SigningKey/ExpiresAfterSec/ReadSigningKey/ReadExpiresAfterSec accessors.

The Rust guard is already safe: every read and the SIGHUP write go
through the shared RwLock<Guard>.

* security: fold whitelist + auth state into the atomic snapshot

Review follow-up. UpdateSigningKeys still wrote isWriteActive while the
request path read it (and the whitelist maps) unsynchronized, so a SIGHUP
under load could expose an inconsistent mix of activation bits and
whitelist contents.

Move all hot-reloadable Guard state -- keys, expirations, whitelist, and
the activation flags -- into a single immutable guardState swapped behind
one atomic.Pointer. The Update* methods take a small mutex to serialize
the read-modify-write; readers stay lock-free. The concurrency test now
also rotates the whitelist and probes IsWhiteListed under -race.

Also read each signing key once per branch in the volume/filer JWT auth
checks, so a reload landing mid-check can't take the allow-fast-path
after auth was enabled or verify against a different key than the branch
saw.
2026-06-04 22:26:08 -07:00
Chris LuandGitHub 0d72023fac fix(master): advance maxVolumeId when registering EC shards (#9827)
* fix(master): advance maxVolumeId when registering EC shards

After EC encoding the original normal volume is deleted, so a
high-numbered volume can exist only as EC shards. Only regular volumes
advanced maxVolumeId (Disk.doAddOrUpdateVolume), so a master that
rebuilt its state from heartbeats (raft state not resumed) undercounted
the max and NextVolumeId could re-issue an id that EC shards still
occupy. A new volume then gets created on top of the EC volume id; new
writes land on it, but reads route to the old EC shards whose .ecx never
held the new needle, returning 404 and corrupting that object.

Advance maxVolumeId when EC shards are registered, mirroring the
regular-volume path. RegisterEcShards is the chokepoint both the full
and incremental heartbeat sync paths funnel through.

* test: cover incremental heartbeat path for EC maxVolumeId

Both SyncDataNodeEcShards and IncrementalSyncDataNodeEcShards funnel
through RegisterEcShards; assert the invariant on the incremental path
too.
2026-06-04 22:25:30 -07:00
Chris LuandGitHub 8d59069a0a s3: return BucketAlreadyOwnedByYou when recreating your own bucket (#9822)
* s3: return BucketAlreadyOwnedByYou when recreating your own bucket

PutBucket returned BucketAlreadyExists for every existing bucket, even
when the caller already owns it, so idempotent re-creation (e.g. a
container that creates its bucket on startup) couldn't tell "someone
else took the name" from "it's already mine".

Recreating a bucket you own now returns BucketAlreadyOwnedByYou, unless
the request conflicts with the existing bucket: a different Object Lock
setting, or an ACL on the request or the existing bucket. To detect the
latter, a requested non-default canned/grant ACL is now persisted on
creation instead of being dropped.

* s3: fail PutBucket when the existing bucket's config can't be read

When a bucket already exists, an unreadable config left the recreate
defaulting to BucketAlreadyOwnedByYou, masking the backend error and
possibly accepting a conflicting recreate (Object Lock / ACL unknown).
Surface the read error instead.

* s3: return the stored bucket ACL from GetBucketAcl

GetBucketAcl always returned the owner's default full-control grant and
ignored any stored ACL, so a bucket created with a canned ACL or one set
via PutBucketAcl never read back correctly. Decode the stored grants
instead, sharing one grants-to-XML helper with the object ACL handler.

The shared helper also emits each grantee's real xsi:type (e.g. Group for
public-read) instead of a hardcoded CanonicalUser, so group grants read
back correctly for both bucket and object ACLs.

* s3: resolve the right already-exists error on the concurrent-create race

When two requests create the same bucket at once, the loser's mkdir
fails and the handler fell back to a flat BucketAlreadyExists, bypassing
the same-owner idempotency check. Route both the pre-check and the race
fallback through one existingBucketError helper so a same-owner recreate
still gets BucketAlreadyOwnedByYou.

* s3: record the bucket owner's account id at creation

setBucketOwner only stored the creating identity name, so the canonical
account id wasn't available later. Persist it under ExtAmzOwnerKey too,
the same field PutBucketAcl writes, so the bucket owner can be reported
independently of whoever reads it.

* s3: report the bucket owner from GetBucketAcl, not the caller

GetBucketAcl built the ACL Owner from the caller's account header, so an
admin or cross-account read returned the wrong owner. Use the owner
persisted on the bucket, falling back to the caller only when none is
recorded.
2026-06-04 15:33:03 -07:00
Chris LuandGitHub a24f4844d3 filer: keep S3 list order byte-lexicographic regardless of SQL name column collation (#9824)
* mysql: keep S3 list order byte-lexicographic regardless of name column collation

ORDER BY name and the name > ? pagination predicate follow the column
collation, so a case-insensitive filemeta.name (e.g. utf8mb3_general_ci)
returns S3 keys out of byte order and breaks clients that merge two sorted
listings.

Detect the live name collation at startup; only when it isn't binary, wrap
the list comparison, prefix, and ORDER BY in BINARY name so order and
pagination stay consistent. Correctly configured utf8mb4_bin tables keep
their indexed range scan unchanged, and the operator gets a warning to
convert the column.

* postgres: keep S3 list order byte-lexicographic regardless of name column collation

ORDER BY name and the name > $n pagination predicate follow the column or
database collation, so a locale-aware filemeta.name (e.g. the en_US.UTF-8
database default) returns S3 keys out of byte order and breaks clients that
merge two sorted listings.

Detect the live name collation at startup; only when it isn't byte-ordered,
wrap the list comparison, prefix, and ORDER BY in COLLATE "C" so order and
pagination stay consistent. A byte-ordered (C/POSIX/C.UTF-8) column keeps its
indexed range scan unchanged, and the operator gets a warning to declare the
column COLLATE "C".
2026-06-04 14:33:41 -07:00
Chris LuandGitHub 8c2d9f466f filer: stream persisted log files when serving metadata subscriptions (#9821)
* filer: stream persisted log files when serving metadata subscriptions

readFileEntries buffered every LogEntry of a whole log file into memory
before returning them one by one, making a subscription read O(entries in
one log file) instead of O(one entry). On a filer with large per-entry
metadata, many concurrent SubscribeMetadata streams each loading a full
log file exhausted memory.

Keep a current LogFileIterator and return one entry at a time, advancing
files as each is exhausted. The deleted-volume skip is preserved.

* filer: close the log file iterator on read errors too

A genuine read error returned early without closing the current
LogFileIterator, leaving its ChunkStreamReader alive until GC. Close on
every exit path and propagate only a real error.

* filer: close persisted-log iterators when a subscription stops early

The streaming iterator keeps a log file reader open across calls, so a
subscription that returns before EOF (early stop, cancellation) left the
reader alive until GC. Add idempotent Close on LogFileQueueIterator and
OrderedLogVisitor, and have ReadPersistedLogBuffer wait for the readahead
goroutine and close the visitor on the way out.
2026-06-04 13:27:25 -07:00
6e8002f065 fix: handle meta backup offset errors safely (#9818)
* fix: log meta backup offset errors

* fix: log meta backup offset errors

* fix: exit on meta backup offset errors

Exit with a non-zero status when the initial metadata backup offset cannot be persisted.

Classify offset-read failures during streaming so the backup process exits instead of retrying forever, allowing supervisors to restart and bootstrap from a missing checkpoint.

* meta backup: read offset in the loop, drop offset error type

Reading the saved offset inside the retry loop makes an offset read
failure a clean exit and a stream error a retry, without a typed error
to tell them apart. streamMetadataBackup now takes the start time.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-06-04 10:53:23 -07:00
Chris LuandGitHub 3e8ec879c4 s3: keep dynamic IAM live when -iam.config is set (#9817)
* s3: keep dynamic IAM live when -iam.config is set

-iam.config was treated like a static -config identity file: it set
useStaticConfig, which makes the filer metadata subscription skip
reloads. Identities and policies created at runtime (the IAM gRPC API)
then never took effect, so advanced IAM (OIDC/STS) and dynamic IAM were
mutually exclusive.

Gate useStaticConfig on whether inline identities were actually loaded.
An OIDC/STS-only config carries none, so it keeps the dynamic credential
store live; a -config identity file still freezes its identities as
before.

* s3: mark static identities on config reload too

A -config reload (grace.OnReload) re-reads the file, but only the startup
path marked its identities static, so identities added to the file and
reloaded were left unprotected from dynamic filer updates. Move the
marking into loadS3ApiConfigurationFromFile and make it additive and
scoped to the file's identities, so a reload protects newly added ones
without freezing dynamic filer-managed identities.

* s3: sync reloaded static identities into the credential manager

After marking a (re)loaded config file's identities static, push the
updated set into the credential manager so reloaded identities still
appear in listings and survive later dynamic merges. Centralize the sync
in loadS3ApiConfigurationFromFile and drop the now-redundant call in the
reload hook.
2026-06-03 23:28:25 -07:00
Fabian HardtGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Chris Lu
ce6a51468a sftpd: support SSH user certificates signed by a trusted CA (#9815)
* sftpd: support SSH user certificates signed by a trusted CA

Adds a new "certificate" auth method to weed sftp. When enabled, the server
loads trusted CA public keys from -trustedUserCAKeysFile (OpenSSH
authorized_keys format, one or more keys) and accepts only ssh.Certificate
blobs of type UserCert on the public-key channel. Validation uses
ssh.CertChecker: CA signature, ValidAfter/ValidBefore, non-empty
ValidPrincipals and SSH login user must appear in ValidPrincipals. The
authenticated user must exist in the user store; home dir and permissions
resolve as before.

Behaviour mirrors MinIO's --sftp=trusted-user-ca-key and OpenSSH's
TrustedUserCAKeys: when certificate auth is active, plain (non-cert) public
keys are rejected even if "publickey" is also listed. Default authMethods
remain "password,publickey", so existing deployments are unaffected.

* Update weed/sftpd/auth/certificate.go

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* sftpd: address review feedback on certificate auth

- Pre-marshal trusted CA public keys in IsUserAuthority instead of
  re-marshaling on every authentication attempt (gemini-code-assist).
- Differentiate user-not-found from underlying store errors via
  errors.As(*user.UserNotFoundError) so backend/read failures are no
  longer reported as bad credentials (coderabbitai).
- Fix the corresponding sanity check in the missing-file test to use
  errors.As instead of errors.Is (UserNotFoundError has no Is method,
  so the previous check never matched) (coderabbitai).

* sftpd: register trustedUserCAKeysFile flag in filer and server commands

The new field on SftpOptions is dereferenced unconditionally in
resolvePaths(), but only the standalone `weed sftp` command was wiring
its flag. `weed filer` and `weed server` both embed an SftpOptions value
and call resolvePaths() on it, so they hit a nil pointer dereference at
startup.

Register `-sftp.trustedUserCAKeysFile` in both commands and update the
-sftp.authMethods help text to mention the new "certificate" method.

Fixes the SFTP Integration Tests CI failure on this PR.

* helm: expose SFTP certificate auth in the SeaweedFS chart

Adds Helm-chart support for the new SSH user-certificate auth method:

- values.yaml (sftp:) gains `trustedUserCAKeys` (inline OpenSSH
  authorized_keys-format CA public keys) and `existingCAKeysSecret`
  (reference an externally managed Secret). Same pair added under
  allInOne.sftp with a null default that falls back to the top-level
  sftp.* setting.
- New template templates/sftp/sftp-ca-secret.yaml renders a
  chart-managed Secret <release>-sftp-ca-secret with `ca_user.pub`,
  but only when SFTP is enabled, "certificate" is in authMethods,
  inline keys are provided, and no existingCAKeysSecret is set.
- templates/sftp/sftp-deployment.yaml and the all-in-one deployment
  template add `-trustedUserCAKeysFile=/etc/sw/sftp_ca/ca_user.pub`
  to the weed sftp command, mount the CA secret at /etc/sw/sftp_ca
  and add the corresponding volume. All cert-auth bits are guarded
  by `contains "certificate" authMethods` so existing users see no
  change.
- authMethods help text updated to mention "certificate".

Verified end-to-end on a local k3d cluster: cert login succeeds,
plain-pubkey login is rejected with "public key without certificate
not allowed".

* helm: fail render when SFTP certificate auth lacks CA keys

When certificate is in authMethods but neither trustedUserCAKeys nor
existingCAKeysSecret is set, the deployment mounted a secret that the
chart never renders, leaving the pod stuck on a missing volume. Fail at
template time with a clear message instead.

* sftpd: fix stale auth-method list in SFTPServiceOptions comment

keyboard-interactive was never implemented; certificate is the new
supported method. Match the CLI help text.

* sftpd: test Manager wiring of certificate vs public-key channel

Cover the channel takeover at the Manager level: certificate auth
displaces plain public-key auth when both are enabled, public-key auth
stays put otherwise, and enabling certificate without a CA file errors.

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-06-03 22:32:47 -07:00
Chris LuandGitHub df879e1ed7 filer: bound TraverseBfsMetadata memory by queuing directory paths (#9814)
* filer: bound TraverseBfsMetadata memory by queuing directory paths

The BFS enqueued every entry, so it held the whole subtree in memory
including each file's chunk list. A filer serving a peer's first-time
bootstrap traversal of a large tree could exhaust memory and get killed.

Stream each entry as it is visited and queue only directory paths to
descend into. Memory is now bounded by the number of directories rather
than the entire tree, and the streamed output order is unchanged.

* filer: match excluded prefixes on path-component boundaries

Only treat an excluded prefix as a match when it ends at a path
boundary, so excluding /a/b does not also drop a sibling like /a/bc.
Short-circuit the trie walk on the first real match.
2026-06-03 10:28:42 -07:00
Konstantin LebedevandGitHub df833d485f [test] update docker image for s3test (#9811) 2026-06-03 09:45:00 -07:00
Lars LehtonenandGitHub d321e463e9 chore(weed/storage/needle): prune unused test functions (#9812) 2026-06-03 09:26:28 -07:00
Chris LuandGitHub ef1aa4f936 s3: defer a recently-unreachable owner that is also the current filer (#9808)
Blanking preferred kept route-by-key reads from dialing a flagged owner first,
but withFilerClientFailover always re-adds the current filer, so when the owner
is the gateway's current filer it stayed in the candidate list and got dialed
anyway. Treat a recently-unreachable filer as unhealthy in the health partition
so it is deferred to the last-resort tail instead of tried before healthy
replicas; preferred is still tried first, and a live owner is unaffected.
2026-06-03 00:28:56 -07:00
Chris LuandGitHub 2d1b8be22b s3: route object reads to the key's owner filer (#9806)
* s3: route object reads to the key's owner filer

Writes already route by key to the owner filer on the lock ring, where the
entry is created. Reads went to the gateway's local filer and treated its
NotFound as authoritative, so a GET on one gateway could miss an object
another gateway had just written until the filers' metadata replication
caught up.

Resolve an object's entry from the key's owner first, failing over to the
gateway's filer set only on transport errors. An owner NotFound stays
authoritative: no fan-out across filers, and no resurrecting a peer's
not-yet-replicated tombstone, so a delete routed to the owner is visible at
once and a genuine miss costs one lookup. Keys owned by the local filer are
unchanged. Objects written through the non-routed lock path land on a
gateway's local filer, so they can still read as absent on the owner until
they replicate.

withFilerClientFailover takes a preferred start filer; the object-entry
reads pass the owner, every other caller passes "" and keeps the
current-filer fast path.

* s3: consult the prior owner on a rebalance-window read miss

Owner-first reads route a key to its current ring owner. When a filer joins,
~1/N of keys reassign to it, and the new owner may not have replicated a
just-moved key yet, so an owner NotFound would surface a transient 404 for an
object that already exists elsewhere.

Retain the previous ring on the gateway's LockClient for a cooling-off window
(PriorOwnerForKey, mirroring the master's LockRing.PriorOwner) and, on the
owner's NotFound, probe the key's previous owner once before treating the miss
as final. The probe is scoped to keys whose ownership actually moved and only
within the window, so steady-state reads are untouched.

This trades the transient scale-up 404 for a transient stale read if a delete
routed to the new owner races the same window — the same authoritative-NotFound
tradeoff, narrowed to the rebalance.

* s3: try healthy filers before unhealthy ones on failover

The candidate list probed its first entry (usually the current filer)
unconditionally, so a health-flagged current filer cost a transport timeout on
every ordinary call before failover reached a replica. Partition candidates into
healthy and unhealthy, keep priority within each, and fall back to unhealthy
ones only when all healthy ones fail.

* reduce comments on the routed read and lock client paths

* s3: skip a recently-unreachable owner on route-by-key reads

The gateway's filer health tracking no-ops for an owner outside the static
-filer list, so during a sustained owner outage every route-by-key read
re-dials the dead owner before failing over. Flag an owner whose owner-first
read hit a transport error and skip it (read local-first) for a short TTL, so
reads pay one dead dial per TTL instead of one per request; the flag expires so
owner-first reads resume once the owner or the ring recovers.

* s3: always try the preferred owner first, health-order only the rest

The healthy/unhealthy partition also demoted a health-flagged preferred owner
behind healthy replicas, so a replica's authoritative NotFound could mask a
write that had only reached the owner — the read-after-write race this routing
exists to close. Pull preferred out of the partition and keep it first; the
recently-unreachable gate already steers reads away from a genuinely dead owner.
2026-06-03 00:12:28 -07:00
Chris LuandGitHub 4e5839ce82 fix(iam): return a valid user ARN from CreateUser and GetUser (#9794)
* fix(iam): return a valid user ARN from CreateUser and GetUser

The terraform aws provider 6.41 reads a user back after creating it and
blocks until GetUser returns a value that passes arn.IsARN. We only set
UserName, so the ARN was empty and apply hung until the 2m timeout.
Populate Arn (and Path) via a shared iam.NewUser helper in both the
embedded and standalone IAM handlers.

* fix(iam): use the userName parameter directly in NewUser

Drop the redundant local copy; the value parameter is already function-local.

* fix(iam): return full user objects with ARNs from GetGroup

GetGroup listed members with only UserName set. Build them via the shared
NewUser helper so group members carry a valid Arn and Path like the other
user responses, in both the embedded and standalone IAM handlers.
2026-06-02 22:01:57 -07:00
Chris LuandGitHub f711868fb6 fix(log_buffer): re-check buffer before bailing with ResumeFromDiskError (#9804)
ReadFromBuffer and HasData() take the read lock separately, so a write
that lands between them can make a subscriber which just read a
momentarily empty buffer return ResumeFromDiskError even though the data
is now servable from memory. Re-read under a fresh lock and only bail
when the position is genuinely behind the in-memory window (flushed to
disk); otherwise loop back and read it.
2026-06-02 21:37:15 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris Lu
24159fbff9 build(deps): bump opentofu/setup-opentofu from 1 to 2 (#9801)
Bumps [opentofu/setup-opentofu](https://github.com/opentofu/setup-opentofu) from 1 to 2.
- [Release notes](https://github.com/opentofu/setup-opentofu/releases)
- [Commits](https://github.com/opentofu/setup-opentofu/compare/v1...v2)

---
updated-dependencies:
- dependency-name: opentofu/setup-opentofu
  dependency-version: '2'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-06-02 21:37:05 -07:00
Chris LuandGitHub 7b44cf5627 fix(iam): implement CreatePolicyVersion for managed policies (#9795)
* fix(iam): implement CreatePolicyVersion for managed policies

The AWS Terraform provider updates a managed policy in place via
CreatePolicyVersion, which returned 501 NotImplemented and broke
terraform apply on any policy change.

Implement CreatePolicyVersion (plus ListPolicyVersions, GetPolicyVersion
and DeletePolicyVersion) on both the standalone IAM server and the
embedded S3 IAM API. Managed policies keep a single current document, so
each is modeled as one default version "v1": CreatePolicyVersion replaces
the document, List/GetPolicyVersion expose it, and DeletePolicyVersion
rejects deleting the default. GetPolicy now reports DefaultVersionId so
the provider's read can fetch the document. The standalone path also
refreshes the cached Identity.Actions of every identity the policy is
attached to so the new document takes effect.

* fix(iam): reject CreatePolicyVersion unless SetAsDefault=true

With a single always-default managed-policy version, a request with
SetAsDefault=false (or omitted) would stage a non-default version on AWS
but here silently replaced the active document. Reject it on both the
standalone and embedded paths.

Isolate the new policy-version tests from the shared package fixtures so
they stay order-independent, and assert IsDefaultVersion on the response.
2026-06-02 21:35:02 -07:00
Chris LuandGitHub b6a0bde16b test(s3/iam): scope ListBucket isolation via s3:prefix condition (#9805)
The username-isolation policy denied s3:ListBucket through an object-path
NotResource. ListBucket is bucket-level, so its resource ARN is the bucket
and never matches an object path: the Deny always fired and a user could
not list their own prefix. Scope the per-user List deny with a StringNotLike
s3:prefix condition instead, the same mechanism the matching Allow uses.
2026-06-02 18:41:10 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6bffe3f56a build(deps): bump google.golang.org/grpc from 1.80.0 to 1.81.1 (#9797)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.80.0 to 1.81.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.80.0...v1.81.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.81.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 18:27:52 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris Lu
69bc7325ca build(deps): bump github.com/ydb-platform/ydb-go-sdk/v3 from 3.134.2 to 3.139.5 (#9796)
build(deps): bump github.com/ydb-platform/ydb-go-sdk/v3

Bumps [github.com/ydb-platform/ydb-go-sdk/v3](https://github.com/ydb-platform/ydb-go-sdk) from 3.134.2 to 3.139.5.
- [Release notes](https://github.com/ydb-platform/ydb-go-sdk/releases)
- [Changelog](https://github.com/ydb-platform/ydb-go-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ydb-platform/ydb-go-sdk/compare/v3.134.2...v3.139.5)

---
updated-dependencies:
- dependency-name: github.com/ydb-platform/ydb-go-sdk/v3
  dependency-version: 3.139.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-06-02 17:22:03 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
348de64f13 build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.32.14 to 1.32.21 (#9798)
build(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.32.14 to 1.32.21.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.14...config/v1.32.21)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.32.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 17:14:58 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
30e0116611 build(deps): bump go.etcd.io/etcd/client/pkg/v3 from 3.6.11 to 3.6.12 (#9799)
Bumps [go.etcd.io/etcd/client/pkg/v3](https://github.com/etcd-io/etcd) from 3.6.11 to 3.6.12.
- [Release notes](https://github.com/etcd-io/etcd/releases)
- [Commits](https://github.com/etcd-io/etcd/compare/v3.6.11...v3.6.12)

---
updated-dependencies:
- dependency-name: go.etcd.io/etcd/client/pkg/v3
  dependency-version: 3.6.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 17:14:50 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d6db545b4c build(deps): bump github.com/pierrec/lz4/v4 from 4.1.26 to 4.1.27 (#9800)
Bumps [github.com/pierrec/lz4/v4](https://github.com/pierrec/lz4) from 4.1.26 to 4.1.27.
- [Release notes](https://github.com/pierrec/lz4/releases)
- [Commits](https://github.com/pierrec/lz4/compare/v4.1.26...v4.1.27)

---
updated-dependencies:
- dependency-name: github.com/pierrec/lz4/v4
  dependency-version: 4.1.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 17:14:28 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cb67542d01 build(deps): bump docker/setup-qemu-action from 4.0.0 to 4.1.0 (#9802)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 4.0.0 to 4.1.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v4...v4.1.0)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 17:13:52 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6908445c5d build(deps): bump actions/checkout from 5 to 6 (#9803)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 16:26:55 -07:00
Chris LuandGitHub 160e68dd65 fix(s3api): keep ListBucket resource ARN at bucket level (#9792)
* fix(s3api): keep ListBucket resource ARN at bucket level

ListObjects with ?prefix= was denied for IAM users granted s3:ListBucket
on the bucket ARN. authRequestWithAuthType promotes the prefix into object
so the legacy CanDo path can honor prefix-scoped Action strings, and that
promoted object leaked into the policy resource ARN, producing
arn:aws:s3:::bucket/<prefix> which never matches a bucket-level statement.

Keep the resource bucket-level for List in the bucket-policy and
IAM-attached-policy evaluators; prefix scoping stays in the s3:prefix
Condition. The CanDo path is untouched.

* fix(s3api): resolve List action at bucket level when prefix is promoted

The IAM evaluator built a bucket-level resource ARN but still passed the
prefix-promoted object to ResolveS3Action, so listing with a prefix made
hasObject true and misresolved ListBucketVersions/ListBucketMultipartUploads
to ListBucket. Resolve the action against the same zeroed object, and trim
the resource-ARN comments.
2026-06-02 14:45:45 -07:00
Chris LuandGitHub 8e4022d5c7 fix(s3api): authorize DeleteObjects per key so object-scoped policies match (#9793)
Bulk DeleteObjects carries the keys in the request body, so the route Auth
middleware ran a single bucket-level check with object="", building the
resource ARN as arn:aws:s3:::<bucket>. That never matches an s3:DeleteObject
policy scoped to <bucket>/*, so the entire batch was denied even though the
single-key DELETE worked with the same credentials.

Defer authorization to the handler and check each key via AuthorizeBatchDeleteKey,
mirroring AuthorizeCopySource: a synthetic DELETE /<bucket>/<key> request resolves
s3:DeleteObject (or s3:DeleteObjectVersion when a versionId is given) against the
object ARN. Denied keys come back as per-key errors while authorized keys still
delete, matching AWS semantics.
2026-06-02 14:45:05 -07:00
Chris LuandGitHub b5a952bcb1 fix(mount): don't strand a directory cached-but-empty when an eviction races a rebuild (#9791)
* fix(mount): don't strand a directory cached-but-empty when an off-loop wipe races a rebuild

Idle eviction, kernel Forget, and the copy-range fallback cleared a
directory's cached entries directly, off the metaCache apply loop, after
resetting the cached flag in inodeToPath as a separate step. A concurrent
rebuild could publish a fresh listing (markCachedFn) in between, so the late
DeleteFolderChildren left the directory flagged cached over an empty store.
lookupEntry then returns an authoritative ENOENT and ReadDir returns nothing,
so every file in the directory disappears from the mount although it is still
present on the filer.

Route those wipes through a new apply-loop step that resets the flag and wipes
the store together, serialized with a build's markCachedFn, and skips a
directory while it is building.

* fix(mount): route the meta-event retry cleanup through the apply-loop purge

The subscription-retry callback wiped the mount root's cached children
directly off the apply loop and reset the cache flags as a separate step — the
same pattern that can leave a concurrently-rebuilding root cached-but-empty.
Invalidate all flags (safe on its own, it never deletes entries) then purge the
root's children through the apply loop.
2026-06-02 14:43:46 -07:00
ahalaunandGitHub 3ce4e0dbdf fix(s3/lifecycle): report success to admin via JobCompleted (#9787) 2026-06-02 11:28:56 -07:00
ahalaunandGitHub bcd2c958e1 fix(admin): make scheduler pruning lane-aware (#9790) 2026-06-02 11:17:52 -07:00
e3e02d3364 [CheckDisk]: implement disk health detection (#9560)
* [CheckDisk][GRPC]: implement MVP for disk health detection, added timeout for new grpc connections

* fix(volume): build disk health check on every platform

setDiskStatus only existed behind the statfs build tag, so disk.go failed
to compile on windows, openbsd, solaris, netbsd and plan9. Move the timeout
wrapper and failure tracking into the shared disk.go and have each platform's
fillInDiskStatus return an error, so every platform gets the same protection
from a stuck filesystem.

Also restore the uint64(fs.Bavail) cast: Bavail is int64 on freebsd, so the
unguarded multiply broke the freebsd build.

* fix(volume): keep one outstanding statfs probe per disk

A stuck statfs used to leave isChecking cleared by the timeout path, so the
next check spawned another goroutine while the previous one was still blocked
in the syscall, leaking one goroutine per minute on a hung disk. Clear the
flag only when statfs returns and treat an overlapping check as a failure, so
a hung filesystem keeps a single outstanding probe and still gets reported.

* fix(volume): assume disk available until the first health check

isDiskAvailable defaulted to false, and CollectHeartbeat skips locations that
are not available. A freshly started volume server would therefore omit every
volume from its first heartbeats until the async CheckDiskSpace ran, so the
master could briefly treat all of them as missing.

* fix(volume): label the disk error metric by data directory

The new gauge tagged the series with IdxDirectory while every neighbouring
resource gauge uses Directory, so the error series would not line up with them
in dashboards. Also log the underlying error instead of a generic message.

* test(volume): cover disk health success and repeated-failure paths

* fix(volume): make a healthy disk the zero-value default

Track the disk as isDiskUnavailable instead of isDiskAvailable so the safe
state is the zero value, matching isDiskSpaceLow. CollectHeartbeat only skips a
location once a check has actively marked it unavailable, so any DiskLocation
built without running CheckDiskSpace (tests, future call sites) still reports
its volumes instead of silently dropping them.

* feat(disk): detect degraded disks using IO latency probes

* feat(stats): introduce configurable disk I/O health probe with EWMA-based latency detection

* feat(disk): replace EWMA with sliding window algorithm for disk health detection and added user-friendly options

* feat(disk): improve disk health probing and recovery

* feat(volume): configure disk health checks via volume.toml

* fix(volume): Remove disk IO probe CLI options

---------

Co-authored-by: ptukha <ptukha@tochka.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-06-02 09:02:05 -07:00
7y-9andGitHub 38a47d1dd3 fix(http): check delete request errors before auth (#9784)
Explain:

- problem: Delete and DeleteProxied could panic on malformed URLs when a JWT was provided.

- root cause: maybeAddAuth was called before checking the error returned by http.NewRequest, so req could be nil.

- fix: return the request construction error before adding the Authorization header.

- validation: go test ./weed/util/http -run 'TestDelete(ReturnsInvalidRequestErrorBeforeAddingAuth|ProxiedReturnsInvalidRequestErrorBeforeAddingAuth)' -count=1; git diff --check
2026-06-02 00:41:17 -07:00
Chris Lu 2a46d457ac 4.31 2026-06-01 23:32:04 -07:00
Chris LuandGitHub e264e9883e fix(seaweed-volume): bound request body and stored-content expansion to prevent OOM under load (#9780)
* fix(seaweed-volume): bound request body and stored-content expansion to prevent OOM

The Rust volume server buffered the entire upload body with
to_bytes(usize::MAX) and only checked the file-size limit afterward, so a
single large upload — or many concurrent uploads, since the in-flight byte
throttle defaults to 0 (unlimited) — could exhaust memory and get the process
OOM-killed under load. The read path had two more single-request OOM vectors:
`vec![0u8; manifest.size]` allocated from an attacker-controlled chunk-manifest
size, and gzip decompression was unbounded (gzip bomb).

- Bound the upload body read by file_size_limit_bytes (plus a margin for
  multipart framing), mirroring Go's io.LimitReader(sizeLimit+1), and reject
  oversize before the whole body is buffered.
- Validate manifest.size (reject negative / oversized) before allocating.
- Cap gzip output in maybe_decompress_gzip and route the inline GzDecoder sites
  through it.

* fix(seaweed-volume): address review - chunk offset, 32-bit cast, decompress errors

- Validate chunk.offset before indexing in chunk-manifest expansion: a negative
  offset wrapped to a huge usize and underflowed `end - offset` (panic from a
  crafted manifest). Reject negative, skip out-of-range, use saturating math.
- Use usize::try_from for the upload body limit instead of `as usize`, so a
  >usize::MAX file_size_limit on 32-bit caps at usize::MAX rather than silently
  truncating to a tiny value.
- maybe_decompress_gzip now returns Result<_, GunzipError> distinguishing a
  decode failure (callers fall back to raw bytes, as before) from hitting the
  size cap (TooLarge), which now returns 413 instead of silently serving the
  still-compressed bytes.

* fix(seaweed-volume): inflate manifest chunks into the result window to cap peak memory

The chunk-manifest expansion still doubled memory: `result` was already allocated
at manifest.size (<=2 GiB) and each compressed chunk was inflated into a separate
Vec (also up to 2 GiB), so a single request could peak near 4 GiB.

Decompress compressed chunks directly into their result[offset..] window (bounded
by the remaining space) so a chunk never allocates a second large buffer; peak
stays at ~manifest.size. Bytes past the window are dropped (matching the prior
truncation), and a fully-undecodable chunk still falls back to its raw bytes.

* fix(seaweed-volume): fall back to raw chunk bytes on any decode failure

Per review: the gzip fallback must run on any decode error, not only when no
bytes were decoded. Clear the partially-written output and copy the chunk's raw
bytes (truncated to the window), restoring the prior decode-failure behavior.
2026-06-01 22:24:13 -07:00
Chris LuandGitHub fba71ab14c ci: parallelize the unified release-container build (#9783)
* docker: cross-compile the Go binary instead of emulating it under QEMU

The builder stage ran as the target platform, so arm64/arm/386 images
emulated the whole Go compile (and the full git clone) under QEMU. The
binary is CGO-free, so pin the builder to $BUILDPLATFORM and cross-compile
with GOOS/GOARCH (GOARM for v7), keeping every target's compile native.

* ci: build all release container variants in parallel

The build matrix throttled to two variants at a time on a stale rate-limit
worry. Pulls go through mirror.gcr.io and pushes target GHCR only, so the
five variants can all build at once.

* ci: copy each variant to Docker Hub from its build job

The separate copy-to-dockerhub job waited on the whole build matrix before
any GHCR -> Docker Hub copy could start. Move the crane copy into the build
job so each variant copies as soon as it is built, overlapping with the
others still compiling. tag-latest and helm-release now depend on build.
2026-06-01 20:34:05 -07:00
45465e5a05 fix(master): notify clients after manual volume grow (#9656)
Co-authored-by: Neetika Mittal <mneetika@users.noreply.github.com>
2026-06-01 20:33:37 -07:00
Chris LuandGitHub bf37fba0e1 fix(s3): recover versioned reads when the .versions latest pointer is absent (#9782)
GetObject on a versioned object returned NoSuchKey forever when the
.versions directory existed but carried no latest-version pointer (empty
Extended metadata) while real version files remained inside it. The
self-heal path only fired for a dangling pointer (present but referencing
a missing file), not an absent one, so doGetLatestObjectVersion fell
straight through and errored on every read.

- doGetLatestObjectVersion now calls recoverLatestVersionWithoutPointer
  when the pointer is missing or empty. An absent pointer is the legitimate
  signal that a pre-versioning or suspended-versioning "null" object is
  current, so that object wins; only when it is absent do we rescan
  .versions/ and rebuild the pointer from the version files present.
  Transient rescan failures propagate instead of being masked as NotFound.
- selectLatestVersion derives the version id from the v_<versionId> file
  name when the Seaweed-X-Amz-Version-Id attribute is absent, so version
  files written outside the normal versioned-PUT path (replicated or
  restored entries) are still promotable. The orphan diagnostic uses the
  same detection so an entry can't be both promoted and counted an orphan.
2026-06-01 20:01:30 -07:00
Chris LuandGitHub ca81c0c525 fix(ec): pass per-volume data-shard count to the parity-shard split (#9781)
* fix(ec): pass per-volume data-shard count to the parity-shard split

ShardsInfo.DeleteParityShards/MinusParityShards looped ids 10..13, assuming
the fixed 10+4 layout. For a non-default ratio this splits data vs parity
wrong — a wide ratio (12+4, 16+6) drops real data ids >= 10, which breaks
ec.decode. They now take a dataShards argument (<= 0 falls back to
DataShardsCount) and clear ids dataShards..MaxShardCount. ec.decode threads
the data-shard count from collectEcNodeShardsInfo to both split call sites,
and admin LogicalSize passes DataShardsCount.

Also: EC cleanup now sets an explicit per-disk storage impact
(-len(ShardIds)) instead of falling back to the TotalShardsCount constant,
so freed-capacity accounting matches the shards actually removed.

OSS is always 10+4, so behavior is unchanged here; this keeps the split
ratio-correct and the API aligned with the enterprise per-volume override.
Adds parity-split ratio tests.

* ec: clear parity shards in one locked pass

Address review: DeleteParityShards looped si.Delete, taking the lock once per
id. shards is sorted by Id and shardBits is a bitmap, so mask off the high
bits and truncate the sorted slice at the first parity id (binary search) under
a single lock. Preserves the dataShards<=0 -> DataShardsCount default.
2026-06-01 19:25:15 -07:00
Chris LuandGitHub f410d975c7 fix(ec): resolve EC data-shard count from the volume's .vif on reboot (#9779)
* fix(ec): resolve EC data-shard count from the volume's .vif on reboot

A volume server never loads a cluster EC config into memory, so startup
decisions that assumed 10 data shards mishandled volumes whose .vif
records a different ratio:

- validateEcVolume sized the expected shard against 10 data shards and
  required >=10 local shards, so a volume with a non-default ratio and a
  coexisting .dat could be wiped on reboot. Read the ratio from the .vif.
- pruneIncompleteEcWithSiblingDat used the hardcoded 10-shard threshold,
  so a full data set for a non-default ratio with a healthy sibling .dat
  was wiped as a partial leftover. Use the EcVolume's .vif-derived ratio.

Behavior is unchanged for the standard 10+4 layout (the .vif resolves to
10). Adds storage-level reboot tests.

* ec: avoid per-call allocations in ecDataShardsFromVif

Address review: the helper runs once per EC volume at startup. Replace the
slice+map dedup of the two dirs with direct conditional checks via a small
ecDataShardsFromVifDir helper, eliminating the heap allocations and GC
pressure when loading many volumes.
2026-06-01 19:22:14 -07:00
steve.weiandGitHub 1313600b9e fix(topology): restore active count after vacuum recovery (#9770) 2026-06-01 15:23:22 -07:00
Chris LuandGitHub 2386fa550a grpc: don't tear down the shared master connection on a caller's own timeout (#9775)
A Canceled/DeadlineExceeded from the caller's per-request context was
treated like a dead channel: it closed the shared cached ClientConn and
cancelled every other in-flight RPC on it with "the client connection is
closing". Under a burst of concurrent chunk assigns (e.g. a large S3
multipart upload) one slow assign hitting its 10s attempt timeout could
poison the connection for all the rest, cascading into a flood of 500s.

Thread the caller's context into shouldInvalidateConnection and only
invalidate on Canceled/DeadlineExceeded while that context is still live,
which isolates the genuine stale-channel signal (a peer restart behind a
k8s Service VIP). To carry the context, add a ctx parameter to the
existing WithGrpcClient, WithMasterClient, and WithMasterServerClient; the
master assign and volume-lookup paths pass their per-attempt context and
every other caller passes context.Background().
2026-06-01 15:11:02 -07:00
Chris LuandGitHub dfa86b4313 volume: keep volume writable after a deletion-tail compaction (#9776)
makeupDiff replays post-snapshot changes onto the compacted volume. For a
replayed deletion it appended a tombstone to the new .dat but recorded the
.idx entry with offset 0. When that deletion is the last replayed change the
tombstone lands at the .dat tail, and the post-commit integrity check skips
offset-0 entries, so it sees 32 trailing bytes it can't account for and flips
the volume read-only, reloading it as a SortedFileNeedleMap instead of the
writable map.

Record the tombstone's real .dat offset, matching the normal delete path; the
needle map still treats it as deleted off the negative size, so lookups are
unchanged. Mirror the same fix into the Rust volume server.
2026-06-01 13:15:08 -07:00
Chris LuandGitHub 8c60408bfb s3: auto-enforce bucket quota read-only both ways (#9774)
* s3: auto-enforce bucket quota read-only both ways

Quota read-only only ever flipped when an admin re-ran
s3.bucket.quota.enforce, so a bucket that went over quota stayed
read-only forever even after usage dropped back under.

Fold enforcement into the per-minute, leader-locked bucket-size loop
the s3 gateway already runs for metrics: it now flips each bucket's
read-only flag to match its quota in both directions, rewriting
filer.conf only when a flag actually changes. The set/clear decision
lives in one shared FilerConf.ApplyBucketQuotaReadOnly helper so the
shell command and the gateway can't drift.

* only manage read-only when a quota is set, never clobber manual locks

* trim comments
2026-06-01 13:11:18 -07:00
Chris LuandGitHub 57797c9b38 filer.sync: repair a destination shorter than the source (#9778)
When the destination's stored mtime is newer than the incoming source version, UpdateEntry skips the update (last-writer-wins). A copy left truncated by an earlier failed replication trips this: the source kept the file's original mtime while the partial copy was written recently, so it looks "newer" and is never corrected. When the destination is strictly shorter than the source, re-replicate the full source content and replace the chunk list instead of skipping. Same shorter-than-source bypass for CreateEntry.
2026-06-01 13:04:23 -07:00
Nguyễn Lộc PhúcandGitHub ed31271e28 fix(s3api): Fix multipart upload ETag compatibility with Hadoop S3A (#9772)
* s3api: use getEtagFromEntry for multipart part ETag to prefer Extended metadata

* s3api: add tests for getEtagFromEntry Extended ETag preference in multipart upload

* s3api: avoid double-quoting ETags in ListParts output

* s3api: add docstring for filer_multipart_etag_test.go
2026-06-01 13:03:46 -07:00
7y-9andGitHub 5ea75dcc67 fix(http): handle invalid gzip stream errors (#9767)
* fix(http): handle invalid gzip stream errors

Explain:

- problem: ReadUrlAsStream could panic when a response claimed gzip encoding but the body was not a valid gzip stream.

- root cause: the gzip reader error was ignored and a nil reader was deferred and read from.

- fix: return the gzip.NewReader error before registering Close or reading.

- validation: go test ./weed/util/http -run TestReadUrlAsStreamReturnsGzipReaderError -count=1; git diff --check.

* test: avoid closing shared global HTTP client in unit test
2026-06-01 12:21:19 -07:00
Chris LuandGitHub 1a19683ee6 filer: name the read-only path in the write rejection (#9773)
* filer: name the read-only path in the write rejection

The write path rejected creates under a read-only rule with a bare
"read only", giving no hint which path was locked or why. Wrap the
error with the matched location prefix and a quota hint so a FUSE
mkdir or S3 put points straight at the offending bucket.

* return the read-only reason over HTTP and drop any query string from the fallback prefix
2026-06-01 12:20:45 -07:00
Chris LuandGitHub 2e3fabbf24 filer.sync: back off on transient upload errors (#9777)
A destination volume server that hits its idle deadline while reading a large upload body under load returns 400 "read tcp ...: i/o timeout". fetchAndWrite retried that on the flat ~1s RetryUntil backoff, hammering the already-overloaded destination. Route i/o timeout, connection reset, broken pipe and net.Error timeouts through the same escalating 10s-2min backoff already used for EOF so it can recover.
2026-06-01 12:18:17 -07:00
Chris LuandGitHub f9ee49b03e shell: volume.fsck must not skip the system-log subtree (#9764)
shell: only skip system-log subtree in fs.meta.save, not fsck/verify

The SystemLogDir skip lived in the shared BFS traversal, so volume.fsck
built its in-use set without the /topic/.system/log chunks and flagged
every referenced log needle as orphan. -reallyDeleteFromVolume would then
delete live log data and leave dangling filer entries. Gate the skip
behind a flag that only fs.meta.save sets.
2026-06-01 09:54:22 -07:00
Chris LuandGitHub 80dd3b2621 EC bitrot follow-ups: protect destination sidecar on optional copy; cap sidecar block_size (#9763)
* fix(ec_bitrot): cap sidecar block_size in ValidateBitrotManifest

A sidecar loaded from disk (or supplied via a backfill/peer RPC) could carry a
huge power-of-two block_size that passed validation, then force a multi-GiB
scratch-buffer allocation in scrub/verify. Add a shared MaxBitrotBlockSize
(64 MiB) constant, enforce it as an upper bound in isPow2MultipleOf1MiB, and
derive the volume flag cap from the same constant so they cannot drift.

* fix(ec_bitrot): don't destroy a valid destination sidecar on an optional copy

writeToFile opened the destination with O_TRUNC before knowing whether the
source had the file, so an optional copy (ignoreSourceFileNotFound) from a source
that lacks the .ecsum truncated and then removed a valid pre-existing destination
sidecar. Stage the optional copy into a temp sibling and commit it with an atomic
rename only when the source actually delivered the file; a missing source is now
a no-op. Mandatory copies keep their in-place behavior.
2026-05-31 23:42:33 -07:00
Chris LuandGitHub 9658f309d2 EC bitrot detection: per-shard checksum sidecars (#9761)
* ec: add EC bitrot checksum protobuf

EcBitrotProtection/EcShardChecksums/ChecksumAlgorithm sidecar messages,
copy_ecsum_file and unsafe_ignore_sidecar fields, and a CHECKSUM scrub mode.

* ec: bitrot checksum sidecar format, validation, and per-volume load

Per-shard CRC32C block checksums in an optional <base>.ecsum sidecar with a
self-integrity header; validation, rolling builder, backfill primitive, and
EcVolume load on mount + removal on destroy.

* ec: capture per-shard checksums at encode; verify-and-exclude on rebuild

WriteEcFilesWithContext returns the protection computed inline during encoding.
generateMissingEcFiles verifies present inputs against the sidecar, excludes
corrupt ones, regenerates in place, and re-verifies; fail-closed unless
unsafe_ignore_sidecar, removing all generated outputs on failure.

* ec: read-only checksum scrub with Reed-Solomon arbiter

ChecksumScrub verifies each local shard against the sidecar and reconstructs
flagged shards from the clean shards so stale-sidecar false positives are not
reported. Wired to the gRPC CHECKSUM mode and ec.scrub -mode checksum.

* ec: server-side bitrot sidecar write, copy, cleanup, and opportunistic backfill

Write .ecsum at fresh encode; propagate it with copy_ecsum_file (tolerant);
remove it on full delete and decode; rebuild honors unsafe_ignore_sidecar and
opportunistically backfills a sidecar when all shards are reachable.

* ec: volume server bitrot config flags

-ec.bitrotChecksum (default on) and -ec.bitrotBlockSizeMB (default 16).

* fix(ec_bitrot): bound -ec.bitrotBlockSizeMB before the int64 multiply

Validate the MiB value is in [1, 1024] before multiplying by 1 MiB, so a huge
flag value cannot overflow int64 and slip past the power-of-two check, and a
block size cannot collapse a sidecar to a few oversized blocks.

* fix(ec_bitrot): distribute the .ecsum sidecar from the worker encode path

The worker EC encode wrote the generation-0 sidecar locally but never added it
to shardFiles, so DistributeEcShards never shipped it and the distributed
holders came up unprotected. Append it to shardFiles and map the ecsum shard
type to its extension in the sender so it travels with the shards.

* fix(ec_bitrot): remove orphaned sidecars when the generation is gone

Gate sidecar removal on existingShardCount==0 alone rather than also requiring a
stray .ecx. A sidecar whose shards have all been deleted is orphaned and must be
removed even when no .ecx remains, or it leaks. .ecx/.ecj/.vif removal stays
gated on hasEcxFile as before.

* fix(ec_bitrot): do not fold checksum blocks scanned into TotalFiles

ChecksumScrub's first return is blocks scanned, not files. Discard it so the
scrub response's TotalFiles (a needle/file count) is not inflated by the block
count for CHECKSUM mode.

* test(ec_bitrot): clean up generated .ecsum sidecars in removeGeneratedFiles

* fix(ec_bitrot): reject an oversized sidecar payload before the uint32 cast

The header stores payload_len as a uint32; bound the payload before the
conversion so a pathological manifest cannot truncate the length field and
corrupt the sidecar. A real manifest is a few KB, so this never trips.

* fix(ec_bitrot): cap -ec.bitrotBlockSizeMB at 64 MiB

The block size becomes the per-shard scratch buffer the scrub/backfill path
allocates, so an over-large value (e.g. 1 GiB) is a memory hazard per concurrent
scrub worker. Lower the upper bound from 1024 to 64 MiB.

* fix(ec_bitrot): add -ecUnsafeIgnoreSidecar to weed tool fix -ecx

The -ecx recovery path reconstructs missing shards via RebuildEcFilesWithContext,
which fails closed on a malformed/stale .ecsum. Without an override flag an
operator could not complete the rebuild without manually deleting the sidecar.
Expose -ecUnsafeIgnoreSidecar (default false) and thread it through.

* fix(ec_bitrot): bound sidecar payload with a direct int constant; drop readFull

Guard len(payload) against a plain int constant (1 GiB) before the allocation
instead of a uint64 MaxUint32 compare, so the allocation-size value is provably
bounded (clears the CodeQL overflow alert) and the math import is no longer
needed. Inline os.File.ReadAt with io.EOF handling in verifyShardFileBlocks and
remove the now-redundant readFull helper (os.File.ReadAt fills the slice or
errors).

* test(ec_bitrot): use slices.Contains instead of a hand-rolled containsU32

* refactor(ec): fold the EcFiles WithContext variants into the base functions

RebuildEcFiles now takes the *ECContext directly (nil => derive from .vif as
before) and WriteEcFiles takes it too (nil => default), removing the parallel
RebuildEcFilesWithContext / WriteEcFilesWithContext names. Callers that had an
explicit context drop the WithContext suffix; the default-context callers pass
nil. No behavior change.

* refactor(ec): pass BackgroundECContext instead of nil to Write/RebuildEcFiles

Add a non-nil BackgroundECContext placeholder (analogous to context.Background())
and have callers with no specific layout pass it instead of a nil *ECContext.
WriteEcFiles resolves a zero/background context to the default ratio and
RebuildEcFiles resolves it from the .vif, so behavior is unchanged.

* fix(ec_bitrot): make BackgroundECContext a func; RebuildEcFiles fails closed on bad .vif

- BackgroundECContext is now a function returning a fresh *ECContext, so callers
  cannot mutate a shared singleton or race on it (and it mirrors context.Background,
  which is also a function).
- RebuildEcFiles now propagates the MaybeLoadVolumeInfo error: a present-but-
  unreadable .vif fails closed instead of silently rebuilding with the default
  ratio (which would corrupt a custom-ratio volume). Pass an explicit ctx to override.
2026-05-31 18:52:44 -07:00
Chris LuandGitHub fdfeb4063c shell: warn in volume.list when a volume id spans collections (#9759)
* shell: warn in volume.list when a volume id spans collections

A reused volume id, the result of the master handing out an id already
used by another collection (for example after losing its max-volume-id
counter on restart), makes collection.delete destroy the wrong
collection's data and makes any bare-id lookup, move, or vacuum
ambiguous. volume.list now scans the full topology and warns on ids
present in more than one collection so the clash is visible before any
destructive operation.

* volume.list: track duplicate ids lazily, sort with slices.Sort

Allocate the per-id collection set only on the first cross-collection clash
instead of one set per volume, so allocations scale with duplicates rather
than the volume count.
2026-05-31 11:52:39 -07:00
Chris LuandGitHub 35ab67fa8a s3: reject reserved bucket name "filemeta" (#9760)
filemeta is the filer SQL store's default table name. A bucket of that
name passes VerifyS3BucketName but is rejected by the store's isValidBucket
guard on every operation, so it creates fine yet can't be deleted and wedges
fsck. Reject it at creation so both checks agree.
2026-05-31 11:15:05 -07:00
Chris LuandGitHub 6b06fe5ec4 s3: commit a versioned PutObject and its latest pointer in one transaction (#9756)
* s3: commit a versioned PutObject and its latest pointer in one transaction

A versioned PutObject wrote the version file and flipped the .versions
latest pointer in two separate routed transactions. Fold the
RECOMPUTE_LATEST into the version file's PUT so both commit atomically
under the object's per-path lock: the recompute, applied after the PUT in
the same transaction, scans the directory and sees the new version. A
crash can no longer leave the version present with a stale pointer.

putToFiler now takes a putFinalize describing the finalize step — routed
mutations folded into the PUT, or an afterCreate run under the object
write lock off the ring. Suspended-versioning keeps its afterCreate-only
form; multipart, copy, and delete-marker finalizes are unchanged.

* s3: trim verbose finalize comments
2026-05-31 00:13:36 -07:00
Chris LuandGitHub d806778757 admin: store file browser uploads in volumes, not inline (#9752)
uploadFileGrpc passed SaveSmallInline with a 256 KiB limit, so uploads under
that size were written to entry.Content instead of a volume. The filer's own
upload path never inlines unless saveToFilerLimit is set (default 0), and the
S3 server shares that path. Drop the inline options so admin uploads always
land in volumes.
2026-05-30 23:47:42 -07:00
Chris LuandGitHub 186747e7e8 admin: view images and PDFs inline in the file browser (#9751)
The viewer embedded images and PDFs through the download URL, which sent
Content-Disposition: attachment, so the browser downloaded them instead of
rendering. Add an inline mode to the download endpoint, limited to images and
PDFs so a hostile upload (HTML, SVG) can't run as same-origin script, set
X-Content-Type-Options: nosniff, and resolve the MIME the same way the viewer
does. The viewer now requests the inline URL.
2026-05-30 23:46:09 -07:00
Chris LuandGitHub 05c6500453 volume: fix maxVolumeCount dead zone that stalled writes on auto-sized disks (#9755)
* volume: don't drop the last writable slot on auto-sized disks

MaybeAdjustVolumeMax subtracted 1 from the per-disk slot count, so a disk
with room for exactly one volume (free between 1x and 2x the size limit)
reported 0 slots. The master then never grew a writable volume and every
assign drained its retry budget, so writes failed with context deadline
exceeded. Count the full volumes that actually fit, floored at one for an
auto-sized disk that has free space.

* mini: show disk and volume capacity in the startup banner

Print free space, volume size, total volume count and free volume count
under the data directory line, so a volume size limit that outstrips the
disk is visible at startup instead of surfacing later as failed writes.
2026-05-30 23:45:17 -07:00
Chris LuandGitHub a10607f90a Add Terraform support for VM-based SeaweedFS deployment (#9754)
* terraform: add cloud-agnostic core renderer module

Renders per-node weed argv, systemd units, config files, disk-mount and secret-fetch scripts, and cloud-init from an address map. Creates zero cloud resources. Flags verified against the weed binary: volume uses -mserver for the master list, gRPC is -port.grpc (auto http+10000), minFreeSpacePercent is a string, filer store via -defaultStoreDir.

* terraform: add mTLS and JWT security module

Generates the CA, per-component certs with distinct CNs, and JWT signing keys via the tls/random providers. Emits a core_security object plus PEMs for secret-store delivery.

* terraform: add AWS deployment module and examples

Reserves stable ENIs first, renders config via the core, then creates instances, prevent_destroy EBS data disks mounted at /data, and the cluster security group. With enable_security, generates certs/JWT, stores them in SSM SecureString, grants an instance role, and fetches them at boot so secrets stay out of user_data. Keyed for_each on every stateful tier.

* terraform: add local cluster test harnesses

run_local_cluster.sh and run_local_secure.sh render a cluster with the core and run real weed processes, asserting master quorum, volume registration, filer/s3 round-trips, mutual-TLS formation, and JWT enforcement. Use an isolated high port range with a guard so they never touch a cluster already running on the machine. The weed binary defaults to $(go env GOPATH)/bin/weed.

* terraform: add CI workflow and README

fmt/validate/tofu-test plus smoke jobs that build weed and run both harnesses.

* terraform: guard against empty filesystem UUID in mount script

An empty UUID made grep -q match any fstab line, skipping the fstab entry and breaking the mount. Fail fast when blkid returns no UUID.

* terraform: sanitize cluster name in WEED_CLUSTER env keys

Hyphens or spaces in cluster_name produced invalid systemd/bash env var names; map non-alphanumerics to underscores.

* terraform: omit empty jwt.signing block from security.toml

With enable_security and no JWT key, the template emitted [jwt.signing] key="". Gate the block on a non-empty key and cover it with a test.

* terraform: mark core security input as sensitive

The security object carries JWT signing keys; keep them out of plan output and known values.

* terraform: enforce jwt_length minimum of 32

* terraform: note region/AZ coupling in HA example

* terraform: guard WORKDIR before recursive delete in test harnesses

* terraform: fix README fence language and test count

* terraform: handle embedded s3 with no filer nodes

Indexing sort(keys(var.filers))[0] errored at plan time when embedded S3 was enabled but no filers were defined; fall back to an empty config source.

* terraform: scope kms:Decrypt to a configurable key arn

Replace the hardcoded Resource="*" with a kms_key_arn variable (default "*") so production can restrict decrypt to a specific CMK.

* terraform: encrypt EBS data volumes at rest

Set encrypted = true on the volume/filer data disks and the all-in-one example disk.

* terraform: protect filer instances from API termination

Filers hold the leveldb2 metadata store, so they are stateful and get the same disable_api_termination as masters and volumes.

* terraform: stop instance before detaching in all-in-one example

* terraform: drop stale references to the removed plan doc

* terraform: correct stale mount-step comment in aws module

* terraform: mark Terraform support as experimental in README
2026-05-30 23:43:17 -07:00
Chris LuandGitHub 0e35235908 s3: return NoSuchVersion (not NoSuchKey) for a missing versionId (#9749)
GET/HEAD object with an explicit versionId that does not exist returned
NoSuchKey. AWS S3 returns NoSuchVersion (404) for this case; tools that
distinguish "key gone" from "this version gone" rely on that code.

Add the ErrNoSuchVersion error code and use it on the GET and HEAD
specific-version lookups. Only a genuine not-found maps to NoSuchVersion;
a transient or internal filer error now maps to InternalError (500)
instead of a misleading 404. getSpecificObjectVersion wraps its lookup
error with %w so callers can detect filer_pb.ErrNotFound.
2026-05-30 21:09:53 -07:00
Chris LuandGitHub 7c5ca01027 admin: export file/folder metadata from the file browser (#9750)
Add a per-row Export button (files and folders) that downloads the filer
metadata in the length-prefixed FullEntry protobuf format that weed shell
fs.meta.load reads, gzipped as <name>.meta.gz like fs.meta.save. Folders are
walked recursively via the filer BFS metadata stream, excluding the system
log subtree. Streamed over gRPC so it keeps working with the filer HTTP
listener disabled.
2026-05-30 20:59:01 -07:00
Chris LuandGitHub 3441a2a7f1 s3: short-circuit filer failover on ErrNotFound (#9748)
withFilerClientFailover treated a filer's ErrNotFound like a transport
failure: it kept the result, re-queried every other filer, and finally
wrapped the answer as "all filers failed, last error: ... no entry is
found in filer store".

For workloads with many legitimate misses (e.g. GET object?versionId=X
for a version that was deleted or expired), this turned each 404 into N
filer round-trips and produced a misleading error string.

A reachable filer that answers ErrNotFound has given an authoritative
answer; failover exists to route around unreachable or unhealthy filers,
not to look harder for an entry the store reports as absent. Return
ErrNotFound directly instead of fanning out. Callers that need
read-after-write retries already handle that at the S3 semantic layer
(e.g. getLatestObjectVersion).
2026-05-30 15:07:27 -07:00
Chris Lu 34be9170f0 4.30 2026-05-30 10:52:32 -07:00
30f49013e1 perf(s3.iam.GetUser): Make the API default to the request username if not specified (#9746)
* perf(s3.iam.GetUser): Make the API default to the request username if not
specified

This makes the Embedded S3 IAM API align with the documented behavior of the AWS IAM
API as per AWS Docs: https://docs.aws.amazon.com/IAM/latest/APIReference/API_GetUser.html

BREAKING CHANGE: This changes the default behavior of the Embedded IAM API to use the
username of the user holding the accesskey used to make the request in
the GetUsername request handler.

* test: cover GetUser implicit username default

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-05-30 10:51:03 -07:00
Chris LuandGitHub 4bf27278fa topology: fail replica writes fast when a replica is unreachable (#9744)
* operation: bound upload retries and honor context cancellation

retriedUploadData hardcoded 3 attempts and an uninterruptible backoff
sleep. A synchronous replica write to a dead host therefore paid the
full dial timeout three times over before failing.

Add UploadOption.MaxAttempts (<=0 keeps the default of 3) so callers can
cap attempts, and make the loop return as soon as the context is
cancelled so an abandoned upload unwinds instead of retrying.

* topology: fail replica writes fast when a replica is unreachable

DistributedOperation already returns on the first error, but a single
dead replica is itself the slow result: its goroutine retries the upload
three times through the dial timeout (~30s) before any error surfaces,
stalling the originating client write the whole time.

Make the replica write a single attempt (MaxAttempts=1) so a dead
replica fails after one dial timeout instead of three, and thread a
context into DistributedOperation that is cancelled once the outcome is
decided, so a healthy replica is no longer held hostage by one stalled
in a dial. The originating client write is what retries.

* topology: keep replica deletes off the client request context

ReplicatedDelete runs after the local needle is already deleted. Driving
the replica deletes off r.Context() means a client disconnect cancels
them and orphans needles on the replicas, so use a background context.

* operation, topology: trim comments on the replica fail-fast path
2026-05-30 10:45:02 -07:00
Chris Lu 5834c834e3 Refine enterprise edition feature blurb in version output and docs 2026-05-30 09:29:06 -07:00
ea33b851e6 fix: return immediately on first error in DistributedOperation (#9740)
* fix: return immediately on first error in DistributedOperation

* simplify DistributedOperation fail-fast to a single buffered channel

Drop the separate errCh: the collector now fails fast on the first error
it reads off the buffered resultCh and returns ret.Error(), so the early
return carries the same [host]: err annotation as the aggregated path and
there is no select race between two channels.

---------

Co-authored-by: Ubuntu User <ubuntu@example.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-05-30 00:14:44 -07:00
Chris LuandGitHub e60d02c339 fix(topology): recover heartbeat-fulled volumes once they shrink (#9742)
A volume removed from writables by the write-assign path stamps fullSince
in RecordAssign, which UpdateVolumeSize's recovery branch needs to re-add
it once it decays back under the limit. A volume removed by the heartbeat
capacity path (SetVolumeCapacityFull) never stamped it, so after the
reported size dropped — vacuum, TTL expiry, bulk deletes — the volume
stayed out of writables forever, even though every heartbeat carried the
smaller size.

Stamp fullSince when the capacity path actually removes a volume from
writables, so the existing recovery branch fires. Gating on the removal
keeps it paired with the caller's activeVolumeCount decrement, matching
RecordAssign. Oversized volumes still stay out, as before.
2026-05-30 00:06:10 -07:00
4b23204023 fix(vacuum): writable volume re-notification after worker VACUUM (#9732)
* fix(vacuum): notify master writable after worker vacuum commit

Add Phase 3 (markWritableOne) that walks vacuumTargets and calls
VolumeMarkWritable on each replica's volume server, mirroring
batchVacuumVolumeCommit's per-replica SetVolumeAvailable. Failures are
logged at WARN; the task does not fail because the vacuum itself
already succeeded. See upstream seaweedfs#9685.

* fix(vacuum): delay Phase 3 to let post-commit heartbeats settle

Phase 3's VolumeMarkWritable can race with the volume server's first
post-commit heartbeat. SetVolumeWritable adds the vid to writables,
but a racing heartbeat whose ReadOnly value changed re-runs
EnsureCorrectWritables against the master's per-replica cache, and any
replica still cached as ReadOnly=true silently removes the vid again
— with no further heartbeat change to trigger another recovery.

Sleep 30s after Phase 2 (Commit) so every replica's post-vacuum
heartbeat has reached the master before Phase 3 fires. Cancel cleanly
on ctx.Done so a shutdown during the wait still exits.

* fix(vacuum): reduce post-commit settle from 30s to 10s

VolumePulsePeriod is 5s, so 10s (2x) is enough margin for every
replica's post-commit heartbeat to reach the master before Phase 3
fires. 30s was overly conservative and made TestVacuumExecutionIntegration
hit its 30s context deadline.

* fix(vacuum): use flat 1m timeout for VolumeMarkWritable RPC

VolumeMarkWritable on the volume server is a metadata operation
(reopen idx + flags + master ReadOnly=false heartbeat), independent
of volume size. Scaling via vacuumTimeout(time.Minute) gave it tens
of minutes — even hours on TB volumes — so a single unresponsive
replica could block Phase 3 indefinitely. Use a flat 1m cap.

* fix(vacuum): gate post-vacuum mark-writable on commit read-only state

Phase 3 force-called VolumeMarkWritable on every replica unconditionally,
clearing the read-only flag and persisting ReadOnly=false even for a
replica left read-only by an operator, an EIO quarantine, or low disk.
That overrode states the master deliberately keeps out of writables;
master built-in vacuum gates the same step on the commit's IsReadOnly via
SetVolumeAvailable.

Capture the VacuumVolumeCommit response and skip Phase 3 when any replica
came back read-only, letting it recover on its own ReadOnly=false
heartbeat. Drop the 10s post-commit settle sleep: the heartbeat race it
guarded needed a replica cached read-only at the master, which the gate
now excludes.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-05-29 23:43:24 -07:00
e5fb547e95 wdclient, dailyrun: add equal jitter to retry backoff (#9737)
* wdclient, dailyrun: add equal jitter to retry backoff

Prevents thundering-herd retries when many clients recover from a
transient failure at the same instant (e.g., filer restart, network
partition healing).

Uses equal jitter: wait in [d/2, d) instead of deterministic d.
This bounds the maximum wait while still desynchronizing clients.

Files:
- weed/wdclient/filer_client.go   (LookupVolumeIds retry loop)
- weed/s3api/s3lifecycle/dailyrun/dispatch.go (dispatchWithRetry)

Tests added for bounds, zero/negative inputs, and distribution sanity.

Closes #9735

* wdclient: honor ctx cancellation during LookupVolumeIds backoff

---------

Co-authored-by: Mohamed Chorfa <mohamed.chorfa@thalesgroup.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-05-29 20:54:54 -07:00
10c4ab3e33 s3, iam, volume, filer, master: add /healthz and /readyz health probes (#9738)
Adds standard Kubernetes liveness/readiness endpoints to all HTTP
servers that were missing them:

- S3:     adds /readyz (already had /healthz)
- IAM:    adds /healthz and /readyz (had none)
- Volume: adds /readyz (already had /healthz)
- Filer:  adds /readyz on default and readonly mux
- Master: adds /healthz and /readyz at root level
  (preserves existing /cluster/healthz)

All endpoints reuse existing health handlers or return 200 OK as a
minimal foundation. Future PRs can enhance /readyz with dependency
checks without breaking the contract.

Closes #9736

Co-authored-by: Mohamed Chorfa <mohamed.chorfa@thalesgroup.com>
2026-05-29 20:45:03 -07:00
Chris LuandGitHub 4c5d1d53b4 Update README.md 2026-05-29 14:34:24 -07:00
Chris Lu ba9e74d8a7 docs: add zyner as a gold sponsor 2026-05-29 12:40:58 -07:00
7y-9andGitHub fbcba51e73 refactor: avoid unused sql insert result (#9734) 2026-05-29 00:45:45 -07:00
Chris LuandGitHub c9623007a2 fix(filer.sync): keep sync_offset fresh through filtered-event markers (#9733)
On a read-only watched path the idle heartbeat keeps sync_offset fresh,
but a busy source filer still emits a MaxUnsyncedEvents marker after many
filtered events. The marker has a non-nil but empty EventNotification, so
the client routed it to the event path, where it advanced no real
watermark yet drove offsetFunc to republish the stale processed
watermark — regressing the gauge between heartbeats and spiking the
derived lag every time a filtered-event burst landed.

Route the empty marker through OnIdleHeartbeat like the idle heartbeat so
its fresh timestamp keeps the gauge current; it still advances the
in-stream resume cursor.
2026-05-28 23:29:59 -07:00
Chris LuandGitHub 5955972fe6 fix(shell): verify volume.merge output before overwriting replicas (#9731)
* fix(shell): verify volume.merge output before overwriting replicas

volume.merge overwrote every replica with the merged copy without checking it was complete. Read back the merged copy and refuse to overwrite unless it holds at least as many live needles as the most complete source replica, leaving the originals intact on a short or empty merge.

* fix(shell): keep merged volume until all replicas are rebuilt

On a copy failure partway through the overwrite loop, the temporary merged copy was deleted along with the half-rebuilt replicas. Stop deleting it until every replica has been rebuilt; on failure the verified copy is kept so the merge can be re-run to completion.

* refactor(shell): reuse readVolumeStatus in ensureVolumeReadonly

* fix(shell): guard against nil volume status response
2026-05-28 19:29:25 -07:00
Chris LuandGitHub 16717b0bf4 fix(s3): authenticate JWT unsigned-streaming uploads (#9729)
A bearer-token client whose SDK appends a CRC32 trailer sends an
unsigned-streaming PUT (STREAMING-UNSIGNED-PAYLOAD-TRAILER) with no SigV4
signature, so getRequestAuthType classifies it as authTypeStreamingUnsigned.
The auth dispatch ignored the bearer token and fell back to anonymous, and
newChunkedReader tried to verify the bearer token as a SigV4 seed signature
and failed, so the body could not be decoded either.

Dispatch the streaming-unsigned auth on whatever credential is present
(SigV4 / JWT / anonymous), and skip the SigV4 seed-signature recompute for
JWT requests in the chunked reader.
2026-05-28 18:10:24 -07:00
Chris LuandGitHub 2f0643e5b1 fix(volume): stop flipping volumes read-only on a non-append-ordered .idx (#9726)
* fix(volume): verify the .dat-tail needle in the integrity check

CheckVolumeDataIntegrity checked the last entry by file position in the .idx
and, for a live needle, flipped the volume read-only when fileSize > fileTailOffset.
That entry is the .dat tail only when the .idx is in append order; a key-sorted
.idx (weed fix and other rebuilds listed entries by key) puts the highest-key
needle last, whose tail sits mid-file, so healthy volumes went read-only on every
load and re-running weed fix only reproduced the sorted index.

Locate the needle at the maximum offset — the one physically last in the .dat —
and verify the .dat ends exactly at it, regardless of .idx ordering. The
append-ordered common case stays O(1) (the last entry's on-disk end matches the
.dat size); only a key-sorted index pays a single linear scan. Deletion
tombstones at the tail are now verified too, instead of skipping the file-size
check.

* fix(command): weed fix rebuilds the .idx in .dat offset order

SaveToIdx wrote entries via AscendingVisit — sorted by key, the .sdx/.ecx shape
— so the rebuilt .idx put the highest-key needle last instead of the .dat-tail
needle, and dropped tombstones whose live needle was gone. Collect the live and
deleted entries, sort by .dat offset, and write them in append order so the .idx
stays a faithful log whose last entry is the real .dat tail.
2026-05-28 18:04:31 -07:00
Chris LuandGitHub 685571d93f fix(s3): allow anonymous unsigned-streaming PutObject (#9727)
Modern botocore attaches a CRC32 trailer to plain PutObject, turning the
payload into STREAMING-UNSIGNED-PAYLOAD-TRAILER. An anonymous upload then
carries that header but no Authorization, so it was classified as
authTypeStreamingUnsigned and sent straight to SigV4 verification, which
rejected it as AccessDenied while explicit credentials kept working.

Fall back to the anonymous identity when an unsigned-streaming request
carries no signature, mirroring the plain anonymous path. The request
stays classified as unsigned-streaming so the chunked body is still
decoded.
2026-05-28 17:00:41 -07:00
Chris LuandGitHub f5b833ab6a test(ec): end-to-end encode over a multi-server multi-disk stuck layout (#9728)
* test(framework): support multiple disks per server in MultiVolumeCluster

StartMultiVolumeClusterWithDisks gives each volume server N data
directories (one DiskLocation each), passed to -dir as a comma list, with
a per-server disk-dir accessor for file inspection. StartMultiVolumeCluster
keeps its one-disk default.

* test(ec): end-to-end encode over a multi-server multi-disk stuck layout

A volume in the stuck state — real .dat source, a 0-byte stub replica, and
partial stale EC shards from an interrupted encode — must converge to one
valid EC layout. Asserts the full shard set across servers, .ecx/.vif kept
per server (info file survives the source-volume delete), stale shards
cleared, and no regular .dat/.idx left behind.
2026-05-28 16:44:42 -07:00
Chris LuandGitHub 3674f9d04d fix(storage): keep EC .vif when deleting a coexisting regular volume (#9723)
* fix(storage): keep EC .vif when deleting a coexisting regular volume

A regular volume and an EC volume for the same id share <base>.vif. When
EC shards are distributed onto a server that still holds the regular
volume — the encode source, or any replica the planner targets — the
post-encode VolumeDelete ran removeVolumeFiles and stripped the shared
.vif, leaving the freshly built EC volume without its info file.

Skip the .vif in removeVolumeFiles when an EC volume for the same id
exists on the disk (mounted, or a sealed .ecx on disk). The regular
volume's .dat/.idx still go; the EC sidecars survive.

A two-server end-to-end test encodes a volume whose source and a stub
replica both also receive shards, and asserts the final on-disk layout:
both .dat/.idx gone, each server holding only its assigned shards plus
.ecx/.vif. Storage unit tests cover the with-EC and no-EC cases, and the
Rust seaweed-volume port carries the same guard and tests.

* test(storage): assert .idx is removed in the no-EC destroy case

Strengthen TestDestroyRemovesVifWhenNoEc to confirm the full regular
volume cleanup (.dat, .idx, .vif) when no EC volume coexists.
2026-05-28 15:39:31 -07:00
Chris LuandGitHub dfd05d14cb refactor(filer): remove the inode->path index and the NFS gateway (#9724)
* fix(filer): derive inodes by hash instead of a snowflake sequencer

Compute the same inode the FUSE mount would: non-hard-linked entries hash path + crtime, hard links hash their shared HardLinkId so every link resolves to one inode. Removes the snowflake inodeSequencer and the SEAWEEDFS_FILER_SNOWFLAKE_ID knob; inodes are now deterministic across filers.

* chore: remove the experimental NFS gateway

The NFS frontend ('weed nfs') was the only consumer of the inode->path index. Remove the weed/server/nfs package, the command and its registration, the integration test harness, and the CI workflow; go mod tidy drops the willscott/go-nfs and go-nfs-client dependencies.

* refactor(filer): drop the inode->path index

With the NFS gateway gone, nothing reads it. A regular file's inode is a pure hash of its path and a hard link's is a hash of its shared HardLinkId -- both derivable on demand -- so the secondary KV index and its write/remove hooks are dead. Removes filer_inode_index.go and the recordInodeIndex hooks from the store wrapper.
2026-05-28 15:00:18 -07:00
3537312045 [docker] add make test_keycloak_s3 for local develop and debug (#9719)
* add make test_keylock_s3 for local develop and debug

* fix typos

* add condition oidc:azp

* docker: reuse test/s3/iam realm and iam config for keycloak dev compose

Point the keycloak dev compose at the existing test/s3/iam configs instead
of a parallel realm/port/key/role set. Adds one declarative realm import
(seaweedfs-test-realm.json) as the single realm source and drops the
duplicated iam.json/s3.json.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-05-28 13:39:32 -07:00
Chris LuandGitHub b1dcb6c52e fix(ec): delete empty stub replicas before distributing EC shards (#9722)
* fix(ec): delete empty stub replicas before distributing EC shards

An interrupted encode can leave a 0-byte .dat replica behind. Until now
the only thing that removed it was deleteOriginalVolume, which runs after
distribute+mount and calls VolumeDelete -> removeVolumeFiles. A regular
volume and an EC volume share the same <collection>_<vid>.vif path, so
deleting the stub at that point strips the .vif out from under the
freshly distributed shards.

Sweep the original replicas with VolumeDelete(OnlyEmpty=true) before
distribute: doIsEmpty uses the same superblock threshold, so only the
0-byte stubs go and any data-bearing replica is refused and kept for the
post-verify delete. Servers cleared in the sweep are skipped by
deleteOriginalVolume so it never touches a server that now holds only EC
shards.

* fix(ec): fail the encode when an empty-replica sweep can't confirm a node

The sweep swallowed every VolumeDelete(OnlyEmpty) error, so a transient
failure on a stub node fell through to the post-verify force-delete on
that node — the shared-.vif clobber the sweep exists to avoid.

Treat only the expected cases (volume not empty, or already gone) as
leave-in-place; any other error propagates and fails the encode, which
rolls back the readonly marks and retries next cycle.
2026-05-28 13:21:24 -07:00
Chris LuandGitHub 691e601e6f fix(ec): prefer credible replica as canonical metric in EC detection (#9717)
* fix(ec): prefer credible replica as canonical metric in EC detection

An interrupted encode can leave a 0-byte .dat replica behind. When that
stub sits on a lower-sorting server than the real replica, the
lowest-server canonical pick reported Size=0, tripped the min-size gate,
and the volume was stranded in skippedTooSmall: detection never proposed
an encode, so the partial EC shards were never cleared and re-distribute
kept hitting the mounted-volume guard.

selectCanonicalMetric now prefers the lowest-server credible replica
(data-bearing, not already EC), falling back to the lowest-server metric
only when nothing is credible so the downstream gates skip as before. A
leftover EC shard set on a lower server no longer short-circuits the
volume at the IsECVolume guard either, so the orphan-source cleanup and
re-encode paths get their chance.

* fix(ec): treat a bare superblock .dat as a stub too

An interrupted encode or copy can write the 8-byte superblock and then
fail, leaving an 8-byte .dat with no data. isStubReplica used a strict <
so that file slipped through as credible, could win the canonical pick on
a low server, and re-tripped the min-size gate. Use <= the superblock so a
data-less .dat never shadows a real replica.
2026-05-28 13:06:21 -07:00
5b1098e2ad fix(s3): honor MetadataDirective=REPLACE for system metadata on CopyObject (#9721)
* fix(s3): honor MetadataDirective=REPLACE for system metadata on CopyObject

* fix(s3): match copy metadata keys case-insensitively for legacy data

Legacy / non-S3 write paths (FUSE mount, direct filer HTTP API, older
versions) may persist Cache-Control etc. in lowercase form. Make
isManagedCopyMetadataKey case-insensitive so mergeCopyMetadata still
clears stale source values under REPLACE, and let the COPY branch of
processMetadataBytes fall back to a lowercase key on the source so
legacy values survive into the destination (re-emitted as canonical).

Mirrors the existing x-amz-meta-* backward-compat path.

* fix(s3): keep legacy non-canonical tag and system metadata across COPY

The previous case-insensitive isManagedCopyMetadataKey caused
mergeCopyMetadata to delete legacy lowercase x-amz-tagging-* and
mixed-case system headers, but the COPY branch in processMetadataBytes
only matched canonical or strict-lowercase keys when re-populating
them, so any non-canonical key was permanently dropped on COPY.

- COPY now scans existing in a single pass and uses strings.EqualFold
  against the system header whitelist, re-emitting under the canonical
  header name. Handles any case folding (CACHE-CONTROL, Cache-control,
  etc.), not just strings.ToLower.
- COPY tagging branch now uses hasPrefixFold(k, AmzObjectTagging) and
  re-emits the canonical X-Amz-Tagging-<suffix>, mirroring the existing
  X-Amz-Meta-* migration path.
- Tests cover lowercase/uppercase/mixed-case system headers and tags
  surviving COPY.

* fix(s3): make COPY of system metadata and tags deterministic across case variants

Single-pass EqualFold matching let Go's randomized map iteration pick
either the canonical or a legacy-cased value when both lived on the
source, so the COPY result varied between calls.

Both COPY branches now use two passes: a canonical-exact lookup first,
then a case-insensitive fallback that only writes when the canonical
slot is still empty. Mirrors the collision-check pattern used by the
X-Amz-Meta-* migration path.

Tests run the canonical-vs-legacy collision 32 times each to exercise
varied map orders.

* fix(s3): apply REPLACE Content-Type on in-place copy

The metadata-only self-copy path never set Attributes.Mime, so a same-key
CopyObject with REPLACE and a new Content-Type silently kept the old type.
Route in place only when the Mime is unchanged; otherwise take the locked
clone path (still metadata-only, reuses source chunks) and set the new Mime
there. Also covers the versioned self-copy path.

* perf(s3): drop per-key ToLower in isManagedCopyMetadataKey

Use the allocation-free hasPrefixFold helper instead of lowercasing the key
and both constant prefixes on every metadata-key check.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-05-28 12:55:08 -07:00
21ab68aa94 chore(weed/storage/backend/s3_backend): remove unused function (#9715)
* chore(weed/storage/backend/s3_backend): remove unused function

* fix(s3_backend): cache session under the composite region|endpoint key

createSession looked up sessions by region|endpoint but stored them by
region alone, so the cache never hit and a new session was built every
call. With getSession gone the lock can also drop to a plain Mutex.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-05-27 22:14:45 -07:00
Chris LuandGitHub 24e664d651 fix(shell): don't halt volume.fsck purge on a stuck read-only volume (#9714)
* fix(shell): don't halt volume.fsck purge on a stuck read-only volume

A failed VolumeMarkWritable on one volume aborted the entire fsck purge
run; per-volume errors now log and continue so remaining volumes still
get purged.

* fix(shell): unify volume.fsck per-volume skip logging at the caller

Return the mark-writable error from purgeOneVolume instead of logging
in two places — the caller already prints "skip purging volume N: %v"
and defers still fire on the error return.

* fix(shell): collect volume.fsck purge-skipped volumes and report at end

Track volume IDs whose purge was skipped (mark-writable failure or
other per-volume errors) and print a sorted summary so operators don't
have to scrape the run log to find them. Deletes for those volumes are
already skipped; this just makes them explicit.
2026-05-27 17:49:35 -07:00
7y-9andGitHub bbbc3925ec fix: validate s3 ownership controls rule (#9684) 2026-05-27 14:41:10 -07:00
69c84801e4 fix(s3tables/iceberg): make metadata spec-compliant and accept real-world manifest names (#9703)
* fix(s3tables/iceberg): make metadata spec-compliant and accept real-world manifest names

Two related issues prevent SeaweedFS S3 Tables from interoperating with
strict Iceberg clients (Java/Spark/Flink/Trino):

1. iceberg-go v0.5.0 serializes empty TableMetadata state by dropping
   keys via `omitempty` on optional pointer/slice fields. The Iceberg
   table spec, however, requires `current-snapshot-id`, `snapshots`,
   `snapshot-log`, `metadata-log`, and `refs` to be present even when
   empty (`current-snapshot-id` must be -1 for a table with no
   snapshots). Java's TableMetadataParser uses JsonUtil.getLong on
   `current-snapshot-id` and throws "Cannot parse missing long
   current-snapshot-id" against responses produced by this server.

2. The Iceberg layout validator only accepts manifest filenames that
   match Iceberg's internal naming (`{uuid}-m{n}.avro`,
   `snap-{n}-{n}-{uuid}.avro`). Real writers — notably Flink's sink —
   emit manifests like
   `{flink-job-id}-{checkpoint}-{operator-id}-{n}.avro`, which the
   validator rejects with 403, breaking INSERT commits.

Fixes:

* Add ensureMetadataSpecCompliance helper that backfills the five
  spec-required empty-state fields when iceberg-go omits them or emits
  explicit JSON null. Apply it on every code path that writes
  v*.metadata.json to S3 or returns metadata to clients
  (handlers_table create-table, handlers_commit, commit_helpers
  create-on-commit, plus MarshalJSON on LoadTableResult and
  CommitTableResponse). Real values from non-empty tables are never
  overwritten.

* Add catch-all regex entries to metadataFilePatterns accepting any
  *.avro / *.metadata.json filename composed of [A-Za-z0-9._-]. The
  Iceberg spec does not mandate filename format; the strict patterns
  remain for documentation. Metadata-directory subdirectory rejection
  and the data-file path validation are unchanged.

No upstream dependencies are forked: iceberg-go stays at v0.5.0 and
go.mod is untouched. The compliance layer can be removed once upstream
emits spec-compliant output.

Tests (all pass under `go test -race`):
- metadata_compliance_test.go: 5 cases covering missing fields,
  preserved real values, explicit null, invalid JSON, empty input.
- iceberg_layout_test.go: 3 groups (16 subtests) covering real-world
  manifest names from Flink/Spark/Iceberg, security boundary
  (subdirectories, bad extensions), and data-file regression.

* fix(s3tables/iceberg): preserve metadata key order and keep config field stable

Two small follow-ups on the spec-compliance fix:

* ensureMetadataSpecCompliance now splices missing keys in at the byte
  level just before the closing brace, so iceberg-go's struct-declared
  key order survives the backfill. The previous unmarshal/remarshal
  through map[string]json.RawMessage silently alphabetized every key in
  the document, which is spec-legal but breaks byte-equality fixtures
  and any downstream hashing of the persisted metadata. The slower
  remarshal path is kept for the rare explicit-null replacement case.

* LoadTableResult.MarshalJSON now serializes Config without omitempty,
  matching the struct field tag. The custom marshaler had silently
  flipped the tag to ,omitempty, which made the "config" key disappear
  from the response whenever s3Endpoint was unset (since
  buildFileIOConfig returned an empty but non-nil Properties map).

Tests:
- PreservesOriginalKeyOrder pins the byte-level output against
  iceberg-go's emitted shape; would have caught the alphabetization
  regression.
- EmptyObjectBackfilled covers the {} -> sentinels-only case (no
  leading comma).
- AllPresentReturnsSameBytes confirms the no-op path returns input
  bytes unchanged, with whitespace intact.
- iceberg_layout_test pins the catch-all $ anchor: metadata/file.avro.txt
  must still be rejected.

* fix(s3tables/iceberg): guard ensureMetadataSpecCompliance against top-level null

json.Unmarshal of a JSON `null` literal succeeds but leaves the map nil.
The current byte-append path no-ops gracefully on this input, but the
slow remarshal path would panic with "assignment to entry in nil map"
if the input ever combined `null` with the explicit-null detection. Add
an explicit nil-map short-circuit so the safety property is obvious
from the source, and a test that pins the contract.

* test(s3tables/iceberg): assert full byte equality in AllPresentReturnsSameBytes

The prefix check only caught a missing "{\n  " opener, so the test
would have passed even if the function silently reordered keys or
collapsed whitespace later in the document. Switch to a full string
comparison so any future regression in the no-op path is loud.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-05-27 13:05:41 -07:00
Chris LuandGitHub 21b4b81edb fix(filer/postgres): default to ON CONFLICT upsert to keep tx alive (#9709)
* fix(filer/postgres): default to ON CONFLICT upsert to keep tx alive

A KvPut from the inode-index secondary write could fail with 23505
(duplicate key) inside a rename's transaction, after which the next
statement returned 25P02 and rename surfaced to FUSE as EIO. Default
the postgres upsert query when enableUpsert=true so INSERTs are
idempotent; the enableUpsert=false escape hatch is preserved for
non-PG-compatible backends.

* fix(filer/mysql): default to ON DUPLICATE KEY UPDATE upsert

Same shape as the postgres default: when enableUpsert=true but no
upsertQuery is configured, install a sensible default so the
inode-index KvPut does not waste a duplicate-key roundtrip on every
entry write. Uses the VALUES() form so the default works on MariaDB
and MySQL >=5.7; the MySQL 8.0.19 row-alias form is left to explicit
config.

* fix(filer): default enableUpsert=true for sql stores

The default-template fallback only kicks in when enableUpsert=true,
so minimal configs that omit the flag entirely were still exposed.
Default it on for postgres/postgres2/mysql/mysql2; an explicit false
in filer.toml still wins because SetDefault only fills absent keys.
2026-05-27 12:23:30 -07:00
Chris LuandGitHub 396e3c326b fix(remote_storage/gcs): forward entry mime as ContentType (#9711)
fix(remote_storage/gcs): forward entry.Attributes.Mime as ContentType

Same gap as the S3 client: filer.remote.sync to GCS never populated
the object's ContentType, so HTML/CSS/etc. ended up stored as the GCS
default and didn't render correctly in browsers.

Mirrors the existing Azure client behavior
(weed/remote_storage/azure/azure_storage_client.go).
2026-05-27 12:21:27 -07:00
Chris LuandGitHub 9cb9699e9d fix(replication/s3sink): forward entry mime as ContentType (#9710)
* fix(replication/s3sink): forward entry.Attributes.Mime as ContentType

Same gap as the remote_storage S3 client: filer.replicate uploads via
s3manager.Uploader without populating ContentType, so replicated objects
on S3-compatible backends (e.g. Backblaze B2) store binary/octet-stream
and browsers refuse to render HTML, CSS, etc.

Pass entry.Attributes.Mime through to UploadInput.ContentType, leaving
the header unset when no Mime is recorded so the remote keeps its own
default.

* fix(replication/s3sink): nil-guard entry.Attributes when reading Mime

* Revert "fix(replication/s3sink): nil-guard entry.Attributes when reading Mime"

This reverts commit 08c3698e44.

The function already dereferences entry.Attributes.Mtime and
entry.Attributes.Md5 unconditionally on the same path, so a nil guard
on Mime alone is inconsistent and provides no real safety.
2026-05-27 12:20:51 -07:00
Chris LuandGitHub 629beda1eb fix(remote_storage/s3): forward entry mime as ContentType (#9708)
fix(remote_storage/s3): forward entry.Attributes.Mime as ContentType

filer.remote.sync was uploading every object without a Content-Type, so
S3-compatible backends (e.g. Backblaze B2) stored binary/octet-stream
and browsers refused to render HTML, CSS, etc.

Pass entry.Attributes.Mime through to UploadInput.ContentType, leaving
the header unset when no Mime is recorded so the remote keeps its own
default behavior.
2026-05-27 12:13:01 -07:00
Chris LuandGitHub c3255b51fd fix(volume): avoid panic when URL path has a dot before the comma (#9712)
LastIndex returns -1 when the separator is missing and can return any
position when both are present. A path like /vol/file.jpg,abc gives
dotSep<commaSep, so path[commaSep+1:dotSep] slices with start>end and
panics. Only treat the dot as an extension boundary when it sits after
the comma.
2026-05-27 11:29:11 -07:00
Chris LuandGitHub 65d557cbb0 fix(util): guard BytesToUint{16,32,64} against short input (#9713)
* fix(util): guard BytesToUint{16,32,64} against short input

length is computed as uint, so length-1 on an empty slice underflows
to MaxUint and the loop indexes b[0] on a zero-length slice. BytesToUint16
also indexed b[0]/b[1] with no length check. All call sites today gate
the slice length explicitly, so this hardens the API for new callers
rather than fixing a live crash.

Return 0 on short input, matching the existing variable-length contract.

* BytesToUint16: match variable-length contract of the 32/64 helpers

A 1-byte slice should return uint16(b[0]) rather than 0, matching how
BytesToUint32 and BytesToUint64 treat short input.
2026-05-27 11:29:01 -07:00
Jaehoon KimandGitHub d00acded8a fix(vacuum): batch all replicas in a single plugin worker task (#9702)
* fix(vacuum): batch all replicas in a single plugin worker task

The plugin worker vacuum path emitted one TaskDetectionResult per
(volume, server) replica, but the dispatcher gates duplicate tasks per
volume via ActiveTopology.HasAnyTask. The first replica's task was
created and the remaining N-1 replicas were silently dropped, so only
one replica per volume was ever vacuumed — leaving the others with all
their garbage intact.

Mirror the master built-in flow (topology.vacuumOneVolumeId →
batchVacuumVolumeCheck/Compact/Commit/Cleanup) by:

- aggregating detection metrics by VolumeID so a single task carries
  every replica in TaskParams.Sources
- having VacuumTask accept []string servers (instead of a single
  string), re-check each replica's garbage ratio at execute time to
  derive a vacuumTargets subset, and run Compact/Commit/Cleanup against
  only that subset
- updating the dispatcher (plugin_handler.Execute, register.CreateTask)
  to forward every Sources node to NewVacuumTask

* fix(vacuum): run all-replica vacuum in two phases to keep failure atomic

The prior implementation iterated Compact → Commit → Cleanup against
each replica in sequence. A Compact failure on the second replica left
the first one already committed (its active files swapped with the
.cp* files), producing replica divergence with no automatic recovery.

Split performVacuum into two phases, matching topology.vacuumOneVolumeId:

  Phase 1 — Compact all targets. If any fails, run VacuumVolumeCleanup
  on every target to drop the .cpd/.cpx/.cpldb temp files, then abort.
  No replica has swapped yet, so every replica returns to its original
  state.

  Phase 2 — Commit all targets. Best-effort, matching
  batchVacuumVolumeCommit: per-replica errors are collected and
  surfaced together. Once any replica has swapped there is no clean
  rollback, so a partial Phase 2 failure requires operator
  reconciliation.

Adds compactOne / commitOne / cleanupOne / cleanupAll helpers and
removes the old performVacuumOne.

* fix(vacuum): abort when any replica's garbage check fails

The prior check tolerated per-replica RPC errors and only failed the
task if every replica errored — partial failures were silently treated
as "ineligible" so the responding replicas would still be vacuumed.
That produces divergence the moment the unreachable replica comes
back: it still carries the original garbage while the others have
been compacted.

Match topology.batchVacuumVolumeCheck's contract instead — its return
value (errCount == 0 && len(vacuumLocationList.list) > 0) gates the
whole vacuum on every replica's check succeeding. If any replica is
unreachable or its VacuumVolumeCheck RPC errors, abort the task; the
volume will be retried on the next detection cycle once the replica
is healthy.

* fix(vacuum): guard against nil metrics and TaskSource entries

Detection's bucket-building loop dereferenced m.VolumeID without
checking m for nil. VacuumTask.Validate built sourceSet from
params.Sources without checking each entry for nil. Both paths would
panic on a malformed protobuf payload that managed to deliver a nil
slot. Skip nil entries in both loops — neutral with the existing
nil/empty filtering already done in register.CreateTask and
plugin_handler.Execute.

* test(vacuum): success path no longer calls VacuumVolumeCleanup

The plugin worker vacuum is now two-phase (Compact-all → Commit-all,
with Cleanup only invoked on Compact failure to roll back .cp* temp
files). This matches topology.vacuumOneVolumeId, where
batchVacuumVolumeCleanup runs only on the Compact-failure branch.

On a successful Commit the temp files do not linger:
  - CommitCompactVolume renames .cpd → .dat and .cpx → .idx
  - leveldb needle map renames .cpldb → .ldb (needle_map_leveldb.go)

so calling VacuumVolumeCleanup afterwards is a redundant no-op. The
prior worker code called it unconditionally and the integration test
asserted that — switch the expectation to cleanupCalls == 0 to
document the new (and master-aligned) contract.
2026-05-27 11:15:25 -07:00
515 changed files with 25989 additions and 12473 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ jobs:
org.opencontainers.image.vendor=Chris Lu
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v4.1.0
- name: Create BuildKit config
run: |
@@ -126,7 +126,7 @@ jobs:
echo "seaweedfs_ref=$seaweed" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+1 -1
View File
@@ -207,7 +207,7 @@ jobs:
org.opencontainers.image.vendor=Chris Lu
- name: Set up QEMU
if: matrix.platform != 'amd64'
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v4.1.0
- name: Create BuildKit config
run: |
cat > /tmp/buildkitd.toml <<EOF
@@ -43,7 +43,7 @@ jobs:
org.opencontainers.image.vendor=Chris Lu
-
name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v4.1.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+20 -60
View File
@@ -118,8 +118,9 @@ jobs:
needs: [build-rust-binaries]
runs-on: ubuntu-latest
strategy:
# Build sequentially to avoid rate limits
max-parallel: 2
# All variants at once: pulls hit mirror.gcr.io and pushes go to GHCR only,
# so docker.io limits don't apply. Watch the 10 GB gha cache budget.
max-parallel: 5
matrix:
include:
# Normal volume - multi-arch
@@ -219,7 +220,7 @@ jobs:
- name: Set up QEMU
if: (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant) && contains(matrix.platforms, 'arm')
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v4.1.0
- name: Create BuildKit config
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
@@ -272,93 +273,52 @@ jobs:
BRANCH=${{ github.sha }}
${{ matrix.variant == 'rocksdb' && format('ROCKSDB_VERSION={0}', github.event.inputs.rocksdb_version || 'v10.10.1') || '' }}
- name: Clean up build artifacts
if: always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant)
run: |
sudo docker system prune -f
sudo rm -rf /tmp/go-build*
copy-to-dockerhub:
runs-on: ubuntu-latest
needs: [build]
if: github.event_name != 'pull_request'
strategy:
matrix:
variant: [normal, large_disk, full, large_disk_full, rocksdb]
include:
- variant: normal
tag_suffix: ""
- variant: large_disk
tag_suffix: _large_disk
- variant: full
tag_suffix: _full
- variant: large_disk_full
tag_suffix: _large_disk_full
- variant: rocksdb
tag_suffix: _large_disk_rocksdb
steps:
- name: Login to Docker Hub
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
uses: docker/login-action@v4.2.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
uses: docker/login-action@v4.2.0
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
# Copy GHCR -> Docker Hub here, per variant, so it overlaps with the other
# variants still building instead of waiting on the whole matrix.
- name: Install crane
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
if: (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant) && github.event_name != 'pull_request'
run: |
cd $(mktemp -d)
curl -sL "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_x86_64.tar.gz" | tar xz
sudo mv crane /usr/local/bin/
crane version
- name: Copy ${{ matrix.variant }} from GHCR to Docker Hub
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
- name: Copy ${{ matrix.variant }} to Docker Hub
if: (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant) && github.event_name != 'pull_request'
run: |
# Function to retry with exponential backoff
retry_with_backoff() {
local max_attempts=5
local timeout=1
local attempt=1
local exit_code=0
while [ $attempt -le $max_attempts ]; do
if "$@"; then
return 0
else
exit_code=$?
fi
if [ $attempt -lt $max_attempts ]; then
echo "Attempt $attempt failed. Retrying in ${timeout}s..." >&2
sleep $timeout
timeout=$((timeout * 2))
fi
attempt=$((attempt + 1))
done
echo "Command failed after $max_attempts attempts" >&2
return $exit_code
}
# Copy multi-arch image from GHCR to Docker Hub with retry
# This is much more efficient than pulling/pushing individual arch images
echo "Copying ${{ matrix.variant }} from GHCR to Docker Hub..."
retry_with_backoff crane copy \
ghcr.io/chrislusf/seaweedfs:${{ env.RELEASE_TAG }}${{ matrix.tag_suffix }} \
chrislusf/seaweedfs:${{ env.RELEASE_TAG }}${{ matrix.tag_suffix }}
echo "Successfully copied ${{ matrix.variant }} to Docker Hub"
echo "Copied ${{ matrix.variant }} to Docker Hub"
- name: Clean up build artifacts
if: always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant)
run: |
sudo docker system prune -f
sudo rm -rf /tmp/go-build*
# Report-only trivy scan: uploads fixable HIGH/CRITICAL findings to GitHub
# Security for visibility, but never blocks the release. Releases (including
@@ -418,7 +378,7 @@ jobs:
# trivy-scan: vuln findings are reported but do not block `latest`.
tag-latest:
runs-on: ubuntu-latest
needs: [copy-to-dockerhub]
needs: [build]
if: github.event_name == 'push'
strategy:
matrix:
@@ -483,7 +443,7 @@ jobs:
helm-release:
runs-on: ubuntu-latest
needs: [copy-to-dockerhub]
needs: [build]
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
permissions:
contents: write
@@ -82,7 +82,7 @@ jobs:
echo "seaweedfs_ref=$seaweed" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v1
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v1
+5
View File
@@ -31,6 +31,11 @@ jobs:
with:
go-version-file: 'go.mod'
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+65 -23
View File
@@ -51,22 +51,22 @@ jobs:
echo "=== Testing default configuration ==="
helm template test $CHART_DIR > /tmp/default.yaml
echo "✓ Default configuration renders successfully"
echo "Default configuration renders successfully"
echo "=== Testing with S3 enabled ==="
helm template test $CHART_DIR --set s3.enabled=true > /tmp/s3.yaml
grep -q "kind: Deployment" /tmp/s3.yaml && grep -q "seaweedfs-s3" /tmp/s3.yaml
echo "✓ S3 deployment renders correctly"
echo "S3 deployment renders correctly"
echo "=== Testing with all-in-one mode ==="
helm template test $CHART_DIR --set allInOne.enabled=true > /tmp/allinone.yaml
grep -q "seaweedfs-all-in-one" /tmp/allinone.yaml
echo "✓ All-in-one deployment renders correctly"
echo "All-in-one deployment renders correctly"
echo "=== Testing with security enabled ==="
helm template test $CHART_DIR --set global.seaweedfs.enableSecurity=true > /tmp/security.yaml
grep -q "security-config" /tmp/security.yaml
echo "✓ Security configuration renders correctly"
echo "Security configuration renders correctly"
echo ""
echo "=== Testing IAM gRPC opt-in path ==="
@@ -121,7 +121,7 @@ jobs:
if configmap(out, "test-seaweedfs-security-config") is not None:
failed.append("defaults: security ConfigMap should not render")
else:
print("✓ defaults: no security-config ConfigMap (unchanged)")
print("defaults: no security-config ConfigMap (unchanged)")
# Case 2: filerWrite=true alone is the documented opt-in for
# the Admin UI Users tab. Configmap must render with
@@ -141,7 +141,7 @@ jobs:
if "[grpc" in toml:
failed.append("filerWrite=true: security.toml unexpectedly has [grpc.*] (would need cert mounts)")
if "[jwt.filer_signing]" in toml and "[grpc" not in toml:
print("✓ filerWrite=true: security.toml has [jwt.filer_signing], no [grpc.*]")
print("filerWrite=true: security.toml has [jwt.filer_signing], no [grpc.*]")
# Case 3: filer + admin pods must MOUNT the security ConfigMap
# under filerWrite=true so the JWT key reaches both processes.
@@ -154,7 +154,7 @@ jobs:
if "security-config" not in vols or "security-config" not in mounts:
failed.append(f"filerWrite=true: {wl} does not mount security-config (IAM gRPC would still fail)")
else:
print(f"✓ filerWrite=true: {wl} mounts security-config")
print(f"filerWrite=true: {wl} mounts security-config")
cert_vols = {v for v in vols if v.endswith("-cert")}
if cert_vols:
failed.append(f"filerWrite=true: {wl} unexpectedly has cert volumes {sorted(cert_vols)}")
@@ -172,7 +172,7 @@ jobs:
if missing:
failed.append(f"enableSecurity=true: security.toml missing {missing}")
else:
print("✓ enableSecurity=true: security.toml has [jwt.signing] + [grpc.*] preserved")
print("enableSecurity=true: security.toml has [jwt.signing] + [grpc.*] preserved")
# Case 5: helper must tolerate explicit nulls (gemini-code-assist
# PR review). securityConfig=null was the parens-pattern crash
@@ -187,7 +187,7 @@ jobs:
if configmap(out, "test-seaweedfs-security-config") is not None:
failed.append(f"{null_path}=null: should not render configmap")
else:
print(f"✓ {null_path}=null: render tolerates explicit null")
print(f"{null_path}=null: render tolerates explicit null")
if failed:
print("\nFAIL:", file=sys.stderr)
@@ -195,14 +195,14 @@ jobs:
print(f" - {f}", file=sys.stderr)
sys.exit(1)
PYEOF
echo "✓ IAM gRPC decoupling tests passed"
echo "IAM gRPC decoupling tests passed"
echo "=== Testing with monitoring enabled ==="
helm template test $CHART_DIR \
--set global.seaweedfs.monitoring.enabled=true \
--set global.seaweedfs.monitoring.gatewayHost=prometheus \
--set global.seaweedfs.monitoring.gatewayPort=9091 > /tmp/monitoring.yaml
echo "✓ Monitoring configuration renders correctly"
echo "Monitoring configuration renders correctly"
echo "=== Testing with PVC storage ==="
helm template test $CHART_DIR \
@@ -210,25 +210,25 @@ jobs:
--set master.data.size=10Gi \
--set master.data.storageClass=standard > /tmp/pvc.yaml
grep -q "PersistentVolumeClaim" /tmp/pvc.yaml
echo "✓ PVC configuration renders correctly"
echo "PVC configuration renders correctly"
echo "=== Testing with custom replicas ==="
helm template test $CHART_DIR \
--set master.replicas=3 \
--set filer.replicas=2 \
--set volume.replicas=3 > /tmp/replicas.yaml
echo "✓ Custom replicas configuration renders correctly"
echo "Custom replicas configuration renders correctly"
echo "=== Testing filer with S3 gateway ==="
helm template test $CHART_DIR \
--set filer.s3.enabled=true \
--set filer.s3.enableAuth=true > /tmp/filer-s3.yaml
echo "✓ Filer S3 gateway renders correctly"
echo "Filer S3 gateway renders correctly"
echo "=== Testing SFTP enabled ==="
helm template test $CHART_DIR --set sftp.enabled=true > /tmp/sftp.yaml
grep -q "seaweedfs-sftp" /tmp/sftp.yaml
echo "✓ SFTP deployment renders correctly"
echo "SFTP deployment renders correctly"
echo "=== Testing ingress configurations ==="
helm template test $CHART_DIR \
@@ -237,12 +237,12 @@ jobs:
--set s3.enabled=true \
--set s3.ingress.enabled=true > /tmp/ingress.yaml
grep -q "kind: Ingress" /tmp/ingress.yaml
echo "✓ Ingress configurations render correctly"
echo "Ingress configurations render correctly"
echo "=== Testing COSI driver ==="
helm template test $CHART_DIR --set cosi.enabled=true > /tmp/cosi.yaml
grep -q "seaweedfs-cosi" /tmp/cosi.yaml
echo "✓ COSI driver renders correctly"
echo "COSI driver renders correctly"
echo ""
echo "=== Testing long release name: service names match DNS references ==="
@@ -285,7 +285,7 @@ jobs:
[ "$MASTER_SVC" = "$MASTER_ADDR_SVC" ] || { echo "FAIL: master service name mismatch"; exit 1; }
[ "$FILER_CLIENT_SVC" = "$FILER_ADDR_SVC" ] || { echo "FAIL: filer-client service name mismatch"; exit 1; }
[ "$FILER_CLIENT_SVC" = "$S3_FILER_SVC" ] || { echo "FAIL: S3 -filer= does not match filer-client service"; exit 1; }
echo "✓ Normal mode: service names match DNS references with long release name"
echo "Normal mode: service names match DNS references with long release name"
# --- All-in-one mode: all-in-one service vs both helper addresses ---
helm template "$LONG_RELEASE" $CHART_DIR \
@@ -305,7 +305,7 @@ jobs:
[ "$AIO_SVC" = "$AIO_MASTER_ADDR_SVC" ] || { echo "FAIL: all-in-one master address mismatch"; exit 1; }
[ "$AIO_SVC" = "$AIO_FILER_ADDR_SVC" ] || { echo "FAIL: all-in-one filer address mismatch"; exit 1; }
echo "✓ All-in-one mode: service names match DNS references with long release name"
echo "All-in-one mode: service names match DNS references with long release name"
echo ""
echo "=== Testing security+S3: no blank lines in shell command blocks ==="
@@ -343,7 +343,7 @@ jobs:
print(f"FAIL: {e}", file=sys.stderr)
print("Rendered with: global.seaweedfs.enableSecurity=true, filer.s3.enabled=true, s3.enabled=true, allInOne.enabled=true", file=sys.stderr)
sys.exit(1)
print("✓ No blank lines in security+S3 command blocks")
print("No blank lines in security+S3 command blocks")
PYEOF
echo ""
@@ -421,7 +421,7 @@ jobs:
f"(got cert={has_cert} key={has_key} https={has_https})", file=sys.stderr)
failed = True
else:
print(f"✓ {label}: cert/key/https args emitted together")
print(f"{label}: cert/key/https args emitted together")
else:
if has_cert or has_key or has_https:
print(f"FAIL: {label}: expected none of {cert_flag}/{key_flag}/{https_flag}; "
@@ -429,7 +429,7 @@ jobs:
f"(got cert={has_cert} key={has_key} https={has_https})", file=sys.stderr)
failed = True
else:
print(f"✓ {label}: no TLS args emitted, main -port stays HTTP")
print(f"{label}: no TLS args emitted, main -port stays HTTP")
# bash -n: pin down that the rendered script parses. Guards against
# a future helper change that leaves a dangling `\` with nothing
@@ -446,7 +446,49 @@ jobs:
sys.exit(1 if failed else 0)
PYEOF
echo "✅ All template rendering tests passed!"
echo ""
echo "=== Testing all-in-one env: a key in both global and component renders once ==="
# Regression: all-in-one looped global and component extraEnvironmentVars
# in two separate ranges, emitting duplicate env entries for any key set
# in both maps. Render a shared key and assert it appears exactly once in
# the all-in-one container, with the component value winning (consistent
# with the merge helper the other components already use). pyyaml is
# installed by the earlier IAM gRPC block in this same step.
helm template test $CHART_DIR \
--set allInOne.enabled=true \
--set global.seaweedfs.extraEnvironmentVars.WEED_SHARED=fromGlobal \
--set allInOne.extraEnvironmentVars.WEED_SHARED=fromComponent > /tmp/aio-env.yaml
python3 - /tmp/aio-env.yaml <<'PYEOF'
import sys, yaml
from collections import Counter
docs = [d for d in yaml.safe_load_all(open(sys.argv[1])) if d]
dep = next(d for d in docs if d.get("kind") == "Deployment"
and d["metadata"]["name"].endswith("all-in-one"))
envs = [e["name"] for c in dep["spec"]["template"]["spec"]["containers"]
for e in c.get("env", [])]
dups = {k: v for k, v in Counter(envs).items() if v > 1}
if dups:
print(f"FAIL: duplicate env entries in all-in-one container: {dups}", file=sys.stderr)
sys.exit(1)
val = next(e.get("value") for c in dep["spec"]["template"]["spec"]["containers"]
for e in c.get("env", []) if e["name"] == "WEED_SHARED")
if val != "fromComponent":
print(f"FAIL: WEED_SHARED should take the component value 'fromComponent', got '{val}'",
file=sys.stderr)
sys.exit(1)
print("all-in-one env: shared key renders once, component value wins")
PYEOF
echo "=== Testing bucket versioning: YAML bool false suspends like string \"false\" ==="
# bool false used to be a silent no-op while string "false" suspended.
BOOL_FALSE=$(helm template test $CHART_DIR \
--set s3.enabled=true \
--set s3.createBuckets[0].name=verbucket \
--set s3.createBuckets[0].versioning=false | grep 's3.bucket.versioning -name verbucket' || true)
echo "$BOOL_FALSE" | grep -q -- '-status Suspended' || { echo "FAIL: bool false versioning did not Suspend the bucket"; exit 1; }
echo "Bucket versioning: YAML bool false suspends consistently with string \"false\""
echo "All template rendering tests passed!"
- name: Create kind cluster
uses: helm/kind-action@v1.14.0
+5
View File
@@ -32,6 +32,11 @@ jobs:
**/go.sum
id: go
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
-137
View File
@@ -1,137 +0,0 @@
name: "NFS Integration Tests"
on:
push:
branches: [ master, main ]
paths:
- 'weed/server/nfs/**'
- 'weed/command/nfs.go'
- 'weed/filer/filer_inode.go'
- 'weed/filer/filer_inode_index.go'
- 'weed/filer/filerstore_wrapper.go'
- 'weed/server/filer_grpc_server_rename.go'
- 'test/nfs/**'
- '.github/workflows/nfs-tests.yml'
pull_request:
branches: [ master, main ]
paths:
- 'weed/server/nfs/**'
- 'weed/command/nfs.go'
- 'weed/filer/filer_inode.go'
- 'weed/filer/filer_inode_index.go'
- 'weed/filer/filerstore_wrapper.go'
- 'weed/server/filer_grpc_server_rename.go'
- 'test/nfs/**'
- '.github/workflows/nfs-tests.yml'
concurrency:
group: ${{ github.head_ref }}/nfs-tests
cancel-in-progress: true
permissions:
contents: read
env:
TEST_TIMEOUT: '15m'
jobs:
nfs-integration:
name: NFS Integration Testing
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Build SeaweedFS
run: |
cd weed
go build -o weed .
chmod +x weed
./weed version
- name: Run NFS Integration Tests
run: |
cd test/nfs
echo "Running NFS integration tests..."
echo "============================================"
# Install test dependencies
go mod download
# Run the protocol-layer tests. The kernel-mount tests require root
# for mount(2) and are exercised in their own privileged step below;
# skip them here so a "skipped because not root" line doesn't show
# up as noise on every CI run.
go test -v -timeout=${{ env.TEST_TIMEOUT }} -skip '^TestKernelMount' ./...
echo "============================================"
echo "NFS integration tests completed"
- name: Install kernel NFS client
run: |
# nfs-common provides mount.nfs; netbase provides /etc/protocols
# which mount.nfs's protocol-name lookups (`tcp`, `udp`) need.
sudo apt-get update
sudo apt-get install -y nfs-common netbase
- name: Run kernel-mount E2E tests
run: |
cd test/nfs
echo "Running kernel-mount end-to-end tests..."
echo "These mount the running 'weed nfs' subprocess via the actual"
echo "Linux NFS client to catch protocol regressions invisible to"
echo "the go-nfs-client-based tests above."
echo "============================================"
# mount(2) is privileged. Preserve PATH so 'go' (and the weed
# binary that test/nfs/framework.go locates via $PATH) resolve
# correctly under sudo, and pass through the Go module/cache dirs
# so we don't redownload modules under root.
sudo env "PATH=$PATH" \
GOMODCACHE="$(go env GOMODCACHE)" \
GOCACHE="$(go env GOCACHE)" \
go test -v -timeout=${{ env.TEST_TIMEOUT }} -run '^TestKernelMount' ./...
echo "============================================"
echo "Kernel-mount E2E tests completed"
- name: Test Summary
if: always()
run: |
echo "## NFS Integration Test Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Test Coverage" >> $GITHUB_STEP_SUMMARY
echo "- **Read/Write Round Trip**: Basic file create + read" >> $GITHUB_STEP_SUMMARY
echo "- **Directory Operations**: Mkdir, ReadDirPlus, RmDir" >> $GITHUB_STEP_SUMMARY
echo "- **Nested Directories**: Deep tree creation and leaf I/O" >> $GITHUB_STEP_SUMMARY
echo "- **Rename**: Content preserved across rename" >> $GITHUB_STEP_SUMMARY
echo "- **Overwrite + Truncate**: Setattr(size=0) + shorter write" >> $GITHUB_STEP_SUMMARY
echo "- **Large Files**: 3 MiB binary round trip" >> $GITHUB_STEP_SUMMARY
echo "- **Edge Payloads**: All 256 byte values + empty files" >> $GITHUB_STEP_SUMMARY
echo "- **Symlinks**: Symlink + Lookup" >> $GITHUB_STEP_SUMMARY
echo "- **Missing Path**: Remove on missing entry errors cleanly" >> $GITHUB_STEP_SUMMARY
echo "- **FSINFO**: Non-zero rtpref/wtpref advertised" >> $GITHUB_STEP_SUMMARY
echo "- **Sequential Append**: Two-part concatenation" >> $GITHUB_STEP_SUMMARY
echo "- **ReadDir After Remove**: Meta cache does not serve stale entries" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Kernel-Mount E2E Coverage" >> $GITHUB_STEP_SUMMARY
echo "- **V3 over TCP**: baseline NFSv3 mount + readdir" >> $GITHUB_STEP_SUMMARY
echo "- **V3 with mountproto=udp**: regression test for UDP MOUNT v3 responder" >> $GITHUB_STEP_SUMMARY
echo "- **V4 rejects cleanly**: regression test for the v4 PROG_MISMATCH path (#9262)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Harness" >> $GITHUB_STEP_SUMMARY
echo "Most tests boot their own master + volume + filer + nfs subprocess" >> $GITHUB_STEP_SUMMARY
echo "stack on loopback and drive it via the NFSv3 RPC protocol using" >> $GITHUB_STEP_SUMMARY
echo "go-nfs-client. The kernel-mount E2E tests reuse the same harness" >> $GITHUB_STEP_SUMMARY
echo "but mount the export through the in-tree Linux NFS client to" >> $GITHUB_STEP_SUMMARY
echo "catch protocol regressions a Go-only client can't see; they run" >> $GITHUB_STEP_SUMMARY
echo "in a separate privileged step (mount(2) requires root)." >> $GITHUB_STEP_SUMMARY
+5
View File
@@ -31,6 +31,11 @@ jobs:
- name: Check out code
uses: actions/checkout@v6
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+43 -12
View File
@@ -66,34 +66,42 @@ jobs:
SEAWEEDFS_COMMIT: ${{ github.sha }}
run: |
cd seaweed-volume
cargo build --release --target ${{ matrix.target }}
cargo build --release --target ${{ matrix.target }} --target-dir target/large-disk
- name: Build Rust volume server (normal)
env:
SEAWEEDFS_COMMIT: ${{ github.sha }}
run: |
cd seaweed-volume
cargo build --release --target ${{ matrix.target }} --no-default-features
cargo build --release --target ${{ matrix.target }} --no-default-features --target-dir target/normal
- name: Package binaries
run: |
# Large disk (default, 5bytes feature)
cp seaweed-volume/target/${{ matrix.target }}/release/weed-volume weed-volume-large-disk
cp seaweed-volume/target/large-disk/${{ matrix.target }}/release/weed-volume weed-volume-large-disk
tar czf weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz weed-volume-large-disk
rm weed-volume-large-disk
# Normal volume size
cp seaweed-volume/target/${{ matrix.target }}/release/weed-volume weed-volume-normal
cp seaweed-volume/target/normal/${{ matrix.target }}/release/weed-volume weed-volume-normal
tar czf weed-volume_${{ matrix.asset_suffix }}.tar.gz weed-volume-normal
rm weed-volume-normal
- name: Generate md5 checksums
run: |
for f in weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz weed-volume_${{ matrix.asset_suffix }}.tar.gz; do
md5sum "$f" > "$f.md5"
done
- name: Upload release assets
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v3
with:
files: |
weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz
weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz.md5
weed-volume_${{ matrix.asset_suffix }}.tar.gz
weed-volume_${{ matrix.asset_suffix }}.tar.gz.md5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -104,7 +112,9 @@ jobs:
name: rust-volume-${{ matrix.asset_suffix }}
path: |
weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz
weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz.md5
weed-volume_${{ matrix.asset_suffix }}.tar.gz
weed-volume_${{ matrix.asset_suffix }}.tar.gz.md5
build-rust-volume-darwin:
permissions:
@@ -145,32 +155,40 @@ jobs:
SEAWEEDFS_COMMIT: ${{ github.sha }}
run: |
cd seaweed-volume
cargo build --release --target ${{ matrix.target }}
cargo build --release --target ${{ matrix.target }} --target-dir target/large-disk
- name: Build Rust volume server (normal)
env:
SEAWEEDFS_COMMIT: ${{ github.sha }}
run: |
cd seaweed-volume
cargo build --release --target ${{ matrix.target }} --no-default-features
cargo build --release --target ${{ matrix.target }} --no-default-features --target-dir target/normal
- name: Package binaries
run: |
cp seaweed-volume/target/${{ matrix.target }}/release/weed-volume weed-volume-large-disk
cp seaweed-volume/target/large-disk/${{ matrix.target }}/release/weed-volume weed-volume-large-disk
tar czf weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz weed-volume-large-disk
rm weed-volume-large-disk
cp seaweed-volume/target/${{ matrix.target }}/release/weed-volume weed-volume-normal
cp seaweed-volume/target/normal/${{ matrix.target }}/release/weed-volume weed-volume-normal
tar czf weed-volume_${{ matrix.asset_suffix }}.tar.gz weed-volume-normal
rm weed-volume-normal
- name: Generate md5 checksums
run: |
for f in weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz weed-volume_${{ matrix.asset_suffix }}.tar.gz; do
md5 -r "$f" > "$f.md5"
done
- name: Upload release assets
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v3
with:
files: |
weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz
weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz.md5
weed-volume_${{ matrix.asset_suffix }}.tar.gz
weed-volume_${{ matrix.asset_suffix }}.tar.gz.md5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -181,7 +199,9 @@ jobs:
name: rust-volume-${{ matrix.asset_suffix }}
path: |
weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz
weed-volume_large_disk_${{ matrix.asset_suffix }}.tar.gz.md5
weed-volume_${{ matrix.asset_suffix }}.tar.gz
weed-volume_${{ matrix.asset_suffix }}.tar.gz.md5
build-rust-volume-windows:
permissions:
@@ -213,33 +233,42 @@ jobs:
SEAWEEDFS_COMMIT: ${{ github.sha }}
run: |
cd seaweed-volume
cargo build --release
cargo build --release --target-dir target/large-disk
- name: Build Rust volume server (normal)
env:
SEAWEEDFS_COMMIT: ${{ github.sha }}
run: |
cd seaweed-volume
cargo build --release --no-default-features
cargo build --release --no-default-features --target-dir target/normal
- name: Package binaries
shell: bash
run: |
cp seaweed-volume/target/release/weed-volume.exe weed-volume-large-disk.exe
cp seaweed-volume/target/large-disk/release/weed-volume.exe weed-volume-large-disk.exe
7z a weed-volume_large_disk_windows_amd64.zip weed-volume-large-disk.exe
rm weed-volume-large-disk.exe
cp seaweed-volume/target/release/weed-volume.exe weed-volume-normal.exe
cp seaweed-volume/target/normal/release/weed-volume.exe weed-volume-normal.exe
7z a weed-volume_windows_amd64.zip weed-volume-normal.exe
rm weed-volume-normal.exe
- name: Generate md5 checksums
shell: bash
run: |
for f in weed-volume_large_disk_windows_amd64.zip weed-volume_windows_amd64.zip; do
md5sum "$f" > "$f.md5"
done
- name: Upload release assets
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v3
with:
files: |
weed-volume_large_disk_windows_amd64.zip
weed-volume_large_disk_windows_amd64.zip.md5
weed-volume_windows_amd64.zip
weed-volume_windows_amd64.zip.md5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -250,4 +279,6 @@ jobs:
name: rust-volume-windows_amd64
path: |
weed-volume_large_disk_windows_amd64.zip
weed-volume_large_disk_windows_amd64.zip.md5
weed-volume_windows_amd64.zip
weed-volume_windows_amd64.zip.md5
@@ -28,6 +28,11 @@ jobs:
go-version-file: 'go.mod'
id: go
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+7 -3
View File
@@ -33,15 +33,19 @@ jobs:
go-version-file: 'go.mod'
id: go
- name: Set up Docker
uses: docker/setup-buildx-action@v4
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Install SeaweedFS
run: |
go install -buildvcs=false ./weed
- name: Pre-pull Spark image
run: docker pull apache/spark:3.5.8
run: |
pull() { for i in 1 2 3; do docker pull "$1" && return 0; sleep 15; done; return 1; }
pull apache/spark:3.5.1
- name: Run S3 Spark integration tests
working-directory: test/s3/spark
+93 -45
View File
@@ -83,6 +83,11 @@ jobs:
go-version-file: 'go.mod'
id: go
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Run go mod tidy
run: go mod tidy
@@ -139,11 +144,15 @@ jobs:
go-version-file: 'go.mod'
id: go
- name: Set up Docker
uses: docker/setup-buildx-action@v4
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Pre-pull Trino image
run: docker pull trinodb/trino:479
run: |
pull() { for i in 1 2 3; do docker pull "$1" && return 0; sleep 15; done; return 1; }
pull trinodb/trino:479
- name: Run go mod tidy
run: go mod tidy
@@ -205,14 +214,16 @@ jobs:
go-version-file: 'go.mod'
id: go
- name: Set up Docker
uses: docker/setup-buildx-action@v4
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Pre-pull Dremio image
run: docker pull dremio/dremio-oss:25.2.0
- name: Pre-pull Python image for PyIceberg writer
run: docker pull python:3.11-slim
- name: Pre-pull images
run: |
pull() { for i in 1 2 3; do docker pull "$1" && return 0; sleep 15; done; return 1; }
pull dremio/dremio-oss:25.2.0
pull python:3.11-slim
- name: Run go mod tidy
run: go mod tidy
@@ -276,14 +287,16 @@ jobs:
go-version-file: 'go.mod'
id: go
- name: Set up Docker
uses: docker/setup-buildx-action@v4
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Pre-pull Doris image
run: docker pull apache/doris:doris-all-in-one-2.1.0
- name: Pre-pull Python image for PyIceberg writer
run: docker pull python:3.11-slim
- name: Pre-pull images
run: |
pull() { for i in 1 2 3; do docker pull "$1" && return 0; sleep 15; done; return 1; }
pull apache/doris:doris-all-in-one-2.1.0
pull python:3.11-slim
- name: Run go mod tidy
run: go mod tidy
@@ -354,8 +367,15 @@ jobs:
run: |
go install -buildvcs=false ./weed
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Pre-pull Polaris image
run: docker pull apache/polaris:latest
run: |
pull() { for i in 1 2 3; do docker pull "$1" && return 0; sleep 15; done; return 1; }
pull apache/polaris:latest
- name: Run Polaris Integration Tests
timeout-minutes: 25
@@ -408,11 +428,15 @@ jobs:
go-version-file: 'go.mod'
id: go
- name: Set up Docker
uses: docker/setup-buildx-action@v4
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Pre-pull Spark image
run: docker pull apache/spark:3.5.1
run: |
pull() { for i in 1 2 3; do docker pull "$1" && return 0; sleep 15; done; return 1; }
pull apache/spark:3.5.1
- name: Run go mod tidy
run: go mod tidy
@@ -474,13 +498,16 @@ jobs:
go-version-file: 'go.mod'
id: go
- name: Set up Docker
uses: docker/setup-buildx-action@v4
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Pre-pull RisingWave image
run: |
docker pull risingwavelabs/risingwave:v2.5.0
docker pull postgres:16-alpine
pull() { for i in 1 2 3; do docker pull "$1" && return 0; sleep 15; done; return 1; }
pull risingwavelabs/risingwave:v2.5.0
pull postgres:16-alpine
- name: Run go mod tidy
run: go mod tidy
@@ -542,11 +569,39 @@ jobs:
go-version-file: 'go.mod'
id: go
- name: Set up Docker
uses: docker/setup-buildx-action@v4
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Pre-pull Python image
run: docker pull python:3
- name: Week stamp for image cache key
id: week
run: echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
- name: Restore python:3 image cache
id: python-image
uses: actions/cache@v5
with:
path: /tmp/python3-image.tar
key: python3-image-${{ steps.week.outputs.week }}
restore-keys: |
python3-image-
- name: Load or pull python:3
run: |
if [ "${{ steps.python-image.outputs.cache-hit }}" = "true" ]; then
docker load -i /tmp/python3-image.tar
exit 0
fi
pull() { for i in 1 2 3; do docker pull "$1" && return 0; sleep 15; done; return 1; }
if pull python:3; then
docker save -o /tmp/python3-image.tar python:3
elif [ -f /tmp/python3-image.tar ]; then
# Docker Hub unreachable; fall back to last week's cached image
docker load -i /tmp/python3-image.tar
else
exit 1
fi
- name: Run go mod tidy
run: go mod tidy
@@ -608,15 +663,6 @@ jobs:
go-version-file: 'go.mod'
id: go
- name: Set up Docker
uses: docker/setup-buildx-action@v4
- name: Pre-pull Python image
run: docker pull python:3
- name: Pre-pull LocalStack image (if needed)
run: docker pull localstack/localstack:latest || true
- name: Run go mod tidy
run: go mod tidy
@@ -677,14 +723,16 @@ jobs:
go-version-file: 'go.mod'
id: go
- name: Set up Docker
uses: docker/setup-buildx-action@v4
- name: Configure Docker Hub mirror
run: |
echo '{"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Pre-pull Unity Catalog image
run: docker pull unitycatalog/unitycatalog:v0.4.0
- name: Pre-pull Python image for delta-rs writer
run: docker pull python:3.11-slim
- name: Pre-pull images
run: |
pull() { for i in 1 2 3; do docker pull "$1" && return 0; sleep 15; done; return 1; }
pull unitycatalog/unitycatalog:v0.4.0
pull python:3.11-slim
- name: Run go mod tidy
run: go mod tidy
+2 -2
View File
@@ -45,7 +45,7 @@ jobs:
- name: Fix S3 tests bucket creation conflicts
run: |
python3 test/s3/fix_s3_tests_bucket_conflicts.py
python3 test/s3/compatibility/fix_s3_tests_bucket_conflicts.py
env:
S3_TESTS_PATH: s3-tests
@@ -388,7 +388,7 @@ jobs:
- name: Fix S3 tests bucket creation conflicts
run: |
python3 test/s3/fix_s3_tests_bucket_conflicts.py
python3 test/s3/compatibility/fix_s3_tests_bucket_conflicts.py
env:
S3_TESTS_PATH: s3-tests
+80
View File
@@ -0,0 +1,80 @@
name: "terraform: validate and test modules"
on:
push:
branches: [ master ]
paths: ['terraform/**', '.github/workflows/terraform_ci.yml']
pull_request:
branches: [ master ]
paths: ['terraform/**', '.github/workflows/terraform_ci.yml']
permissions:
contents: read
jobs:
validate:
name: fmt, validate, plan-level tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up OpenTofu
uses: opentofu/setup-opentofu@v2
with:
tofu_version: 1.12.1
- name: fmt check
working-directory: terraform
run: tofu fmt -recursive -check -diff
- name: validate core
working-directory: terraform/modules/core
run: |
tofu init -backend=false -input=false
tofu validate
- name: validate security
working-directory: terraform/modules/security
run: |
tofu init -backend=false -input=false
tofu validate
- name: plan-level tests (core)
working-directory: terraform/modules/core
run: tofu test
- name: validate examples
run: |
set -e
for ex in terraform/examples/*/; do
echo "== validate $ex =="
tofu -chdir="$ex" init -backend=false -input=false
tofu -chdir="$ex" validate
done
smoke:
name: local cluster smoke test (real weed)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Build weed
run: go build -o "$RUNNER_TEMP/weed" ./weed
- name: Set up OpenTofu
uses: opentofu/setup-opentofu@v2
with:
tofu_version: 1.12.1
- name: Run local cluster harness
working-directory: terraform/test/local
run: WEED="$RUNNER_TEMP/weed" ./run_local_cluster.sh
- name: Run local mTLS cluster harness
working-directory: terraform/test/local-secure
run: WEED="$RUNNER_TEMP/weed" ./run_local_secure.sh
+3 -1
View File
@@ -35,6 +35,7 @@ Your support will be really appreciated by me and other supporters!
[![nodion](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/sponsor_nodion.png)](https://www.nodion.com)
[![piknik](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/piknik.png)](https://www.piknik.com)
[![keepsec](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/keepsec.png)](https://www.keepsec.ca)
[![zyner](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/sponsor_zyner.png)](https://zyner.org)
---
@@ -654,7 +655,8 @@ Cluster Total: 3302.88 MiB/s, 550.51 obj/s over 43s.
## Enterprise ##
For enterprise users, please visit [seaweedfs.com](https://seaweedfs.com) for the SeaweedFS Enterprise Edition,
which has a self-healing storage format with better data protection.
which has advanced features, including data recovery, self-healing storage,
customizable erasure coding, EC vacuum and repair, etc.
[Back to TOC](#table-of-contents)
+7 -2
View File
@@ -1,4 +1,6 @@
FROM golang:1.25-alpine AS builder
# Pin the builder to the host arch and cross-compile the (CGO-free) Go binary,
# so arm64/arm/386 targets skip QEMU emulation of the whole compile.
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
RUN apk add git g++ fuse
RUN mkdir -p /go/src/github.com/seaweedfs/
ARG BRANCH=${BRANCH:-master}
@@ -12,9 +14,12 @@ RUN cd /go/src/github.com/seaweedfs/seaweedfs && \
git checkout $BRANCH) || \
(echo "ERROR: Branch/commit $BRANCH not found in repository" && \
echo "Available branches:" && git branch -a && exit 1))
ARG TARGETOS TARGETARCH TARGETVARIANT
RUN cd /go/src/github.com/seaweedfs/seaweedfs/weed \
&& export LDFLAGS="-X github.com/seaweedfs/seaweedfs/weed/util/version.COMMIT=$(git rev-parse --short HEAD)" \
&& CGO_ENABLED=0 go install -tags "$TAGS" -ldflags "-extldflags -static ${LDFLAGS}"
&& export GOOS=$TARGETOS GOARCH=$TARGETARCH \
&& case "$TARGETARCH" in arm) export GOARM="${TARGETVARIANT#v}";; esac \
&& CGO_ENABLED=0 go build -tags "$TAGS" -ldflags "-extldflags -static ${LDFLAGS}" -o /go/bin/weed .
# Rust volume server: use pre-built binary from CI when available (placed in
# weed-volume-prebuilt/ by the build-rust-binaries job), otherwise compile
+3
View File
@@ -127,6 +127,9 @@ test_tarantool: tags = tarantool
test_tarantool: build_tarantool_dev_env build
docker compose -f compose/test-tarantool-filer.yml -p seaweedfs up
test_keycloak_s3: build
docker compose -f compose/test-keycloak-s3.yml -p seaweedfs up
clean:
rm ./weed
+46
View File
@@ -0,0 +1,46 @@
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0.7
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_HTTP_ENABLED: "true"
KC_HOSTNAME: "http://keycloak:8080"
KC_HOSTNAME_STRICT: "false"
ports:
- "8080:8080"
volumes:
- ../../test/s3/iam/seaweedfs-test-realm.json:/opt/keycloak/data/import/seaweedfs-test-realm.json:ro
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080"]
interval: 10s
timeout: 5s
retries: 5
start_period: 60s
s3:
image: chrislusf/seaweedfs:local
command:
- -v=9
- server
- -ip=s3
- -filer
- -master.volumeSizeLimitMB=16
- -volume
- -volume.max=0
- -volume.preStopSeconds=1
- -s3
- -s3.port=8333
- -s3.config=/etc/seaweedfs/s3_config.json
- -s3.iam.config=/etc/seaweedfs/iam_config.json
environment:
WEED_MASTER_VOLUME_GROWTH_COPY_OTHER: 1
volumes:
- ../../test/s3/iam/test_config.json:/etc/seaweedfs/s3_config.json:ro
- ../../test/s3/iam/iam_config_docker.json:/etc/seaweedfs/iam_config.json:ro
ports:
- "8333:8333"
depends_on:
keycloak:
condition: service_healthy
+40 -44
View File
@@ -88,9 +88,9 @@ require (
go.etcd.io/etcd/client/v3 v3.6.10
go.mongodb.org/mongo-driver v1.17.9
go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.45.0
gocloud.dev v0.46.0
gocloud.dev/pubsub/natspubsub v0.45.0
gocloud.dev/pubsub/rabbitpubsub v0.45.0
gocloud.dev/pubsub/rabbitpubsub v0.46.0
golang.org/x/crypto v0.52.0
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
@@ -102,7 +102,7 @@ require (
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.278.0
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc v1.80.0
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
gopkg.in/inf.v0 v0.9.1 // indirect
modernc.org/b v1.0.0 // indirect
@@ -117,19 +117,18 @@ require (
github.com/Jille/raft-grpc-transport v1.6.1
github.com/ThreeDotsLabs/watermill v1.5.1
github.com/a-h/templ v0.3.1001
github.com/apache/cassandra-gocql-driver/v2 v2.1.0
github.com/apache/cassandra-gocql-driver/v2 v2.1.1
github.com/apache/iceberg-go v0.5.0
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3
github.com/arangodb/go-driver v1.6.9
github.com/armon/go-metrics v0.4.1
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.32.14
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
github.com/aws/aws-sdk-go-v2 v1.41.12
github.com/aws/aws-sdk-go-v2/config v1.32.21
github.com/aws/aws-sdk-go-v2/credentials v1.19.20
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2
github.com/cognusion/imaging v1.0.3
github.com/fluent/fluent-logger-golang v1.10.1
github.com/getsentry/sentry-go v0.44.1
github.com/go-git/go-billy/v5 v5.9.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/flatbuffers/go v0.0.0-20230108230133-3b8644d32c50
@@ -145,21 +144,19 @@ require (
github.com/rabbitmq/amqp091-go v1.11.0
github.com/rclone/rclone v1.74.1
github.com/rdleal/intervalst v1.5.0
github.com/redis/go-redis/v9 v9.19.0
github.com/redis/go-redis/v9 v9.20.0
github.com/schollz/progressbar/v3 v3.19.0
github.com/seaweedfs/go-fuse/v2 v2.9.3
github.com/shirou/gopsutil/v4 v4.26.3
github.com/tarantool/go-tarantool/v2 v2.4.2
github.com/testcontainers/testcontainers-go v0.40.0
github.com/tikv/client-go/v2 v2.0.7
github.com/willscott/go-nfs v0.0.4
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886
github.com/xeipuuv/gojsonschema v1.2.0
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.1
github.com/ydb-platform/ydb-go-sdk/v3 v3.134.2
go.etcd.io/etcd/client/pkg/v3 v3.6.11
github.com/ydb-platform/ydb-go-sdk/v3 v3.139.5
go.etcd.io/etcd/client/pkg/v3 v3.6.12
go.uber.org/atomic v1.11.0
golang.org/x/sync v0.20.0
golang.org/x/sync v0.21.0
golang.org/x/tools/godoc v0.1.0-deprecated
google.golang.org/grpc/security/advancedtls v1.0.0
)
@@ -182,7 +179,7 @@ require (
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/apache/arrow-go/v18 v18.5.2-0.20260220015023-a886a5722b87 // indirect
github.com/apache/thrift v0.23.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.1.2 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bazelbuild/rules_go v0.46.0 // indirect
github.com/biogo/store v0.0.0-20201120204734-aad293a2328f // indirect
@@ -211,6 +208,7 @@ require (
github.com/dromara/dongle v1.0.1 // indirect
github.com/gin-gonic/gin v1.11.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
github.com/google/go-cmp v0.7.0 // indirect
@@ -233,7 +231,7 @@ require (
github.com/klauspost/asmfmt v1.3.2 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lib/pq v1.11.1 // indirect
github.com/lib/pq v1.12.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lithammer/shortuuid/v3 v3.0.7 // indirect
github.com/magiconair/properties v1.8.10 // indirect
@@ -257,7 +255,6 @@ require (
github.com/pquerna/otp v1.5.0 // indirect
github.com/pterm/pterm v0.12.82 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 // indirect
github.com/rclone/Proton-API-Bridge v1.0.3 // indirect
github.com/rclone/go-proton-api v1.0.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
@@ -277,10 +274,10 @@ require (
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/mock v0.5.2 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
@@ -323,23 +320,22 @@ require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
github.com/arangodb/go-velocypack v0.0.0-20200318135517-5af53c29c67e // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.26 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/sns v1.39.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10
github.com/aws/smithy-go v1.25.1
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.27 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.26 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
github.com/aws/aws-sdk-go-v2/service/sns v1.39.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.24 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.0
github.com/aws/smithy-go v1.27.1
github.com/boltdb/bolt v1.3.1 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
@@ -350,7 +346,7 @@ require (
github.com/cloudinary/cloudinary-go/v2 v2.15.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/colinmarc/hdfs/v2 v2.4.0 // indirect
github.com/creasty/defaults v1.8.0 // indirect
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 // indirect
@@ -362,8 +358,8 @@ require (
github.com/elastic/gosigar v0.14.3 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flynn/noise v1.1.0 // indirect
@@ -392,7 +388,7 @@ require (
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
@@ -441,7 +437,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.26
github.com/pierrec/lz4/v4 v4.1.27
github.com/pingcap/errors v0.11.5-0.20211224045212-9687c2b0f87c // indirect
github.com/pingcap/failpoint v0.0.0-20220801062533-2eaa32854a6c // indirect
github.com/pingcap/kvproto v0.0.0-20230403051650-e166ae588106 // indirect
@@ -478,7 +474,7 @@ require (
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e // indirect
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180 // indirect
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260428144813-1c07baab7f7b // indirect
github.com/ydb-platform/ydb-go-yc v0.12.1 // indirect
github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 // indirect
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 // indirect
@@ -488,7 +484,7 @@ require (
go.etcd.io/bbolt v1.4.3 // indirect
go.etcd.io/etcd/api/v3 v3.6.10 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
+82 -92
View File
@@ -573,10 +573,6 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4 h1:tZh20RjgfMxKBxJiI
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4/go.mod h1:vGYAk36rhMVCfTP7v+RVruCR0zmPe6S+36KRpDCLySw=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc=
github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
@@ -694,8 +690,8 @@ github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmO
github.com/apache/arrow-go/v18 v18.5.2-0.20260220015023-a886a5722b87 h1:r/gg2gzUXiXoy72VU3jnODh8l/5rL0aslnTAbtmai/U=
github.com/apache/arrow-go/v18 v18.5.2-0.20260220015023-a886a5722b87/go.mod h1:IJTMBTlHe7cDOhRh0ioGuEKBl5iTR6xPfl5BN4AgirU=
github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
github.com/apache/cassandra-gocql-driver/v2 v2.1.0 h1:VEbbeJ2ift4deKMZ6Fs55Vs3fq/RrkjCcxCnqUxhwf8=
github.com/apache/cassandra-gocql-driver/v2 v2.1.0/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4=
github.com/apache/cassandra-gocql-driver/v2 v2.1.1 h1:DjmtmSRoUuIFswczA8LPBppRb8T9vxqNP5Txc/lQxhE=
github.com/apache/cassandra-gocql-driver/v2 v2.1.1/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4=
github.com/apache/iceberg-go v0.5.0 h1:wQj4CK5YiXZcB+tj19gWG+Jf1I6MiORQ/StSL/E5gGQ=
github.com/apache/iceberg-go v0.5.0/go.mod h1:F/rdP1yZmnO4mQ0Qew2HTGdc+ZV57cRfxbbq/uJm1eM=
github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
@@ -715,50 +711,50 @@ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2 v1.41.12 h1:DIKX2c31ekm9RA2D9FBj1EWXx++9AdAqRw+e78Tq2Ck=
github.com/aws/aws-sdk-go-v2 v1.41.12/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
github.com/aws/aws-sdk-go-v2/config v1.32.21 h1:1i1v5jWn6iNPl7zkH4VwIXQOb+OMqMRFNjdHQeZbFVo=
github.com/aws/aws-sdk-go-v2/config v1.32.21/go.mod h1:7NXDJjk0e4QOMv3Y3ppwALx3ifdSQHzCACFtRX5J1hI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.20 h1:nIGz0+cQKwijxSsWNpggvPxjxFMtrAHNj16RYzGbu2Q=
github.com/aws/aws-sdk-go-v2/credentials v1.19.20/go.mod h1:/UQHYia3DSeilGdchXJDEVzJws15XuZSdnTlwbTBg0Q=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.26 h1:a1VPHz7eG7mVNHWHXUcCyC8rQDBEr4k+o4HJYbSoYZ0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.26/go.mod h1:9G8LPblMwaodeQdS+IfDBgPqABDctJLpyZ/M6jflE6s=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.13 h1:uMC4oL6G3MNhodo358QEqSDjrgvzV3TUQ58nyQSGq2E=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.13/go.mod h1:Cer86AE2686DvVUe57LPve3jUBmbujuaonSX8pNzGgw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sns v1.39.7 h1:fovS7qGMT+BBSuifkySdVaMWxXTyaYT6qaBx/1y6Ij4=
github.com/aws/aws-sdk-go-v2/service/sns v1.39.7/go.mod h1:gFahrattA8ulEtiS4XL/fQiQ77l+Urc52Y96/r1e6ks=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.17 h1:ZNMxVFPayuHe14u/vn+BwLi3wxQvxcNTw8WdPv2gqBc=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.17/go.mod h1:ZxqweFQ2w6NNznWMUvWV9AvkAfM6J8F/MC250Mb4n1I=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 h1:w5OoDiMN6x53ROmiIImGzmVcxXv2q1GXY+aKV4WAJYM=
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3/go.mod h1:dAhgYp776bX3LuWvnSCFwQEjNs6fuFg7YXIy5PXcP3Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.26 h1:dx1qc6V0exl5SK5/kwSc2IdOKCxLHoNbl/zXuYRwopw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.26/go.mod h1:iDMGb+drKqPTqRjv1kWc5meMYzT6HsvPXxJV/Axq1t0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.26 h1:ZjsIjuahUd/OxnxgfeK6OZqrReRNru1BcPfFD95IoN0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.26/go.mod h1:57VbRA0K0Xxx5XUUTUrVxZ/gm4u8Bk3LBflfufwHtKQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.27 h1:i49S7g2smFWmlconi97Nn8dxb7jSQ0TeOFD20NX3M+o=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.27/go.mod h1:qiLes3uVQAK+cpb837HUEhypYKGBz2ZCDcDl5BYQtpM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.26 h1:oIRaBVJ2CxT7T61l3JkBCW/rnyh2TFZIRL71MvJTKRI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.26/go.mod h1:1gCTZPMlqAUSFoFbkl35hKtTpE96WFl7ORvDXmSLpRc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.2 h1:qwU4CNor4G2gRW3Na24FWOCZSEvr967IAV3D+MXxdE0=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.2/go.mod h1:vwIj2P3URYf7ZtOQkO2iVbAaR8qFhqMv05w3aPPA5XA=
github.com/aws/aws-sdk-go-v2/service/sns v1.39.14 h1:p8WdWDh5AwSZdp19Haa3XMyPCICi9Z375a/Nu3IIEZY=
github.com/aws/aws-sdk-go-v2/service/sns v1.39.14/go.mod h1:NKVY7DER6VXHkt2I/ycmHakALNboi3Rqwt4eEf/1Cnk=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.24 h1:JP2wjWGmUp8lTCZb13Dv0Eciyc1jbO8pd0HZVMHFlrc=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.24/go.mod h1:Ql9ziDutk8ERAN9HMaYANCW3lop451ppebkxEJMLCTM=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.0 h1:cMDUWhi7vlmGFj6rldHofjnAQhLLBWwRvNRbtEoyZo4=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.0/go.mod h1:VfGCm6HLUGwcouY6zjQJd81PoFsb8sfSmKd5QCg9XXc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.3 h1:/pKy3mNES+ppFHf3g/XmI95+f3vdeXnkWYObByoYGAg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.3/go.mod h1:0z2rhlVAjsYeBzV8fdT4jR23Nq+fYhoJNSJN+4SBlqM=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.0 h1:HN43uYvF8D2NtaPTeE4L3HMvXgd+RtqEqrea4uUztkM=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.0/go.mod h1:qzhDlfw1BRYyoNofevemNzrlRpUXa0BJamWx7klrR8w=
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bazelbuild/rules_go v0.46.0 h1:CTefzjN/D3Cdn3rkrM6qMWuQj59OBcuOjyIp3m4hZ7s=
@@ -854,8 +850,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20230310173818-32f1caf87195/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg=
github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=
github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
@@ -1000,16 +996,16 @@ github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJ
github.com/envoyproxy/go-control-plane v0.11.0/go.mod h1:VnHyVMpzcLvCFt9yUz1UnCwHLhwx1WguiVDV7pTG/tI=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo=
github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w=
github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA=
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0=
@@ -1324,8 +1320,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hamba/avro/v2 v2.31.0 h1:wv3nmua7lCEIwWsb6vqsTS3pXktTxcKg5eoyNu0VhrU=
github.com/hamba/avro/v2 v2.31.0/go.mod h1:t6lJYAGE5Mswfn17zjtyQsssRQgnqO6TXLBCHHWRqrw=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -1511,8 +1507,8 @@ github.com/lanrat/extsort v1.4.2 h1:akbLIdo4PhNZtvjpaWnbXtGMmLtnGzXplkzfgl+XTTY=
github.com/lanrat/extsort v1.4.2/go.mod h1:hceP6kxKPKebjN1RVrDBXMXXECbaI41Y94tt6MDazc4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/linkedin/goavro/v2 v2.15.0 h1:pDj1UrjUOO62iXhgBiE7jQkpNIc5/tA5eZsgolMjgVI=
github.com/linkedin/goavro/v2 v2.15.0/go.mod h1:KXx+erlq+RPlGSPmLF7xGo6SAbh8sCQ53x064+ioxhk=
github.com/linxGnu/grocksdb v1.10.8 h1:Nau01Hhm/0kaVTR6d4viwD6npYbnDvZAfzwJCLzKRYo=
@@ -1691,8 +1687,8 @@ github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrre/compare v1.0.2 h1:k4IUsHgh+dbcAOIWCfxVa/7G6STjADH2qmhomv+1quc=
github.com/pierrre/compare v1.0.2/go.mod h1:8UvyRHH+9HS8Pczdd2z5x/wvv67krDwVxoOndaIIDVU=
github.com/pierrre/geohash v1.0.0 h1:f/zfjdV4rVofTCz1FhP07T+EMQAvcMM2ioGZVt+zqjI=
@@ -1783,8 +1779,6 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8=
github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 h1:UVArwN/wkKjMVhh2EQGC0tEc1+FqiLlvYXY5mQ2f8Wg=
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93/go.mod h1:Nfe4efndBz4TibWycNE+lqyJZiMX4ycx+QKV8Ta0f/o=
github.com/rclone/Proton-API-Bridge v1.0.3 h1:Bs7RC4xCFSN0BPIYVda/BNxp0qo3NV0gB2VZqx2KIew=
github.com/rclone/Proton-API-Bridge v1.0.3/go.mod h1:26RAest751Ofk+F/d8xtl4UyWXrZvMQwn39U8rm/WKM=
github.com/rclone/go-proton-api v1.0.2 h1:cJtJUab0MGJ3C6q5kiEJs3pbyhSLnOKMyYOQehA0PBc=
@@ -1795,8 +1789,8 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5X
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rdleal/intervalst v1.5.0 h1:SEB9bCFz5IqD1yhfH1Wv8IBnY/JQxDplwkxHjT6hamU=
github.com/rdleal/intervalst v1.5.0/go.mod h1:xO89Z6BC+LQDH+IPQQw/OESt5UADgFD41tYMUINGpxQ=
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/redis/rueidis v1.0.71 h1:pODtnAR5GAB7j4ekhldZ29HKOxe4Hph0GTDGk1ayEQY=
github.com/redis/rueidis v1.0.71/go.mod h1:lfdcZzJ1oKGKL37vh9fO3ymwt+0TdjkkUCJxbgpmcgQ=
github.com/redis/rueidis/rueidiscompat v1.0.71 h1:wNZ//kEjMZgBM0KCk7ncOX8KmAgROU2kDdDNpwheG4w=
@@ -2028,10 +2022,6 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/willscott/go-nfs v0.0.4 h1:1vpOPAdECmoT2KmZ8u+ukO/jfvDjMEUNYhA2F1jGJtI=
github.com/willscott/go-nfs v0.0.4/go.mod h1:VhNccO67Oug787VNXcyx9JDI3ZoSpqoKMT/lWMhUIDg=
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886 h1:DtrBtkgTJk2XGt4T7eKdKVkd9A5NCevN2e4inLXtsqA=
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886/go.mod h1:Tq++Lr/FgiS3X48q5FETemXiSLGuYMQT2sPjYNPJSwA=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ=
@@ -2064,14 +2054,14 @@ github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e h1:9LPdmD
github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20221215182650-986f9d10542f/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20230528143953-42c825ace222/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180 h1:avIdi8eGXjKbn1WLokNR1Ofnz1k8t7tJ88YQLD/iCi8=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260428144813-1c07baab7f7b h1:xeiobG1riqe6dTMZuTcOvwOY0BbFidSk3gRLyVeLfJA=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260428144813-1c07baab7f7b/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.1 h1:XaRxeVrOyl3y6v9CiYMWaFdZ6zevvYe+TRxOR8ifa2s=
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.1/go.mod h1:9YzkhlIymWaJGX6KMU3vh5sOf3UKbCXkG/ZdjaI3zNM=
github.com/ydb-platform/ydb-go-sdk/v3 v3.44.0/go.mod h1:oSLwnuilwIpaF5bJJMAofnGgzPJusoI3zWMNb8I+GnM=
github.com/ydb-platform/ydb-go-sdk/v3 v3.47.3/go.mod h1:bWnOIcUHd7+Sl7DN+yhyY1H/I61z53GczvwJgXMgvj0=
github.com/ydb-platform/ydb-go-sdk/v3 v3.134.2 h1:Bzra5hUQIWrxknQ0oV6HE5q/eZU1VhHMKSZ/XWpqMgM=
github.com/ydb-platform/ydb-go-sdk/v3 v3.134.2/go.mod h1:VYUUkRJkKuQPkIpgtZJj6+58Fa2g8ccAqdmaaK6HP5k=
github.com/ydb-platform/ydb-go-sdk/v3 v3.139.5 h1:mTih9wy1RbI4PplGlFn/CMnkgxm1HzD/nKSdnNMy4pE=
github.com/ydb-platform/ydb-go-sdk/v3 v3.139.5/go.mod h1:b9NEO6mgaiqsnOMkS003uS82XsKh6GL+ZTFfPqXWz+c=
github.com/ydb-platform/ydb-go-yc v0.12.1 h1:qw3Fa+T81+Kpu5Io2vYHJOwcrYrVjgJlT6t/0dOXJrA=
github.com/ydb-platform/ydb-go-yc v0.12.1/go.mod h1:t/ZA4ECdgPWjAb4jyDe8AzQZB5dhpGbi3iCahFaNwBY=
github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 h1:9E5q8Nsy2RiJMZDNVy0A3KUrIMBPakJ2VgloeWbcI84=
@@ -2111,8 +2101,8 @@ go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.etcd.io/etcd/api/v3 v3.6.10 h1:jlwjtELjA8yi2VWpOFH+0w0lGr3K6mVDyn0RDB9aaAY=
go.etcd.io/etcd/api/v3 v3.6.10/go.mod h1:pdV4VeFmvhdNjB4LWRkC8ReLyRBAxUOze3GarMhE2sk=
go.etcd.io/etcd/client/pkg/v3 v3.6.11 h1:e41mp315Yn3QMGPmEzCyLsMINgJXTY/dX8kM++1csxU=
go.etcd.io/etcd/client/pkg/v3 v3.6.11/go.mod h1:DysuMe/inqRyC/1tjRR6hReH/VV9Lufs27YKSKBWWJg=
go.etcd.io/etcd/client/pkg/v3 v3.6.12 h1:36zzB+pQOdHbhN+kH2iJz/K8bJn0ZLtLfPPO7jozTDo=
go.etcd.io/etcd/client/pkg/v3 v3.6.12/go.mod h1:hh2+ZXtfLzs3o6mn92ntgNPBrTJJOvXqICM5g3L3DMY=
go.etcd.io/etcd/client/v3 v3.6.10 h1:J598zJ+C/ZPvImypmq5waj84+bovePrlZERHklf34y0=
go.etcd.io/etcd/client/v3 v3.6.10/go.mod h1:iHhUDUcEwaKs1YFq3MgmI9U4zhTVasp/vgdVbFf1RS8=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
@@ -2128,8 +2118,8 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 h1:0tY123n7CdWMem7MOVdKOt0YfshufLCwfE5Bob+hQuM=
@@ -2138,14 +2128,14 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:Oyrsyzu
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 h1:0NIXxOCFx+SKbhCVxwl3ETG8ClLPAa0KuKV6p3yhxP8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0/go.mod h1:ChZSJbbfbl/DcRZNc9Gqh6DYGlfjw4PvO1pEOZH1ZsE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
@@ -2163,8 +2153,8 @@ go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLh
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
@@ -2188,12 +2178,12 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gocloud.dev v0.45.0 h1:WknIK8IbRdmynDvara3Q7G6wQhmEiOGwpgJufbM39sY=
gocloud.dev v0.45.0/go.mod h1:0kXKmkCLG6d31N7NyLZWzt7jDSQura9zD/mWgiB6THI=
gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
gocloud.dev/pubsub/natspubsub v0.45.0 h1:kfCupVejeynIQcS1GaIvkY3Nj/acLlM5yPWyxZN8wJ8=
gocloud.dev/pubsub/natspubsub v0.45.0/go.mod h1:WU5SMDWuF7CCr2U8UpbrEONYYFOmvigCAvTpOOpUvYE=
gocloud.dev/pubsub/rabbitpubsub v0.45.0 h1:hlejKjBFYQBUAj7ZsHKgW0JOER/3XUqrMFFxBOFUIOU=
gocloud.dev/pubsub/rabbitpubsub v0.45.0/go.mod h1:Kqq6gppGY/RTrRQkKSHZq+/qbCufPEoQ0E1V3X389zM=
gocloud.dev/pubsub/rabbitpubsub v0.46.0 h1:uWJ8z/F8xYxoTlBafTweoKvX2V85cwtN+/+nyRRtjZ0=
gocloud.dev/pubsub/rabbitpubsub v0.46.0/go.mod h1:Ef5XHxv17zV9B3U9xoYAqJ8Rutslxt5Emb5e1cScuXc=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -2405,8 +2395,8 @@ golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -2892,8 +2882,8 @@ google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsA
google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY=
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6 h1:ExN12ndbJ608cboPYflpTny6mXSzPrDLh0iTaVrRrds=
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8=
+2 -2
View File
@@ -1,6 +1,6 @@
apiVersion: v1
description: SeaweedFS
name: seaweedfs
appVersion: "4.29"
appVersion: "4.33"
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
version: 4.29.0
version: 4.33.0
+1 -1
View File
@@ -384,4 +384,4 @@ helm install seaweedfs seaweedfs/seaweedfs \
## Enterprise
For enterprise users, please visit [seaweedfs.com](https://seaweedfs.com) for the SeaweedFS Enterprise Edition,
which has a self-healing storage format with better data protection.
which has advanced features, including data recovery, self-healing storage, customizable erasure coding, EC vacuum and repair, etc.
@@ -3666,6 +3666,291 @@
],
"title": "S3 Bucket Object Count",
"type": "timeseries"
},
{
"collapsed": false,
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 130
},
"id": 92,
"panels": [],
"title": "Filer Object Size Distribution",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"description": "Rate of new objects created, split by size range.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "objects/s",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 25,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 4,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "normal"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short",
"unitScale": true
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 131
},
"id": 93,
"links": [],
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "8.1.2",
"targets": [
{
"exemplar": true,
"expr": "sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1024\",namespace=\"$NAMESPACE\"}[$__rate_interval]))",
"format": "time_series",
"hide": false,
"instant": false,
"interval": "",
"intervalFactor": 2,
"legendFormat": "< 1KB",
"refId": "A",
"step": 60
},
{
"exemplar": true,
"expr": "clamp_min(sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"102400\",namespace=\"$NAMESPACE\"}[$__rate_interval])) - sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1024\",namespace=\"$NAMESPACE\"}[$__rate_interval])), 0)",
"format": "time_series",
"hide": false,
"instant": false,
"interval": "",
"intervalFactor": 2,
"legendFormat": "1KB - 100KB",
"refId": "B",
"step": 60
},
{
"exemplar": true,
"expr": "clamp_min(sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+06\",namespace=\"$NAMESPACE\"}[$__rate_interval])) - sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"102400\",namespace=\"$NAMESPACE\"}[$__rate_interval])), 0)",
"format": "time_series",
"hide": false,
"instant": false,
"interval": "",
"intervalFactor": 2,
"legendFormat": "100KB - 1MB",
"refId": "C",
"step": 60
},
{
"exemplar": true,
"expr": "clamp_min(sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+08\",namespace=\"$NAMESPACE\"}[$__rate_interval])) - sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+06\",namespace=\"$NAMESPACE\"}[$__rate_interval])), 0)",
"format": "time_series",
"hide": false,
"instant": false,
"interval": "",
"intervalFactor": 2,
"legendFormat": "1MB - 100MB",
"refId": "D",
"step": 60
},
{
"exemplar": true,
"expr": "clamp_min(sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.073741824e+09\",namespace=\"$NAMESPACE\"}[$__rate_interval])) - sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+08\",namespace=\"$NAMESPACE\"}[$__rate_interval])), 0)",
"format": "time_series",
"hide": false,
"instant": false,
"interval": "",
"intervalFactor": 2,
"legendFormat": "100MB - 1GB",
"refId": "E",
"step": 60
},
{
"exemplar": true,
"expr": "clamp_min(sum(rate(SeaweedFS_filer_object_size_bytes_count{namespace=\"$NAMESPACE\"}[$__rate_interval])) - sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.073741824e+09\",namespace=\"$NAMESPACE\"}[$__rate_interval])), 0)",
"format": "time_series",
"hide": false,
"instant": false,
"interval": "",
"intervalFactor": 2,
"legendFormat": "> 1GB",
"refId": "F",
"step": 60
}
],
"title": "Filer Object Write Rate by Size Range",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"description": "Objects created per size range over the selected time window.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 131
},
"id": 94,
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showUnfilled": true,
"valueMode": "color"
},
"pluginVersion": "8.1.2",
"targets": [
{
"exemplar": true,
"expr": "sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1024\",namespace=\"$NAMESPACE\"}[$__range]))",
"format": "time_series",
"instant": true,
"interval": "",
"intervalFactor": 2,
"legendFormat": "< 1KB",
"refId": "A"
},
{
"exemplar": true,
"expr": "clamp_min(sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"102400\",namespace=\"$NAMESPACE\"}[$__range])) - sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1024\",namespace=\"$NAMESPACE\"}[$__range])), 0)",
"format": "time_series",
"instant": true,
"interval": "",
"intervalFactor": 2,
"legendFormat": "1KB - 100KB",
"refId": "B"
},
{
"exemplar": true,
"expr": "clamp_min(sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+06\",namespace=\"$NAMESPACE\"}[$__range])) - sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"102400\",namespace=\"$NAMESPACE\"}[$__range])), 0)",
"format": "time_series",
"instant": true,
"interval": "",
"intervalFactor": 2,
"legendFormat": "100KB - 1MB",
"refId": "C"
},
{
"exemplar": true,
"expr": "clamp_min(sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+08\",namespace=\"$NAMESPACE\"}[$__range])) - sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+06\",namespace=\"$NAMESPACE\"}[$__range])), 0)",
"format": "time_series",
"instant": true,
"interval": "",
"intervalFactor": 2,
"legendFormat": "1MB - 100MB",
"refId": "D"
},
{
"exemplar": true,
"expr": "clamp_min(sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.073741824e+09\",namespace=\"$NAMESPACE\"}[$__range])) - sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+08\",namespace=\"$NAMESPACE\"}[$__range])), 0)",
"format": "time_series",
"instant": true,
"interval": "",
"intervalFactor": 2,
"legendFormat": "100MB - 1GB",
"refId": "E"
},
{
"exemplar": true,
"expr": "clamp_min(sum(increase(SeaweedFS_filer_object_size_bytes_count{namespace=\"$NAMESPACE\"}[$__range])) - sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.073741824e+09\",namespace=\"$NAMESPACE\"}[$__range])), 0)",
"format": "time_series",
"instant": true,
"interval": "",
"intervalFactor": 2,
"legendFormat": "> 1GB",
"refId": "F"
}
],
"title": "Filer Object Size Distribution",
"type": "bargauge"
}
],
"refresh": "",
@@ -81,8 +81,9 @@ spec:
imagePullPolicy: {{ default "IfNotPresent" .Values.global.seaweedfs.imagePullPolicy }}
env:
{{- /* Determine default cluster alias and the corresponding env var keys to avoid conflicts */}}
{{- $envMerged := merge (.Values.global.seaweedfs.extraEnvironmentVars | default dict) (.Values.allInOne.extraEnvironmentVars | default dict) }}
{{- $clusterDefault := default "sw" (index $envMerged "WEED_CLUSTER_DEFAULT") }}
{{- $mergedExtraEnvironmentVars := dict }}
{{- include "seaweedfs.mergeExtraEnvironmentVars" (dict "global" .Values.global.seaweedfs "component" .Values.allInOne "target" $mergedExtraEnvironmentVars) }}
{{- $clusterDefault := default "sw" (index $mergedExtraEnvironmentVars "WEED_CLUSTER_DEFAULT") }}
{{- $clusterUpper := upper $clusterDefault }}
{{- $clusterMasterKey := printf "WEED_CLUSTER_%s_MASTER" $clusterUpper }}
{{- $clusterFilerKey := printf "WEED_CLUSTER_%s_FILER" $clusterUpper }}
@@ -100,8 +101,8 @@ spec:
fieldPath: metadata.namespace
- name: SEAWEEDFS_FULLNAME
value: "{{ include "seaweedfs.fullname" . }}"
{{- if .Values.allInOne.extraEnvironmentVars }}
{{- range $key, $value := .Values.allInOne.extraEnvironmentVars }}
{{- range $key := keys $mergedExtraEnvironmentVars | sortAlpha }}
{{- $value := index $mergedExtraEnvironmentVars $key }}
{{- if and (ne $key $clusterMasterKey) (ne $key $clusterFilerKey) }}
- name: {{ $key }}
{{- if kindIs "string" $value }}
@@ -112,20 +113,6 @@ spec:
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.global.seaweedfs.extraEnvironmentVars }}
{{- range $key, $value := .Values.global.seaweedfs.extraEnvironmentVars }}
{{- if and (ne $key $clusterMasterKey) (ne $key $clusterFilerKey) }}
- name: {{ $key }}
{{- if kindIs "string" $value }}
value: {{ tpl $value $ | quote }}
{{- else }}
valueFrom:
{{ toYaml $value | nindent 16 }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
# Inject computed cluster endpoints for the default cluster
- name: {{ $clusterMasterKey }}
value: {{ include "seaweedfs.cluster.masterAddress" . | quote }}
@@ -272,6 +259,9 @@ spec:
{{- $authMethods := .Values.allInOne.sftp.authMethods | default .Values.sftp.authMethods }}
{{- if $authMethods }}
-sftp.authMethods={{ $authMethods }} \
{{- if contains "certificate" $authMethods }}
-sftp.trustedUserCAKeysFile=/etc/sw/sftp_ca/ca_user.pub \
{{- end }}
{{- end }}
{{- $maxAuthTries := .Values.allInOne.sftp.maxAuthTries | default .Values.sftp.maxAuthTries }}
{{- if $maxAuthTries }}
@@ -319,6 +309,12 @@ spec:
name: config-users
readOnly: true
{{- end }}
{{- $aioAuthMethods := .Values.allInOne.sftp.authMethods | default .Values.sftp.authMethods }}
{{- if and $aioAuthMethods (contains "certificate" $aioAuthMethods) }}
- mountPath: /etc/sw/sftp_ca
name: config-sftp-ca
readOnly: true
{{- end }}
{{- end }}
{{- if .Values.filer.notificationConfig }}
- name: notification-config
@@ -454,6 +450,18 @@ spec:
defaultMode: 420
secretName: {{ default (printf "%s-sftp-secret" (include "seaweedfs.fullname" .)) (or .Values.allInOne.sftp.existingConfigSecret .Values.sftp.existingConfigSecret) }}
{{- end }}
{{- $aioAuthMethodsVol := .Values.allInOne.sftp.authMethods | default .Values.sftp.authMethods }}
{{- if and $aioAuthMethodsVol (contains "certificate" $aioAuthMethodsVol) }}
{{- $aioCASecret := or .Values.allInOne.sftp.existingCAKeysSecret .Values.sftp.existingCAKeysSecret }}
{{- $aioCAKeys := or .Values.allInOne.sftp.trustedUserCAKeys .Values.sftp.trustedUserCAKeys }}
{{- if and (not $aioCASecret) (not $aioCAKeys) }}
{{- fail "allInOne.sftp.authMethods includes \"certificate\" but neither trustedUserCAKeys nor existingCAKeysSecret is set" }}
{{- end }}
- name: config-sftp-ca
secret:
defaultMode: 420
secretName: {{ default (printf "%s-sftp-ca-secret" (include "seaweedfs.fullname" .)) $aioCASecret }}
{{- end }}
{{- end }}
{{- if .Values.filer.notificationConfig }}
- name: notification-config
@@ -0,0 +1,40 @@
{{- include "seaweedfs.compat" . -}}
{{- /*
Render a chart-managed Secret carrying the SSH user CA public key(s) for
certificate-based SFTP authentication, but only when:
- SFTP (standalone or all-in-one) is enabled,
- "certificate" is in the relevant authMethods list,
- the user provided inline CA keys via sftp.trustedUserCAKeys, and
- no existingCAKeysSecret is set (which would supersede this Secret).
*/}}
{{- $sftpEnabled := or .Values.sftp.enabled (and .Values.allInOne.enabled .Values.allInOne.sftp.enabled) -}}
{{- $authMethods := .Values.sftp.authMethods -}}
{{- if and .Values.allInOne.enabled .Values.allInOne.sftp.authMethods -}}
{{- $authMethods = .Values.allInOne.sftp.authMethods -}}
{{- end -}}
{{- $certInMethods := and $authMethods (contains "certificate" $authMethods) -}}
{{- $inlineCAKeys := .Values.sftp.trustedUserCAKeys -}}
{{- if and .Values.allInOne.enabled .Values.allInOne.sftp.trustedUserCAKeys -}}
{{- $inlineCAKeys = .Values.allInOne.sftp.trustedUserCAKeys -}}
{{- end -}}
{{- $existingSecret := .Values.sftp.existingCAKeysSecret -}}
{{- if and .Values.allInOne.enabled .Values.allInOne.sftp.existingCAKeysSecret -}}
{{- $existingSecret = .Values.allInOne.sftp.existingCAKeysSecret -}}
{{- end -}}
{{- if and $sftpEnabled $certInMethods $inlineCAKeys (not $existingSecret) }}
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: {{ include "seaweedfs.fullname" . }}-sftp-ca-secret
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: sftp
stringData:
ca_user.pub: |
{{ $inlineCAKeys | indent 4 }}
{{- end }}
@@ -135,6 +135,9 @@ spec:
{{- end }}
{{- if .Values.sftp.authMethods }}
-authMethods={{ .Values.sftp.authMethods }} \
{{- if contains "certificate" .Values.sftp.authMethods }}
-trustedUserCAKeysFile=/etc/sw/sftp_ca/ca_user.pub \
{{- end }}
{{- end }}
{{- if .Values.sftp.maxAuthTries }}
-maxAuthTries={{ .Values.sftp.maxAuthTries }} \
@@ -176,6 +179,11 @@ spec:
- mountPath: /etc/sw/ssh
name: config-ssh
readOnly: true
{{- if and .Values.sftp.authMethods (contains "certificate" .Values.sftp.authMethods) }}
- mountPath: /etc/sw/sftp_ca
name: config-sftp-ca
readOnly: true
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
- name: security-config
readOnly: true
@@ -256,6 +264,19 @@ spec:
{{- else }}
secretName: {{ include "seaweedfs.fullname" . }}-sftp-ssh-secret
{{- end }}
{{- if and .Values.sftp.authMethods (contains "certificate" .Values.sftp.authMethods) }}
{{- if and (not .Values.sftp.existingCAKeysSecret) (not .Values.sftp.trustedUserCAKeys) }}
{{- fail "sftp.authMethods includes \"certificate\" but neither sftp.trustedUserCAKeys nor sftp.existingCAKeysSecret is set" }}
{{- end }}
- name: config-sftp-ca
secret:
defaultMode: 420
{{- if .Values.sftp.existingCAKeysSecret }}
secretName: {{ .Values.sftp.existingCAKeysSecret }}
{{- else }}
secretName: {{ include "seaweedfs.fullname" . }}-sftp-ca-secret
{{- end }}
{{- end }}
{{- if eq .Values.sftp.logs.type "hostPath" }}
- name: logs
hostPath:
@@ -143,6 +143,8 @@ spec:
{{- if kindIs "bool" .versioning }}
{{- if .versioning }}
{{- $bucketVersioning = "Enabled" }}
{{- else }}
{{- $bucketVersioning = "Suspended" }}
{{- end }}
{{- else if kindIs "string" .versioning }}
{{- $versioningLower := lower .versioning }}
+20 -4
View File
@@ -919,7 +919,7 @@ filer:
# Buckets may be exposed publicly by setting `anonymousRead` to `true`
# ttl format: [1-255][m|h|d|w|M|y] (e.g., 7d)
# objectLock enables S3 Object Lock (irreversible, forces versioning)
# versioning: Enabled or Suspended (or true to enable)
# versioning: Enabled or Suspended (or bool true/false)
# createBuckets:
# - name: bucket-a
# anonymousRead: true
@@ -972,7 +972,7 @@ s3:
# Buckets may be exposed publicly by setting `anonymousRead` to `true`
# ttl format: [1-255][m|h|d|w|M|y] (e.g., 7d)
# objectLock enables S3 Object Lock (irreversible, forces versioning)
# versioning: Enabled or Suspended (or true to enable)
# versioning: Enabled or Suspended (or bool true/false)
# createBuckets:
# - name: bucket-a
# anonymousRead: true
@@ -1138,7 +1138,7 @@ sftp:
# SSH server configuration
sshPrivateKey: "/etc/sw/seaweedfs_sftp_ssh_private_key" # Path to the SSH private key file for host authentication
hostKeysFolder: "/etc/sw/ssh" # path to folder containing SSH private key files for host authentication
authMethods: "password,publickey" # Comma-separated list of allowed auth methods: password, publickey, keyboard-interactive
authMethods: "password,publickey" # Comma-separated list of allowed auth methods: password, publickey, certificate
maxAuthTries: 6 # Maximum number of authentication attempts per connection
bannerMessage: "SeaweedFS SFTP Server" # Message displayed before authentication
loginGraceTime: "2m" # Timeout for authentication
@@ -1155,6 +1155,18 @@ sftp:
# Set to the name of an existing kubernetes Secret with the list of ssh private keys for sftp
existingSshConfigSecret: null
# SSH user-certificate authentication (CA-signed user certs). Mirrors
# OpenSSH `TrustedUserCAKeys` and MinIO `--sftp=trusted-user-ca-key`.
# Add "certificate" to `authMethods` to activate; when active, plain
# public keys are rejected on the public-key channel.
#
# Inline CA public keys in OpenSSH authorized_keys format (one per
# line). Ignored when `existingCAKeysSecret` is set.
trustedUserCAKeys: ""
# Set to the name of an existing kubernetes Secret carrying the CA
# public keys under data key `ca_user.pub`.
existingCAKeysSecret: null
# Additional resources
sidecars: []
initContainers: ""
@@ -1514,7 +1526,7 @@ allInOne:
# Buckets may be exposed publicly by setting `anonymousRead` to `true`
# ttl format: [1-255][m|h|d|w|M|y] (e.g., 7d)
# objectLock enables S3 Object Lock (irreversible, forces versioning)
# versioning: Enabled or Suspended (or true to enable)
# versioning: Enabled or Suspended (or bool true/false)
# createBuckets:
# - name: bucket-a
# anonymousRead: true
@@ -1545,6 +1557,10 @@ allInOne:
existingConfigSecret: null
# Set to the name of an existing kubernetes Secret with the SSH keys
existingSshConfigSecret: null
# SSH user-certificate authentication. See sftp.trustedUserCAKeys above.
# (null on either field inherits from the top-level sftp.* setting.)
trustedUserCAKeys: null
existingCAKeysSecret: null
# Service settings
service:
Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

+231
View File
@@ -623,6 +623,237 @@
"show": true
}
]
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "${DS_PROMETHEUS}",
"editable": true,
"error": false,
"fill": 5,
"fillGradient": 0,
"grid": {},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 31
},
"hiddenSeries": false,
"id": 91,
"legend": {
"alignAsTable": true,
"avg": true,
"current": true,
"hideEmpty": false,
"hideZero": false,
"max": true,
"min": false,
"rightSide": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "null as zero",
"options": {
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "8.1.2",
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": true,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1024\"}[1m]))",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "< 1KB",
"refId": "A"
},
{
"expr": "clamp_min(sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"102400\"}[1m])) - sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1024\"}[1m])), 0)",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "1KB - 100KB",
"refId": "B"
},
{
"expr": "clamp_min(sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+06\"}[1m])) - sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"102400\"}[1m])), 0)",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "100KB - 1MB",
"refId": "C"
},
{
"expr": "clamp_min(sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+08\"}[1m])) - sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+06\"}[1m])), 0)",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "1MB - 100MB",
"refId": "D"
},
{
"expr": "clamp_min(sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.073741824e+09\"}[1m])) - sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+08\"}[1m])), 0)",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "100MB - 1GB",
"refId": "E"
},
{
"expr": "clamp_min(sum(rate(SeaweedFS_filer_object_size_bytes_count[1m])) - sum(rate(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.073741824e+09\"}[1m])), 0)",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "> 1GB",
"refId": "F"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Filer Object Write Rate by Size Range",
"tooltip": {
"msResolution": false,
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": "objects/s",
"logBase": 1,
"max": null,
"min": 0,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"datasource": "${DS_PROMETHEUS}",
"description": "Objects created per size range over the selected time window.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 31
},
"id": 92,
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showUnfilled": true,
"valueMode": "color"
},
"pluginVersion": "8.1.2",
"targets": [
{
"expr": "sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1024\"}[$__range]))",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "< 1KB",
"refId": "A",
"instant": true
},
{
"expr": "clamp_min(sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"102400\"}[$__range])) - sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1024\"}[$__range])), 0)",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "1KB - 100KB",
"refId": "B",
"instant": true
},
{
"expr": "clamp_min(sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+06\"}[$__range])) - sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"102400\"}[$__range])), 0)",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "100KB - 1MB",
"refId": "C",
"instant": true
},
{
"expr": "clamp_min(sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+08\"}[$__range])) - sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+06\"}[$__range])), 0)",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "1MB - 100MB",
"refId": "D",
"instant": true
},
{
"expr": "clamp_min(sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.073741824e+09\"}[$__range])) - sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.048576e+08\"}[$__range])), 0)",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "100MB - 1GB",
"refId": "E",
"instant": true
},
{
"expr": "clamp_min(sum(increase(SeaweedFS_filer_object_size_bytes_count[$__range])) - sum(increase(SeaweedFS_filer_object_size_bytes_bucket{le=\"1.073741824e+09\"}[$__range])), 0)",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "> 1GB",
"refId": "F",
"instant": true
}
],
"title": "Filer Object Size Distribution",
"type": "bargauge"
}
],
"repeat": null,
+6
View File
@@ -450,8 +450,10 @@ message VolumeEcShardsDeleteRequest {
uint32 volume_id = 1;
string collection = 2;
repeated uint32 shard_ids = 3;
bool full_teardown = 4; // pre-encode cleanup: wipe every EC artifact + generation for this volume, not just shard_ids
}
message VolumeEcShardsDeleteResponse {
bool full_teardown_done = 1; // set by a new server that performed full_teardown; absent from an old server lets the caller detect the silent no-op
}
message VolumeEcShardsMountRequest {
@@ -476,10 +478,13 @@ message VolumeEcShardReadRequest {
int64 offset = 3;
int64 size = 4;
uint64 file_key = 5;
reserved 6;
int64 encode_ts_ns = 7; // caller's expected encode time; the server rejects a shard from a different encode run
}
message VolumeEcShardReadResponse {
bytes data = 1;
bool is_deleted = 2;
int64 encode_ts_ns = 3; // identity of the shard actually served; client rejects a mismatch (0 = pre-upgrade server)
}
message VolumeEcBlobDeleteRequest {
@@ -577,6 +582,7 @@ message VolumeInfo {
message EcShardConfig {
uint32 data_shards = 1; // Number of data shards (e.g., 10)
uint32 parity_shards = 2; // Number of parity shards (e.g., 4)
int64 encode_ts_ns = 3; // encode time (unix nanos); a read served from a shard of a different encode run is rejected
}
message OldVersionVolumeInfo {
repeated RemoteFile files = 1;
+6
View File
@@ -545,6 +545,12 @@ async fn run(
whitelist.extend(sec.guard_white_list.iter().cloned());
let mut guard = state_reload.guard.write().unwrap();
guard.update_whitelist(&whitelist);
guard.update_signing_keys(
SigningKey(sec.jwt_signing_key),
sec.jwt_signing_expires,
SigningKey(sec.jwt_read_signing_key),
sec.jwt_read_signing_expires,
);
}
// Trigger heartbeat to report new volumes
+4
View File
@@ -424,6 +424,10 @@ mod tests {
#[tokio::test]
async fn test_push_metrics_once() {
register_metrics();
// A CounterVec with no children emits nothing, so create a labelset
// instead of depending on another test having touched the counter
// first (test order is nondeterministic).
REQUEST_COUNTER.with_label_values(&["GET", "200"]).inc();
let captured = Arc::new(Mutex::new(None::<String>));
let captured_clone = captured.clone();
+57
View File
@@ -172,6 +172,26 @@ impl Guard {
self.is_write_active = !is_empty_whitelist || !self.signing_key.is_empty();
}
/// Refresh the JWT signing keys and their expirations in place so operators
/// can rotate keys via SIGHUP without restarting the process. Mirrors Go's
/// `Guard::UpdateSigningKeys`: it swaps the fields and recomputes
/// `is_write_active`.
pub fn update_signing_keys(
&mut self,
signing_key: SigningKey,
expires_after_sec: i64,
read_signing_key: SigningKey,
read_expires_after_sec: i64,
) {
self.signing_key = signing_key;
self.expires_after_sec = expires_after_sec;
self.read_signing_key = read_signing_key;
self.read_expires_after_sec = read_expires_after_sec;
let is_empty_whitelist = self.whitelist_ips.is_empty() && self.whitelist_cidrs.is_empty();
self.is_write_active = !is_empty_whitelist || !self.signing_key.is_empty();
}
/// Check if a remote IP is in the whitelist.
/// Returns true if write security is inactive (no whitelist and no signing key),
/// if the whitelist is empty, or if the IP matches.
@@ -463,6 +483,43 @@ mod tests {
assert!(matches!(err, Err(JwtError::FileIdMismatch { .. })));
}
#[test]
fn test_update_signing_keys_rotates() {
let mut guard = Guard::new(
&[],
SigningKey::from_string("old-write"),
10,
SigningKey::from_string("old-read"),
60,
);
guard.update_signing_keys(
SigningKey::from_string("new-write"),
11,
SigningKey::from_string("new-read"),
61,
);
let token = gen_jwt(&guard.signing_key, 60, "3,01637037d6").unwrap();
// Validates with the rotated key, not the old one.
assert!(decode_jwt(&guard.signing_key, &token).is_ok());
assert!(decode_jwt(&SigningKey::from_string("old-write"), &token).is_err());
assert_eq!(guard.expires_after_sec, 11);
assert_eq!(guard.read_expires_after_sec, 61);
}
#[test]
fn test_update_signing_keys_toggles_write_active() {
let mut guard = Guard::new(&[], SigningKey(vec![]), 0, SigningKey(vec![]), 0);
assert!(guard.check_jwt(None, true).is_ok());
guard.update_signing_keys(SigningKey::from_string("write"), 10, SigningKey(vec![]), 0);
assert!(guard.is_write_active);
guard.update_signing_keys(SigningKey(vec![]), 0, SigningKey(vec![]), 0);
assert!(!guard.is_write_active);
}
#[test]
fn test_extract_host() {
assert_eq!(extract_host("192.168.1.1:8080"), "192.168.1.1");
+97 -8
View File
@@ -399,6 +399,27 @@ impl VolumeServer for VolumeGrpcService {
if !is_ec_volume {
let mut store = self.state.store.write().unwrap();
// Recheck EC state under the write lock before mutating the .dat. The
// is_ec_volume snapshot was taken earlier under a separate read lock;
// ec.encode mounts EC shards (copied from the .dat) BEFORE deleting the
// originals, so the vid can be EC now while the .dat still exists. A
// delete_volume_needle here would tombstone that .dat, which the encode
// then removes — the delete is lost and the needle resurrected from the
// pre-tombstone shards. If the vid is now EC, return a retriable 503 so
// the filer requeues the delete onto the EC path.
if store.has_ec_volume(file_id.volume_id) {
results.push(volume_server_pb::DeleteResult {
file_id: fid_str.clone(),
status: 503,
error: format!(
"volume {} became ec during delete, try again",
file_id.volume_id
),
size: 0,
version: 0,
});
continue;
}
match store.delete_volume_needle(file_id.volume_id, &mut n) {
Ok(size) => {
if size.0 == 0 {
@@ -420,13 +441,35 @@ impl VolumeServer for VolumeGrpcService {
}
}
Err(e) => {
results.push(volume_server_pb::DeleteResult {
file_id: fid_str.clone(),
status: 500,
error: e.to_string(),
size: 0,
version: 0,
});
// The volume vanished between the is_ec_volume snapshot and
// this mutation. If an EC volume now occupies the vid (ec.encode
// mounted EC then deleted the .dat under us), the delete belongs
// on the EC journal, not here — return a retriable 503 with the
// "try again" token so the filer requeues it and the retry hits
// the EC path. A bare exact "not found" would be dropped
// permanently by the filer chunk-GC.
if matches!(e, crate::storage::volume::VolumeError::NotFound)
&& store.has_ec_volume(file_id.volume_id)
{
results.push(volume_server_pb::DeleteResult {
file_id: fid_str.clone(),
status: 503,
error: format!(
"volume {} became ec during delete, try again",
file_id.volume_id
),
size: 0,
version: 0,
});
} else {
results.push(volume_server_pb::DeleteResult {
file_id: fid_str.clone(),
status: 500,
error: e.to_string(),
size: 0,
version: 0,
});
}
}
}
} else {
@@ -2133,6 +2176,12 @@ impl VolumeServer for VolumeGrpcService {
ec_shard_config: Some(crate::storage::volume::VifEcShardConfig {
data_shards: data_shards,
parity_shards: parity_shards,
// This run's identity; the read path rejects a shard from a
// different encode run.
encode_ts_ns: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as i64,
}),
..Default::default()
};
@@ -2593,12 +2642,35 @@ impl VolumeServer for VolumeGrpcService {
self.state.check_maintenance()?;
let req = request.into_inner();
let vid = VolumeId(req.volume_id);
if req.full_teardown {
// Pre-encode cleanup: evict the volume and wipe every EC artifact for it
// on every disk, not just the listed shards, so a remote node retains no
// stale generation that a fresh gen-0 copy would collide with. Echo the
// acknowledgement so the caller can tell a pre-upgrade server apart.
{
let mut store = self.state.store.write().unwrap();
let _ = store.remove_ec_volume(vid);
for loc in &store.locations {
loc.remove_ec_volume_files_full_teardown(&req.collection, vid);
}
}
self.state.volume_state_notify.notify_one();
return Ok(Response::new(
volume_server_pb::VolumeEcShardsDeleteResponse {
full_teardown_done: true,
},
));
}
let mut store = self.state.store.write().unwrap();
store.delete_ec_shards(vid, &req.collection, &req.shard_ids);
drop(store);
self.state.volume_state_notify.notify_one();
Ok(Response::new(
volume_server_pb::VolumeEcShardsDeleteResponse {},
volume_server_pb::VolumeEcShardsDeleteResponse {
full_teardown_done: false,
},
))
}
@@ -2672,6 +2744,21 @@ impl VolumeServer for VolumeGrpcService {
))
})?;
// Reject a shard whose identity doesn't match the caller's index; the caller
// then recovers from parity. Lenient only when the caller has no identity
// (pre-upgrade reader): a known caller must not accept an unstamped holder,
// which would serve a stale shard from a different encode run.
if req.encode_ts_ns != 0 && req.encode_ts_ns != ec_vol.encode_ts_ns {
return Err(Status::failed_precondition(format!(
"ec shard {}.{} belongs to a different encode run",
req.volume_id, req.shard_id
)));
}
// Identity of the shard actually served, echoed on every response chunk so
// the client can reject a different encode run even from a pre-upgrade server.
let served_encode_ts_ns = ec_vol.encode_ts_ns;
// Check if the requested needle is deleted (via .ecx index, matching Go)
if req.file_key > 0 {
let needle_id = NeedleId(req.file_key);
@@ -2682,6 +2769,7 @@ impl VolumeServer for VolumeGrpcService {
if size.is_deleted() {
let results = vec![Ok(volume_server_pb::VolumeEcShardReadResponse {
is_deleted: true,
encode_ts_ns: served_encode_ts_ns,
..Default::default()
})];
return Ok(Response::new(Box::pin(tokio_stream::iter(results))));
@@ -2730,6 +2818,7 @@ impl VolumeServer for VolumeGrpcService {
results.push(Ok(volume_server_pb::VolumeEcShardReadResponse {
data: buf,
is_deleted: false,
encode_ts_ns: served_encode_ts_ns,
}));
if n < chunk_size {
break; // short read means EOF
+164 -53
View File
@@ -23,6 +23,27 @@ use crate::pb::volume_server_pb;
use crate::storage::needle::needle::Needle;
use crate::storage::types::*;
/// Slack added over the configured file-size limit when bounding the raw
/// request body, to allow for multipart/form-data framing overhead. The exact
/// per-file limit is still enforced on the parsed data after multipart parsing.
const UPLOAD_BODY_OVERHEAD: usize = 16 * 1024 * 1024; // 16 MiB
/// Upper bound on bytes we will materialize in memory for a single request when
/// expanding stored content (gzip decompression or chunk-manifest assembly).
/// Guards against gzip bombs and crafted/oversized manifest sizes OOM-killing
/// the server; legitimate large objects are read via client-side chunk fetches.
const MAX_EXPANSION_BYTES: u64 = 2 * 1024 * 1024 * 1024; // 2 GiB
/// Why `maybe_decompress_gzip` failed, so callers can distinguish a recoverable
/// "not valid gzip, use the raw bytes" from "the bomb cap was hit, reject it".
#[derive(Debug)]
enum GunzipError {
/// Input was not valid gzip / decode failed — callers may fall back to raw.
Decode,
/// Decompressed output would exceed `MAX_EXPANSION_BYTES` — must be rejected.
TooLarge,
}
// ============================================================================
// Inflight Throttle Guard
// ============================================================================
@@ -1452,12 +1473,16 @@ async fn get_or_head_handler_inner(
if is_compressed {
if needs_image_ops {
// Always decompress for image operations (Go decompresses before resize/crop)
use flate2::read::GzDecoder;
use std::io::Read as _;
let mut decoder = GzDecoder::new(&data[..]);
let mut decompressed = Vec::new();
if decoder.read_to_end(&mut decompressed).is_ok() {
data = decompressed;
match maybe_decompress_gzip(&data) {
Ok(decompressed) => data = decompressed,
Err(GunzipError::TooLarge) => {
return (
StatusCode::PAYLOAD_TOO_LARGE,
"compressed object exceeds decompression limit",
)
.into_response()
}
Err(GunzipError::Decode) => {} // not valid gzip; keep raw bytes
}
} else {
let accept_encoding = headers
@@ -1474,12 +1499,16 @@ async fn get_or_head_handler_inner(
response_headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap());
} else {
// Decompress for client
use flate2::read::GzDecoder;
use std::io::Read as _;
let mut decoder = GzDecoder::new(&data[..]);
let mut decompressed = Vec::new();
if decoder.read_to_end(&mut decompressed).is_ok() {
data = decompressed;
match maybe_decompress_gzip(&data) {
Ok(decompressed) => data = decompressed,
Err(GunzipError::TooLarge) => {
return (
StatusCode::PAYLOAD_TOO_LARGE,
"compressed object exceeds decompression limit",
)
.into_response()
}
Err(GunzipError::Decode) => {} // not valid gzip; keep raw bytes
}
}
}
@@ -2134,15 +2163,30 @@ pub async fn post_handler(
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Read body
let body = match axum::body::to_bytes(request.into_body(), usize::MAX).await {
// Read body, bounded by the configured file-size limit so a single upload
// cannot buffer unbounded memory and OOM-kill the server (mirrors Go's
// io.LimitReader(r.Body, sizeLimit+1)). A margin covers multipart framing;
// the exact per-file limit is still enforced on the parsed data below.
let body_limit = if state.file_size_limit_bytes > 0 {
// try_from (not `as usize`) so a >usize::MAX limit on 32-bit caps at
// usize::MAX instead of silently truncating/wrapping to a tiny value.
usize::try_from(state.file_size_limit_bytes)
.unwrap_or(usize::MAX)
.saturating_add(UPLOAD_BODY_OVERHEAD)
} else {
usize::MAX
};
let body = match axum::body::to_bytes(request.into_body(), body_limit).await {
Ok(b) => b,
Err(e) => {
return json_error_with_query(
StatusCode::BAD_REQUEST,
format!("read body: {}", e),
Some(&query),
)
// With a limit configured, an error here means the body exceeded it
// before we buffered the whole thing; report it like the size check.
let msg = if state.file_size_limit_bytes > 0 {
format!("file over the limited {} bytes", state.file_size_limit_bytes)
} else {
format!("read body: {}", e)
};
return json_error_with_query(StatusCode::BAD_REQUEST, msg, Some(&query));
}
};
@@ -2282,7 +2326,17 @@ pub async fn post_handler(
};
let uncompressed_data = if is_gzipped {
maybe_decompress_gzip(&body_data_raw).unwrap_or_else(|| body_data_raw.clone())
match maybe_decompress_gzip(&body_data_raw) {
Ok(d) => d,
Err(GunzipError::TooLarge) => {
return json_error_with_query(
StatusCode::PAYLOAD_TOO_LARGE,
"compressed object exceeds decompression limit",
Some(&query),
);
}
Err(GunzipError::Decode) => body_data_raw.clone(),
}
} else {
body_data_raw.clone()
};
@@ -2761,14 +2815,16 @@ pub async fn delete_handler(
// If this is a chunk manifest, delete child chunks first
if n.is_chunk_manifest() {
let manifest_data = if n.is_compressed() {
use flate2::read::GzDecoder;
use std::io::Read as _;
let mut decoder = GzDecoder::new(&n.data[..]);
let mut decompressed = Vec::new();
if decoder.read_to_end(&mut decompressed).is_ok() {
decompressed
} else {
n.data.clone()
match maybe_decompress_gzip(&n.data) {
Ok(d) => d,
Err(GunzipError::TooLarge) => {
return json_error_with_query(
StatusCode::PAYLOAD_TOO_LARGE,
"compressed manifest exceeds decompression limit",
Some(&del_query),
);
}
Err(GunzipError::Decode) => n.data.clone(),
}
} else {
n.data.clone()
@@ -3124,14 +3180,19 @@ fn try_expand_chunk_manifest(
last_modified_str: &Option<String>,
) -> Option<Response> {
let data = if n.is_compressed() {
use flate2::read::GzDecoder;
use std::io::Read as _;
let mut decoder = GzDecoder::new(&n.data[..]);
let mut decompressed = Vec::new();
if decoder.read_to_end(&mut decompressed).is_err() {
return None;
match maybe_decompress_gzip(&n.data) {
Ok(d) => d,
Err(GunzipError::TooLarge) => {
return Some(
(
StatusCode::PAYLOAD_TOO_LARGE,
"compressed manifest exceeds decompression limit",
)
.into_response(),
)
}
Err(GunzipError::Decode) => return None,
}
decompressed
} else {
n.data.clone()
};
@@ -3141,6 +3202,13 @@ fn try_expand_chunk_manifest(
Err(_) => return None,
};
// Guard the attacker-controlled manifest size before allocating: a negative
// value would wrap to a huge usize (capacity-overflow panic) and an oversized
// one would OOM-kill the server.
if manifest.size < 0 || manifest.size as u64 > MAX_EXPANSION_BYTES {
return None;
}
// Read and concatenate all chunks
let mut result = vec![0u8; manifest.size as usize];
let store = state.store.read().unwrap();
@@ -3174,24 +3242,54 @@ fn try_expand_chunk_manifest(
)
}
}
let chunk_data = if chunk_needle.is_compressed() {
// Validate the attacker-controlled chunk offset before indexing: a
// negative value would wrap to a huge usize, and an out-of-range one has
// nowhere to land.
if chunk.offset < 0 {
return Some(
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("invalid negative chunk offset in {}", chunk.fid),
)
.into_response(),
);
}
let offset = chunk.offset as usize;
if offset >= result.len() {
continue;
}
// Write the chunk into its window in `result`. Compressed chunks inflate
// directly into the destination slice (bounded by the remaining window),
// so a chunk never allocates a second large buffer — peak memory stays at
// ~manifest.size instead of doubling. Bytes past the window are dropped,
// matching the prior truncation behavior.
if chunk_needle.is_compressed() {
use flate2::read::GzDecoder;
use std::io::Read as _;
let mut decoder = GzDecoder::new(&chunk_needle.data[..]);
let mut decompressed = Vec::new();
if decoder.read_to_end(&mut decompressed).is_ok() {
decompressed
} else {
chunk_needle.data.clone()
let window = &mut result[offset..];
let mut written = 0usize;
let mut decode_failed = false;
while written < window.len() {
match decoder.read(&mut window[written..]) {
Ok(0) => break,
Ok(n) => written += n,
Err(_) => {
decode_failed = true;
break;
}
}
}
// On any decode failure, drop the partial output and fall back to the
// chunk's raw bytes (truncated to the window), preserving prior behavior.
if decode_failed {
window[..written].fill(0);
let copy_len = chunk_needle.data.len().min(window.len());
window[..copy_len].copy_from_slice(&chunk_needle.data[..copy_len]);
}
} else {
chunk_needle.data.clone()
};
let offset = chunk.offset as usize;
let end = std::cmp::min(offset + chunk_data.len(), result.len());
let copy_len = end - offset;
if copy_len > 0 {
result[offset..offset + copy_len].copy_from_slice(&chunk_data[..copy_len]);
let copy_len = chunk_needle.data.len().min(result.len() - offset);
result[offset..offset + copy_len].copy_from_slice(&chunk_needle.data[..copy_len]);
}
}
@@ -3564,13 +3662,22 @@ fn try_gzip_data(data: &[u8]) -> Option<Vec<u8>> {
encoder.finish().ok()
}
fn maybe_decompress_gzip(data: &[u8]) -> Option<Vec<u8>> {
fn maybe_decompress_gzip(data: &[u8]) -> Result<Vec<u8>, GunzipError> {
use flate2::read::GzDecoder;
use std::io::Read;
let mut decoder = GzDecoder::new(data);
// Cap the output so a crafted, highly-compressible (gzip-bomb) needle cannot
// OOM the server when decompressed on read or upload. take(limit+1) lets us
// tell "exactly at the limit" apart from "over it", which we reject as
// TooLarge so callers fail the request instead of silently using raw bytes.
let mut decoder = GzDecoder::new(data).take(MAX_EXPANSION_BYTES + 1);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed).ok()?;
Some(decompressed)
decoder
.read_to_end(&mut decompressed)
.map_err(|_| GunzipError::Decode)?;
if decompressed.len() as u64 > MAX_EXPANSION_BYTES {
return Err(GunzipError::TooLarge);
}
Ok(decompressed)
}
fn compute_md5_base64(data: &[u8]) -> String {
@@ -3812,7 +3919,11 @@ mod tests {
let compressed = try_gzip_data(data).unwrap();
let decompressed = maybe_decompress_gzip(&compressed).unwrap();
assert_eq!(decompressed, data);
assert!(maybe_decompress_gzip(data).is_none());
// Non-gzip input is reported as a decode error (callers fall back to raw).
assert!(matches!(
maybe_decompress_gzip(data),
Err(GunzipError::Decode)
));
}
#[test]
+27
View File
@@ -70,6 +70,10 @@ struct Snapshot {
intervals: Vec<IntervalResult>,
cached_locations: HashMap<ShardId, Vec<String>>,
cache_refreshed_at: Option<Instant>,
/// This volume's encode identity, carried to peers on remote shard reads so a
/// shard from a different encode run is rejected rather than served at a
/// mismatched offset. 0 for a pre-feature volume (lenient).
encode_ts_ns: i64,
}
/// Top-level entry point. Returns `Ok(None)` for "not found" (matches
@@ -147,6 +151,7 @@ pub async fn read_ec_shard_needle_distributed(
&shard_locations,
snapshot.data_shards as usize,
snapshot.parity_shards as usize,
snapshot.encode_ts_ns,
)
.await?;
assembled.push(buf);
@@ -259,6 +264,7 @@ fn snapshot_under_lock(
intervals: interval_results,
cached_locations,
cache_refreshed_at,
encode_ts_ns: ecv.encode_ts_ns,
}))
}
@@ -383,6 +389,7 @@ async fn fetch_one_interval(
shard_locations: &HashMap<ShardId, Vec<String>>,
data_shards: usize,
parity_shards: usize,
expected_encode_ts_ns: i64,
) -> io::Result<Vec<u8>> {
// Direct peer read against the cached locations for this shard.
if let Some(sources) = shard_locations.get(&shard_id) {
@@ -395,6 +402,7 @@ async fn fetch_one_interval(
shard_id,
shard_offset,
size,
expected_encode_ts_ns,
)
.await
{
@@ -424,6 +432,7 @@ async fn fetch_one_interval(
shard_locations,
data_shards,
parity_shards,
expected_encode_ts_ns,
)
.await
}
@@ -436,6 +445,7 @@ async fn read_remote_ec_shard_interval(
shard_id: ShardId,
shard_offset: i64,
size: usize,
expected_encode_ts_ns: i64,
) -> io::Result<Vec<u8>> {
let mut last_err: Option<io::Error> = None;
for src in sources {
@@ -447,6 +457,7 @@ async fn read_remote_ec_shard_interval(
shard_id,
shard_offset,
size,
expected_encode_ts_ns,
)
.await
{
@@ -470,6 +481,7 @@ async fn do_read_remote_ec_shard_interval(
shard_id: ShardId,
shard_offset: i64,
size: usize,
expected_encode_ts_ns: i64,
) -> io::Result<Vec<u8>> {
let grpc_addr =
parse_grpc_address(source).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
@@ -505,6 +517,7 @@ async fn do_read_remote_ec_shard_interval(
offset: shard_offset,
size: size as i64,
file_key: needle_id.0,
encode_ts_ns: expected_encode_ts_ns,
};
let resp = client
.volume_ec_shard_read(Request::new(req))
@@ -523,6 +536,18 @@ async fn do_read_remote_ec_shard_interval(
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("recv: {}", e)))?
{
// Validate the served shard's identity client-side, so the guard holds even
// against a pre-upgrade server that ignored the request field (returns 0).
// A mismatch fails the read; the caller recovers from parity.
if expected_encode_ts_ns != 0 && msg.encode_ts_ns != expected_encode_ts_ns {
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"ec shard {}.{} from {} belongs to a different encode run (want {} got {})",
vid.0, shard_id, source, expected_encode_ts_ns, msg.encode_ts_ns
),
));
}
if !msg.data.is_empty() {
out.extend_from_slice(&msg.data);
}
@@ -554,6 +579,7 @@ async fn recover_one_remote_ec_shard_interval(
shard_locations: &HashMap<ShardId, Vec<String>>,
data_shards: usize,
parity_shards: usize,
expected_encode_ts_ns: i64,
) -> io::Result<Vec<u8>> {
let total_shards = data_shards + parity_shards;
let rs = ReedSolomon::new(data_shards, parity_shards).map_err(|e| {
@@ -614,6 +640,7 @@ async fn recover_one_remote_ec_shard_interval(
sid,
shard_offset,
size,
expected_encode_ts_ns,
)
.await;
(sid, res)
+61 -3
View File
@@ -21,7 +21,9 @@ use crate::storage::erasure_coding::ec_volume::EcVolume;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::super_block::ReplicaPlacement;
use crate::storage::types::*;
use crate::storage::volume::{remove_volume_files, volume_file_name, Volume, VolumeError};
use crate::storage::volume::{
remove_volume_files, volume_file_name, VifVolumeInfo, Volume, VolumeError,
};
/// A single disk location managing volumes in one directory.
pub struct DiskLocation {
@@ -131,8 +133,8 @@ impl DiskLocation {
volume_id = vid.0,
"volume was not completed: {}, removing files", note
);
remove_volume_files(&volume_name);
remove_volume_files(&idx_name);
remove_volume_files(&volume_name, false);
remove_volume_files(&idx_name, false);
continue;
}
@@ -178,6 +180,20 @@ impl DiskLocation {
continue;
}
// Load existing data only; never create a phantom `.dat`. A lone
// `.vif`/`.idx` (e.g. an EC sidecar whose `.ecx` is on a sibling
// disk) would otherwise have Volume::new write an 8-byte stub that
// the sibling-.dat prune deletes real shards against. Remote-tiered
// volumes also have no local `.dat`, but their `.vif` points at
// remote files and must still load via the remote path.
let dat_path = format!("{}.dat", volume_name);
if !check_dat_file_exists(&dat_path)
&& !vif_references_remote_file(&format!("{}.vif", volume_name))
&& !vif_references_remote_file(&format!("{}.vif", idx_name))
{
continue;
}
match Volume::new(
&self.directory,
&self.idx_directory,
@@ -328,6 +344,37 @@ impl DiskLocation {
}
}
/// Full-teardown variant: everything remove_ec_volume_files clears, PLUS the
/// normal-volume <base>.vif on a SHARD-ONLY node. An interrupted shard copy
/// (which installs shards + .ecx before .vif) could otherwise mount a fresh
/// generation under the prior run's identity / ratio / dat_file_size carried by
/// a stale .vif. Gated on .idx absence so a source-volume holder keeps its live
/// .vif. Reconcile/load-fallback call remove_ec_volume_files directly and
/// intentionally preserve it, mirroring Go's removeEcVolumeFiles (reconcile) vs
/// removeStaleEcArtifacts (teardown) split.
pub(crate) fn remove_ec_volume_files_full_teardown(&self, collection: &str, vid: VolumeId) {
self.remove_ec_volume_files(collection, vid);
let base = volume_file_name(&self.directory, collection, vid);
let idx_base = volume_file_name(&self.idx_directory, collection, vid);
// try_exists, not exists(): a stat error on a PRESENT .idx must not read as
// "shard-only" and delete a live source volume's .vif. Only Ok(false) (genuine
// NotFound) clears it; on a stat error stay conservative and keep the sidecar.
if matches!(
std::path::Path::new(&format!("{}.idx", idx_base)).try_exists(),
Ok(false)
) {
let _ = fs::remove_file(format!("{}.vif", idx_base));
if self.idx_directory != self.directory
&& matches!(
std::path::Path::new(&format!("{}.idx", base)).try_exists(),
Ok(false)
)
{
let _ = fs::remove_file(format!("{}.vif", base));
}
}
}
/// Find a volume by ID.
pub fn find_volume(&self, vid: VolumeId) -> Option<&Volume> {
self.volumes.get(&vid)
@@ -1018,6 +1065,17 @@ fn check_dat_file_exists(path: &str) -> bool {
}
}
/// True when a `.vif` references remote-tier files: a remote-only volume
/// that has no local `.dat` but must still load via the remote path,
/// rather than be skipped as a lone EC sidecar.
fn vif_references_remote_file(vif_path: &str) -> bool {
fs::read_to_string(vif_path)
.ok()
.and_then(|s| serde_json::from_str::<VifVolumeInfo>(&s).ok())
.map(|vif| !vif.files.is_empty())
.unwrap_or(false)
}
fn parse_volume_filename(filename: &str) -> Option<(String, VolumeId)> {
let stem = filename
.strip_suffix(".dat")
@@ -57,6 +57,10 @@ pub struct EcVolume {
pub shard_locations_refresh_time: std::sync::Mutex<Option<std::time::Instant>>,
/// EC volume expiration time (unix epoch seconds), set during EC encode from TTL.
pub expire_at_sec: u64,
/// Encode-run identity (unix nanos) loaded from the .vif EcShardConfig. A read
/// served from a shard of a different encode run is rejected (server- and
/// client-side); 0 for a pre-feature volume, which is treated leniently.
pub encode_ts_ns: i64,
}
/// Locate the `.vif` for a (collection, vid) by preferring the data dir
@@ -137,7 +141,7 @@ impl EcVolume {
// for shard-size math and by the Store-level prune in
// `store_ec_reconcile.rs` to verify a sibling-disk .dat is
// plausibly the encoding source (#9478).
let (expire_at_sec, vif_version, vif_dat_file_size) = {
let (expire_at_sec, vif_version, vif_dat_file_size, encode_ts_ns) = {
let vif_path = locate_vif_path(dir, dir_idx, collection, volume_id);
if let Ok(vif_content) = std::fs::read_to_string(&vif_path) {
if let Ok(vif_info) =
@@ -148,12 +152,21 @@ impl EcVolume {
} else {
Version::current()
};
(vif_info.expire_at_sec, ver, vif_info.dat_file_size)
let cfg_encode_ts_ns = vif_info
.ec_shard_config
.as_ref()
.map_or(0, |c| c.encode_ts_ns);
(
vif_info.expire_at_sec,
ver,
vif_info.dat_file_size,
cfg_encode_ts_ns,
)
} else {
(0, Version::current(), 0)
(0, Version::current(), 0, 0)
}
} else {
(0, Version::current(), 0)
(0, Version::current(), 0, 0)
}
};
@@ -177,6 +190,7 @@ impl EcVolume {
shard_locations: std::sync::RwLock::new(HashMap::new()),
shard_locations_refresh_time: std::sync::Mutex::new(None),
expire_at_sec,
encode_ts_ns,
};
// Open .ecx file (sorted index) in read/write mode for in-place deletion marking.
@@ -605,7 +619,19 @@ impl EcVolume {
})?;
let mut buf = vec![0u8; interval.size as usize];
shard.read_at(&mut buf, shard_offset as u64)?;
let n = shard.read_at(&mut buf, shard_offset as u64)?;
if n != buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"short read on ec shard {}: read {} of {} bytes for needle {}",
shard_id,
n,
buf.len(),
needle_id
),
));
}
bytes.extend_from_slice(&buf);
}
@@ -1243,6 +1269,7 @@ mod tests {
ec_shard_config: Some(crate::storage::volume::VifEcShardConfig {
data_shards: 6,
parity_shards: 3,
..Default::default()
}),
..Default::default()
};
@@ -1268,6 +1295,7 @@ mod tests {
ec_shard_config: Some(crate::storage::volume::VifEcShardConfig {
data_shards: 10,
parity_shards: 10,
..Default::default()
}),
..Default::default()
};
+51 -2
View File
@@ -598,8 +598,16 @@ impl Store {
as i32;
let mut max_count = vol_count + ec_equivalent;
if unclaimed > volume_size_limit as i64 {
max_count += (unclaimed as u64 / volume_size_limit) as i32 - 1;
// One slot per full volume that fits in the unclaimed space.
// A "- 1" here used to zero the count when the disk had room for
// exactly one volume (free between 1x and 2x the limit), stranding
// auto-sized disks at max_volume_count 0 with no writable volume.
if unclaimed > 0 {
max_count += (unclaimed as u64 / volume_size_limit) as i32;
}
// An auto-sized disk with free space always hosts at least one volume.
if max_count < 1 {
max_count = 1;
}
loc.max_volume_count.store(max_count, Ordering::Relaxed);
@@ -1462,6 +1470,47 @@ mod tests {
assert!(with_preallocate > without_preallocate);
}
#[test]
fn test_maybe_adjust_volume_max_no_dead_zone() {
// With auto max (original_max_volume_count == 0) and a large volume size
// limit, a disk with room for at least one volume must not report 0
// slots. It once did so for disks sized between 1x and 2x the limit,
// leaving no writable volume and timing out every assign.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let (_, free) = crate::storage::disk_location::get_disk_stats(dir);
if free < 4 {
return; // cannot determine free disk space
}
let mut store = Store::new(NeedleMapKind::InMemory);
store
.add_location(
dir,
dir,
0, // auto
DiskType::HardDrive,
MinFreeSpace::Percent(1.0),
Vec::new(),
)
.unwrap();
// free disk is 1.5x the limit: room for one full volume, less than two.
let volume_size_limit = free * 2 / 3;
store
.volume_size_limit
.store(volume_size_limit, Ordering::Relaxed);
store.maybe_adjust_volume_max();
let max = store.locations[0].max_volume_count.load(Ordering::Relaxed);
assert!(
max >= 1,
"auto-sized disk with room for one volume reported max_volume_count={max}; want >= 1"
);
}
#[test]
fn test_find_free_location_predicate_prefers_more_capacity_and_skips_low_disk() {
let tmp1 = TempDir::new().unwrap();
@@ -327,6 +327,7 @@ mod tests {
ec_shard_config: Some(VifEcShardConfig {
data_shards,
parity_shards,
..Default::default()
}),
..Default::default()
};
+109 -20
View File
@@ -203,14 +203,11 @@ impl Store {
/// this server) also fall through unchanged because the `.dat`
/// index never matches.
///
/// Before deleting any EC files we also check that the sibling
/// `.dat` is plausibly the encoding source: at least
/// `SUPER_BLOCK_SIZE` bytes long, and — when the EC's `.vif`
/// recorded a non-zero source size in `dat_file_size` — at least
/// that many bytes. A zero-byte shell or a truncated `.dat` does
/// not justify wiping the partial EC, because that EC shard may
/// still combine usefully with shards on other servers in a
/// recoverable distributed-EC layout.
/// The sibling `.dat` must be a credible encoding source before we
/// delete anything: at least the size `.vif` recorded at encode time,
/// or — when unknown (0) — more than a bare superblock so an empty
/// 8-byte stub can't pass. A truncated `.dat` leaves the partial EC
/// alone; those shards may still reconstruct from other servers.
///
/// We don't have to push anything to a deleted-shards channel
/// here: the Rust heartbeat path in `server/heartbeat.rs` diffs
@@ -257,14 +254,13 @@ impl Store {
// per-disk pass; don't second-guess it here.
continue;
}
// Decide whether the sibling .dat is credible. Prefer
// the size baked into .vif at encode time; fall back
// to "at least a superblock" for old EC volumes whose
// .vif predates the field.
// Credible source size: prefer .vif's encode-time size; when
// unknown (0) require more than a bare superblock so an empty
// 8-byte stub (e.g. a phantom .dat) can't pass.
let required = if ev.dat_file_size > 0 {
ev.dat_file_size as u64
} else {
SUPER_BLOCK_SIZE as u64
SUPER_BLOCK_SIZE as u64 + 1
};
if owner.size < required {
warn!(
@@ -459,6 +455,7 @@ mod tests {
ec_shard_config: Some(VifEcShardConfig {
data_shards,
parity_shards,
..Default::default()
}),
..Default::default()
};
@@ -469,6 +466,97 @@ mod tests {
.unwrap();
}
/// Regression: a lone `.vif` whose `.ecx` is on a sibling disk must not
/// make the loader create a phantom `.dat`, nor the sibling-.dat prune
/// delete the real shards on the sibling.
#[test]
fn test_lone_vif_does_not_create_phantom_dat_or_delete_shards() {
let tmp = TempDir::new().unwrap();
let d0 = tmp.path().join("data0");
let d1 = tmp.path().join("data1");
std::fs::create_dir_all(&d0).unwrap();
std::fs::create_dir_all(&d1).unwrap();
let coll = "warp-loadtest";
let vid = 57u32;
// d0: a self-contained but partial (2 < 10) EC volume.
write_shard(d0.to_str().unwrap(), coll, vid, 2);
write_shard(d0.to_str().unwrap(), coll, vid, 4);
write_index_files(d0.to_str().unwrap(), coll, vid, 10, 4);
// d1: ONLY the mirrored `.vif` — no `.ecx`, no shard, no `.dat`.
std::fs::copy(
d0.join(format!("{}_{}.vif", coll, vid)),
d1.join(format!("{}_{}.vif", coll, vid)),
)
.unwrap();
let mut store = Store::new(NeedleMapKind::InMemory);
for d in [&d0, &d1] {
store
.add_location(
d.to_str().unwrap(),
d.to_str().unwrap(),
100,
DiskType::HardDrive,
MinFreeSpace::Percent(0.0),
Vec::new(),
)
.unwrap();
}
let phantom = d1.join(format!("{}_{}.dat", coll, vid));
assert!(
!phantom.exists(),
"loader created a phantom .dat from a lone .vif"
);
let total: usize = store.locations.iter().map(|l| l.ec_shard_count()).sum();
assert_eq!(total, 2, "real EC shards were deleted (total={})", total);
}
/// Regression for the prune credibility gate: when the EC `.vif`
/// records no source size (`dat_file_size == 0`), an empty 8-byte
/// sibling `.dat` stub must NOT justify deleting partial EC shards.
#[test]
fn test_prune_refuses_unverifiable_8byte_dat() {
let tmp = TempDir::new().unwrap();
let d0 = tmp.path().join("data0");
let d1 = tmp.path().join("data1");
std::fs::create_dir_all(&d0).unwrap();
std::fs::create_dir_all(&d1).unwrap();
let coll = "warp-loadtest";
let vid = 99u32;
// d0: partial EC (2 shards); its `.vif` records no source size.
write_shard(d0.to_str().unwrap(), coll, vid, 0);
write_shard(d0.to_str().unwrap(), coll, vid, 1);
write_index_files(d0.to_str().unwrap(), coll, vid, 10, 4);
// d1: a real but empty 8-byte (superblock-sized) `.dat` stub.
std::fs::write(d1.join(format!("{}_{}.dat", coll, vid)), vec![0u8; 8]).unwrap();
let mut store = Store::new(NeedleMapKind::InMemory);
for d in [&d0, &d1] {
store
.add_location(
d.to_str().unwrap(),
d.to_str().unwrap(),
100,
DiskType::HardDrive,
MinFreeSpace::Percent(0.0),
Vec::new(),
)
.unwrap();
}
let total: usize = store.locations.iter().map(|l| l.ec_shard_count()).sum();
assert_eq!(
total, 2,
"prune deleted shards against an unverifiable 8-byte .dat (total={})",
total
);
}
/// Reproduces the orphan-shard layout from issue #9212. Shards live
/// on dir0; the .ecx / .ecj / .vif live on dir1. Without
/// reconciliation, dir0's shards are silently dropped at startup.
@@ -966,15 +1054,15 @@ mod tests {
std::fs::create_dir_all(&dat_dir).unwrap();
std::fs::create_dir_all(&ec_dir).unwrap();
let collection = "";
// Real collection so the loader's volume_file_name-based `.ecx`
// lookup matches the helpers (empty collection emits `_122.*` vs the
// expected `122.*`).
let collection = "pics";
let vid = 122u32;
// Disk A (sdd): a non-trivial .dat plus the regular-volume
// sidecars. The .dat content doesn't matter for the prune —
// load_existing_volumes refuses to mount it because the
// superblock is absent — but its file name has to be present
// so index_dat_owners records this disk as the .dat owner.
let dat_path = dat_dir.join(format!("{}_{}.dat", collection, vid).trim_start_matches('_'));
// Disk A (sdd): a .dat whose name must be present so index_dat_owners
// records this disk as the .dat owner (content doesn't matter).
let dat_path = dat_dir.join(format!("{}_{}.dat", collection, vid));
std::fs::write(&dat_path, vec![0u8; 1024]).unwrap();
// Disk B (sdf): partial EC — one shard, plus .ecx / .ecj / .vif.
@@ -1078,6 +1166,7 @@ mod tests {
ec_shard_config: Some(VifEcShardConfig {
data_shards: 10,
parity_shards: 4,
..Default::default()
}),
..Default::default()
};
+78 -4
View File
@@ -189,6 +189,10 @@ pub struct VifEcShardConfig {
pub data_shards: u32,
#[serde(default, rename = "parityShards")]
pub parity_shards: u32,
/// Encode time (unix nanos). Shards and .ecx of one encode run share it,
/// so a read served from a different run's shard is rejected.
#[serde(default, rename = "encodeTsNs", with = "string_or_i64")]
pub encode_ts_ns: i64,
}
/// Serde-compatible representation of OldVersionVolumeInfo for legacy .vif JSON deserialization.
@@ -279,6 +283,7 @@ impl VifVolumeInfo {
ec_shard_config: pb.ec_shard_config.as_ref().map(|c| VifEcShardConfig {
data_shards: c.data_shards,
parity_shards: c.parity_shards,
encode_ts_ns: c.encode_ts_ns,
}),
}
}
@@ -309,6 +314,7 @@ impl VifVolumeInfo {
crate::pb::volume_server_pb::EcShardConfig {
data_shards: c.data_shards,
parity_shards: c.parity_shards,
encode_ts_ns: c.encode_ts_ns,
}
}),
}
@@ -3071,11 +3077,15 @@ impl Volume {
let bytes = fake_del_needle.write_bytes(version);
dst_dat.write_all(&bytes)?;
// Record the tombstone's real .dat offset, like the normal delete path,
// so a deletion left at the .dat tail stays visible to the integrity
// check on reload. Offset 0 hid the trailing tombstone and falsely
// flipped the volume read-only.
let mut idx_entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE];
crate::storage::types::idx_entry_to_bytes(
&mut idx_entry_buf,
key,
Offset::from_actual_offset(0),
Offset::from_actual_offset(dat_offset as i64),
Size(crate::storage::types::TOMBSTONE_FILE_SIZE.into()),
);
dst_idx.write_all(&idx_entry_buf)?;
@@ -3157,12 +3167,34 @@ impl Volume {
}
}
// A regular volume and an EC volume for the same id share <base>.vif.
// When EC artefacts coexist on this disk (e.g. shards distributed onto
// a source replica before it is deleted), keep the .vif so removing the
// regular volume does not strip the EC volume's info file.
let keep_vif = self.shares_vif_with_ec_volume();
self.close();
remove_volume_files(&self.data_file_name());
remove_volume_files(&self.index_file_name());
remove_volume_files(&self.data_file_name(), keep_vif);
remove_volume_files(&self.index_file_name(), keep_vif);
Ok(())
}
/// Reports whether an EC volume for this id has a sealed .ecx on the same
/// disk, in which case its .vif is the same file as the regular volume's
/// and must outlive the regular volume's deletion. Mirrors the on-disk
/// portion of Go's Volume.sharesVifWithEcVolume / HasEcxFileOnDisk.
fn shares_vif_with_ec_volume(&self) -> bool {
let has_ecx = |base: &str| -> bool {
fs::metadata(format!("{}.ecx", base))
.map(|m| !m.is_dir() && m.len() > 0)
.unwrap_or(false)
};
if has_ecx(&volume_file_name(&self.dir_idx, &self.collection, self.id)) {
return true;
}
self.dir != self.dir_idx
&& has_ecx(&volume_file_name(&self.dir, &self.collection, self.id))
}
/// Check if an I/O error is EIO (errno 5) and record it for health monitoring.
/// On success (None), clears any previously recorded EIO error.
/// Matches Go's `checkReadWriteError` in volume_write.go.
@@ -3231,10 +3263,13 @@ fn get_append_at_ns(last: u64) -> u64 {
/// Remove all files associated with a volume.
/// .dat/.idx removals log at info level so destructive calls are traceable.
pub(crate) fn remove_volume_files(base: &str) {
pub(crate) fn remove_volume_files(base: &str, keep_vif: bool) {
for ext in &[
".dat", ".idx", ".vif", ".sdx", ".cpd", ".cpx", ".note", ".rdb",
] {
if *ext == ".vif" && keep_vif {
continue;
}
let path = format!("{}{}", base, ext);
let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
let existed = fs::remove_file(&path).is_ok();
@@ -4740,4 +4775,43 @@ mod tests {
".vif removed from data dir"
);
}
/// When an EC volume for the same id has a sealed .ecx on the same disk, the
/// .vif is shared with it and must survive the regular volume's deletion.
#[test]
fn test_destroy_keeps_vif_when_ec_coexists() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(1),
data: b"test".to_vec(),
data_size: 4,
..Needle::default()
};
v.write_needle(&mut n, true).unwrap();
let vif_path = format!("{}/1.vif", dir);
std::fs::write(&vif_path, r#"{"version":3}"#).unwrap();
// A sealed .ecx marks a coexisting EC volume for the same id.
let ecx_path = format!("{}/1.ecx", dir);
std::fs::write(&ecx_path, b"ec-index").unwrap();
v.destroy(false, false).unwrap();
let dat_path = format!("{}/1.dat", dir);
let idx_path = format!("{}/1.idx", dir);
assert!(!std::path::Path::new(&dat_path).exists(), ".dat removed");
assert!(!std::path::Path::new(&idx_path).exists(), ".idx removed");
assert!(
std::path::Path::new(&vif_path).exists(),
".vif kept: shared with the coexisting EC volume"
);
assert!(
std::path::Path::new(&ecx_path).exists(),
".ecx is an EC sidecar, never touched here"
);
}
}
Executable
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
# Terraform / OpenTofu working files
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
crash.log
crash.*.log
*.tfvars
!*.tfvars.example
override.tf
override.tf.json
*_override.tf
*_override.tf.json
.terraformrc
terraform.rc
+128
View File
@@ -0,0 +1,128 @@
# SeaweedFS on Terraform
> **Experimental.** This Terraform support is an early scaffold under active
> development. Interfaces (variables, outputs, module layout) may change without
> notice, and not every tier is implemented yet. Not recommended for production
> without your own review and testing.
Self-contained Terraform/OpenTofu modules to deploy SeaweedFS on cloud VMs
running the `weed` binary directly under systemd. No Helm and no Kubernetes
required.
What works today is verified end-to-end against a real `weed` cluster (see
"Test it locally" below).
## Layout
```text
terraform/
modules/
core/ cloud-agnostic renderer (ZERO cloud resources): turns an
address map + config into per-node weed argv, systemd units,
config files, disk-mount + secret-fetch scripts, and cloud-init.
Both the cloud wrappers and the local harnesses consume `nodes`.
security/ cloud-agnostic CA + per-component mTLS certs (distinct CNs) +
JWT signing keys (tls/random providers). Emits a `core_security`
object ready for the core, plus PEMs for secret-store delivery.
aws/ thin AWS wrapper: reserves stable ENIs (fixed private IPs)
first, feeds them to core, creates instances + protected EBS
data disks + SG; with enable_security it generates certs/JWT,
stores them in SSM SecureString, grants an instance role, and
renders a boot fetch-secrets.sh. Keyed for_each throughout.
examples/
aws-ha-distributed/ 3 masters + 3 volumes (1/AZ) + 2 filers + 1 S3,
secure-by-default (mTLS via SSM-delivered certs)
aws-all-in-one/ single `weed server` instance via core directly
test/
local/ render a cluster with core and run it as real weed processes
on 127.0.0.1 (no cloud, no docker), then assert it works
local-secure/ generate certs/JWT with the security module, run a real
mTLS cluster, and assert it forms + enforces JWT auth
```
## Design in one paragraph
The chart is the structural reference, not a dependency. A cloud-agnostic
**core** renders everything portable; thin per-cloud wrappers provision infra.
Addressing is an **input** to the core (wrapper reserves static IPs first), so
the wrapper -> core dependency is one-way with no apply-time cycle. Stateful
tiers (master/volume/filer) are keyed `for_each` maps, never `count`, so a
middle node can be replaced without reindexing its peers or reattaching the
wrong disk. Flag names are verified against the real `weed` binary
(notably: volume uses `-mserver` for the master list; gRPC is `-port.grpc`,
auto = http+10000; `minFreeSpacePercent` is a string).
## Test it locally
Requires `tofu` (or `terraform`), `jq`, `curl`, and a `weed` binary. Renders a
3-master + volume + filer + S3 cluster from the core module and runs it as real
processes, asserting quorum, volume registration, and filer/S3 round-trips:
```bash
cd terraform/test/local
WEED=/path/to/weed ./run_local_cluster.sh
# => 7 passed, 0 failed
```
The harness uses a high port range (29333/28080/28888/28333) so it does not
collide with a SeaweedFS cluster already running on the machine, and aborts if a
required port is taken. `KEEP=1 ./run_local_cluster.sh` leaves the cluster up.
### mTLS end-to-end
```bash
cd terraform/test/local-secure
WEED=/path/to/weed ./run_local_secure.sh
# => generates a CA + component certs + JWT, renders security.toml, runs a real
# mTLS cluster, asserts master/volume/filer form over mutual TLS and that the
# filer enforces JWT signing (unsigned writes get 401). 5 passed.
```
## Plan-level tests (no cloud)
```bash
cd terraform/modules/core && tofu test
# => 11 passed: peers list, -mserver vs -master, metrics gating,
# security.toml conditions, all-in-one inheritance, ...
```
## Validate the cloud wrappers
```bash
cd terraform/examples/aws-ha-distributed && tofu init && tofu validate
```
`apply` needs AWS credentials, a VPC, subnets, and an AMI with `weed` installed
(bake with Packer, or install at boot).
## Status
Implemented and verified:
- Tiers: master / volume / filer / s3 / all-in-one rendered by the core.
- Disk mount: cloud-init runs a `mount-disks.sh` that auto-discovers (or takes
explicit candidate devices), `blkid`-guards `mkfs`, mounts, and persists to
`/etc/fstab` by UUID. The AWS wrapper wires the protected EBS disk to `/data`.
- mTLS + JWT: the `security` module generates the CA + per-component certs
(distinct CNs) + JWT keys; the AWS wrapper stores them in SSM SecureString,
grants an instance role, and the core renders a boot fetch script so secrets
stay OUT of user_data. Proven end-to-end by `test/local-secure`.
Not yet implemented:
- sftp / admin / worker tiers (core does not render them yet).
- GCP/Azure wrappers, the data-plane provider, and the K8s-native module.
- CA in Vault PKI (currently TF-generated, so the CA key lives in state). KMS
decrypt in the IAM policy is scoped by
`ViaService` but uses `Resource="*"`; tighten to the SSM CMK in production.
## Gotchas
- This `weed` build starts an Iceberg REST catalog on `:8181` by default; set
`s3.iceberg_port = 0` to disable, or a port to relocate it.
- Disk auto-discovery assumes a single attached data disk per node. For multiple
data disks, pass explicit `devices` candidates in `disk_mounts`.
## Security note
`tofu output`/state can contain secrets. Generate secrets outside Terraform
(cloud secret manager / Vault) and fetch them at boot; never commit secret
material.
+133
View File
@@ -0,0 +1,133 @@
# SeaweedFS all-in-one on a single AWS instance.
#
# Demonstrates the layered design directly: call the cloud-agnostic core to
# render cloud-init for a `weed server` node, then attach it to one instance
# with a protected data disk. No wrapper module needed for the simple case.
#
# tofu init && tofu validate
# tofu apply # requires AWS credentials + a weed AMI
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40"
}
}
}
provider "aws" {
region = var.region
}
variable "region" {
type = string
default = "us-east-1"
}
variable "vpc_id" {
type = string
}
variable "subnet_id" {
type = string
}
variable "availability_zone" {
type = string
default = "us-east-1a"
}
variable "ami_id" {
type = string
}
variable "private_ip" {
type = string
default = "10.0.1.50"
}
module "core" {
source = "../../modules/core"
weed_binary = "/usr/bin/weed"
# disable the distributed tiers; run a single all-in-one process
master = { enabled = false }
volume = { enabled = false }
filer = { enabled = false }
all_in_one = {
enabled = true
nodes = { a0 = { address = var.private_ip, data_dir = "/data" } }
s3 = { enabled = true }
}
s3_identities = [{
name = "anonymous"
access_key = ""
secret_key = ""
actions = ["Read", "List"]
}]
}
resource "aws_network_interface" "aio" {
subnet_id = var.subnet_id
private_ips = [var.private_ip]
security_groups = [aws_security_group.aio.id]
tags = { Name = "seaweedfs-all-in-one" }
}
resource "aws_security_group" "aio" {
name_prefix = "seaweedfs-aio-"
vpc_id = var.vpc_id
lifecycle {
create_before_destroy = true
}
}
resource "aws_vpc_security_group_ingress_rule" "s3" {
security_group_id = aws_security_group.aio.id
cidr_ipv4 = "10.0.0.0/8"
ip_protocol = "tcp"
from_port = 8333
to_port = 8333
}
resource "aws_vpc_security_group_egress_rule" "all" {
security_group_id = aws_security_group.aio.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_instance" "aio" {
ami = var.ami_id
instance_type = "m5.large"
user_data = module.core.cloud_init_by_node["all-in-one-a0"]
network_interface {
network_interface_id = aws_network_interface.aio.id
device_index = 0
}
metadata_options {
http_tokens = "required"
}
tags = { Name = "seaweedfs-all-in-one", Role = "all-in-one" }
}
resource "aws_ebs_volume" "data" {
availability_zone = var.availability_zone
size = 200
type = "gp3"
encrypted = true
tags = { Name = "seaweedfs-all-in-one-data" }
lifecycle {
prevent_destroy = true
}
}
resource "aws_volume_attachment" "data" {
device_name = "/dev/xvdf"
volume_id = aws_ebs_volume.data.id
instance_id = aws_instance.aio.id
stop_instance_before_detaching = true
}
output "instance_id" {
value = aws_instance.aio.id
}
@@ -0,0 +1,117 @@
# SeaweedFS HA on AWS: 3-master quorum + 3 volume servers (one per AZ) + 2
# filers (leveldb2-replicated HA) + 1 standalone S3 gateway.
#
# tofu init && tofu validate
# tofu apply # requires AWS credentials, a VPC, subnets, and a weed AMI
#
# This is a scaffold: it provisions instances, protected EBS data disks, and the
# security group. Mounting the EBS disk at /data and secret-store cert delivery
# are documented follow-ups (see terraform/README.md).
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40"
}
}
}
provider "aws" {
region = var.region
}
variable "region" {
type = string
default = "us-east-1"
}
variable "vpc_id" {
type = string
}
variable "ami_id" {
description = "AMI with the weed binary at /usr/bin/weed."
type = string
}
# subnet per AZ
variable "subnet_a" { type = string }
variable "subnet_b" { type = string }
variable "subnet_c" { type = string }
# AZ defaults assume region us-east-1. When you change `region`, override az_a/az_b/az_c
# (and the matching subnets) with AZs that belong to that region, or the volume/filer
# EBS volumes will fail to create in a mismatched AZ.
variable "az_a" {
type = string
default = "us-east-1a"
}
variable "az_b" {
type = string
default = "us-east-1b"
}
variable "az_c" {
type = string
default = "us-east-1c"
}
variable "client_ingress_cidrs" {
type = list(string)
default = ["10.0.0.0/8"]
}
variable "ssh_ingress_cidrs" {
type = list(string)
default = []
}
module "seaweedfs" {
source = "../../modules/aws"
name = "seaweedfs"
vpc_id = var.vpc_id
ami_id = var.ami_id
# secure-by-default flagship: mTLS (certs + JWT generated by the security
# submodule, delivered via SSM and fetched at boot) plus monitoring.
enable_security = true
monitoring_enabled = true
masters = {
m0 = { subnet_id = var.subnet_a, private_ip = "10.0.1.10" }
m1 = { subnet_id = var.subnet_b, private_ip = "10.0.2.10" }
m2 = { subnet_id = var.subnet_c, private_ip = "10.0.3.10" }
}
volumes = {
v0 = { subnet_id = var.subnet_a, availability_zone = var.az_a, private_ip = "10.0.1.20", rack = var.az_a, data_center = var.region, data_volume_size_gb = 500 }
v1 = { subnet_id = var.subnet_b, availability_zone = var.az_b, private_ip = "10.0.2.20", rack = var.az_b, data_center = var.region, data_volume_size_gb = 500 }
v2 = { subnet_id = var.subnet_c, availability_zone = var.az_c, private_ip = "10.0.3.20", rack = var.az_c, data_center = var.region, data_volume_size_gb = 500 }
}
filers = {
f0 = { subnet_id = var.subnet_a, availability_zone = var.az_a, private_ip = "10.0.1.30" }
f1 = { subnet_id = var.subnet_b, availability_zone = var.az_b, private_ip = "10.0.2.30" }
}
s3_nodes = {
s0 = { subnet_id = var.subnet_a, private_ip = "10.0.1.40" }
}
client_ingress_cidrs = var.client_ingress_cidrs
ssh_ingress_cidrs = var.ssh_ingress_cidrs
}
output "master_peers" {
value = module.seaweedfs.master_peers
}
output "instance_ids" {
value = module.seaweedfs.instance_ids
}
output "security_group_id" {
value = module.seaweedfs.security_group_id
}
+205
View File
@@ -0,0 +1,205 @@
# ---- step 1: reserve stable addressing (ENIs with fixed private IPs) -------
# These exist BEFORE the core renders config, so the address map is known and
# the core <- wrapper dependency is one-way (no apply-time cycle).
resource "aws_network_interface" "master" {
for_each = var.masters
subnet_id = each.value.subnet_id
private_ips = [each.value.private_ip]
security_groups = [aws_security_group.cluster.id]
tags = merge(var.tags, { Name = "${var.name}-master-${each.key}", Role = "master" })
}
resource "aws_network_interface" "volume" {
for_each = var.volumes
subnet_id = each.value.subnet_id
private_ips = [each.value.private_ip]
security_groups = [aws_security_group.cluster.id]
tags = merge(var.tags, { Name = "${var.name}-volume-${each.key}", Role = "volume" })
}
resource "aws_network_interface" "filer" {
for_each = var.filers
subnet_id = each.value.subnet_id
private_ips = [each.value.private_ip]
security_groups = [aws_security_group.cluster.id]
tags = merge(var.tags, { Name = "${var.name}-filer-${each.key}", Role = "filer" })
}
resource "aws_network_interface" "s3" {
for_each = var.s3_nodes
subnet_id = each.value.subnet_id
private_ips = [each.value.private_ip]
security_groups = [aws_security_group.cluster.id]
tags = merge(var.tags, { Name = "${var.name}-s3-${each.key}", Role = "s3" })
}
# ---- step 2: render config with the cloud-agnostic core --------------------
module "core" {
source = "../core"
weed_binary = var.weed_binary
monitoring_enabled = var.monitoring_enabled
enable_security = var.enable_security
security = local.core_security
# Keep secrets out of cloud-init user_data when security is on; deliver them
# from SSM via the boot fetch script instead.
render_secret_files = !var.enable_security
boot_fetch_script = local.fetch_script
master = {
nodes = { for k, v in var.masters : k => { address = v.private_ip } }
}
volume = {
enabled = length(var.volumes) > 0
nodes = { for k, v in var.volumes : k => {
address = v.private_ip
rack = v.rack
data_center = v.data_center
data_dirs = [{ path = "/data", max_volumes = 0 }]
# auto-discover the single attached data disk and mount it at /data
disk_mounts = [{ mountpoint = "/data", fstype = "xfs" }]
} }
}
filer = {
enabled = length(var.filers) > 0
nodes = { for k, v in var.filers : k => {
address = v.private_ip
data_dir = "/data/filerldb2"
disk_mounts = [{ mountpoint = "/data", fstype = "xfs" }]
} }
s3 = var.embedded_s3
}
s3 = {
enabled = length(var.s3_nodes) > 0
nodes = { for k, v in var.s3_nodes : k => { address = v.private_ip } }
}
s3_identities = var.s3_identities
}
# ---- step 3: instances (keyed for_each; user_data = rendered cloud-init) ----
resource "aws_instance" "master" {
for_each = var.masters
ami = var.ami_id
instance_type = each.value.instance_type
key_name = var.key_name
user_data = module.core.cloud_init_by_node["master-${each.key}"]
iam_instance_profile = var.enable_security ? aws_iam_instance_profile.node[0].name : null
disable_api_termination = var.termination_protection
network_interface {
network_interface_id = aws_network_interface.master[each.key].id
device_index = 0
}
metadata_options {
http_tokens = "required" # IMDSv2
}
tags = merge(var.tags, { Name = "${var.name}-master-${each.key}", Role = "master" })
}
resource "aws_instance" "volume" {
for_each = var.volumes
ami = var.ami_id
instance_type = each.value.instance_type
key_name = var.key_name
user_data = module.core.cloud_init_by_node["volume-${each.key}"]
iam_instance_profile = var.enable_security ? aws_iam_instance_profile.node[0].name : null
disable_api_termination = var.termination_protection
network_interface {
network_interface_id = aws_network_interface.volume[each.key].id
device_index = 0
}
metadata_options {
http_tokens = "required"
}
tags = merge(var.tags, { Name = "${var.name}-volume-${each.key}", Role = "volume" })
}
resource "aws_instance" "filer" {
for_each = var.filers
ami = var.ami_id
instance_type = each.value.instance_type
key_name = var.key_name
user_data = module.core.cloud_init_by_node["filer-${each.key}"]
iam_instance_profile = var.enable_security ? aws_iam_instance_profile.node[0].name : null
disable_api_termination = var.termination_protection
network_interface {
network_interface_id = aws_network_interface.filer[each.key].id
device_index = 0
}
metadata_options {
http_tokens = "required"
}
tags = merge(var.tags, { Name = "${var.name}-filer-${each.key}", Role = "filer" })
}
resource "aws_instance" "s3" {
for_each = var.s3_nodes
ami = var.ami_id
instance_type = each.value.instance_type
key_name = var.key_name
user_data = module.core.cloud_init_by_node["s3-${each.key}"]
iam_instance_profile = var.enable_security ? aws_iam_instance_profile.node[0].name : null
network_interface {
network_interface_id = aws_network_interface.s3[each.key].id
device_index = 0
}
metadata_options {
http_tokens = "required"
}
tags = merge(var.tags, { Name = "${var.name}-s3-${each.key}", Role = "s3" })
}
# ---- step 4: protected data disks (decoupled; survive instance replacement) -
# The core's mount-disks.sh (wired via disk_mounts above) mkfs+mounts these at
# /data before the weed unit starts.
resource "aws_ebs_volume" "volume_data" {
for_each = var.volumes
availability_zone = each.value.availability_zone
size = each.value.data_volume_size_gb
type = each.value.data_volume_type
iops = each.value.data_volume_iops
encrypted = true
tags = merge(var.tags, { Name = "${var.name}-volume-${each.key}-data", Role = "volume" })
lifecycle {
prevent_destroy = true # break-glass: remove to allow destroy
}
}
resource "aws_volume_attachment" "volume_data" {
for_each = var.volumes
device_name = "/dev/xvdf"
volume_id = aws_ebs_volume.volume_data[each.key].id
instance_id = aws_instance.volume[each.key].id
stop_instance_before_detaching = true
}
resource "aws_ebs_volume" "filer_data" {
for_each = var.filers
availability_zone = each.value.availability_zone
size = each.value.data_volume_size_gb
type = "gp3"
encrypted = true
tags = merge(var.tags, { Name = "${var.name}-filer-${each.key}-data", Role = "filer" })
lifecycle {
prevent_destroy = true
}
}
resource "aws_volume_attachment" "filer_data" {
for_each = var.filers
device_name = "/dev/xvdf"
volume_id = aws_ebs_volume.filer_data[each.key].id
instance_id = aws_instance.filer[each.key].id
stop_instance_before_detaching = true
}
+34
View File
@@ -0,0 +1,34 @@
output "security_group_id" {
description = "Cluster security group id."
value = aws_security_group.cluster.id
}
output "master_private_ips" {
description = "Master private IPs keyed by node id."
value = { for k, v in var.masters : k => v.private_ip }
}
output "master_peers" {
description = "Master peer list passed to the weed processes."
value = module.core.master_peers
}
output "filer_private_ips" {
description = "Filer private IPs keyed by node id."
value = { for k, v in var.filers : k => v.private_ip }
}
output "s3_private_ips" {
description = "Standalone S3 gateway private IPs keyed by node id."
value = { for k, v in var.s3_nodes : k => v.private_ip }
}
output "instance_ids" {
description = "All instance ids keyed by role-node."
value = merge(
{ for k, v in aws_instance.master : "master-${k}" => v.id },
{ for k, v in aws_instance.volume : "volume-${k}" => v.id },
{ for k, v in aws_instance.filer : "filer-${k}" => v.id },
{ for k, v in aws_instance.s3 : "s3-${k}" => v.id },
)
}
+136
View File
@@ -0,0 +1,136 @@
# =============================================================================
# mTLS + JWT generation and secret delivery (active when enable_security=true).
#
# Generates the CA + per-component certs + JWT keys with the security submodule,
# stores them (plus the rendered security.toml / S3 config) in SSM Parameter
# Store as SecureString, and renders a boot fetch-secrets.sh that pulls them via
# the instance role. This keeps secrets OUT of cloud-init user_data / IMDS.
# Secrets still live in Terraform state (TF-generated); prefer Vault PKI for the
# CA in production.
# =============================================================================
locals {
all_private_ips = concat(
[for k, v in var.masters : v.private_ip],
[for k, v in var.volumes : v.private_ip],
[for k, v in var.filers : v.private_ip],
[for k, v in var.s3_nodes : v.private_ip],
)
ssm_prefix = "/seaweedfs/${var.name}"
deliver_s3_config = length(var.s3_nodes) > 0 || var.embedded_s3.enabled
# security.toml is identical across nodes; take it from the first master.
first_master = var.enable_security ? sort(keys(var.masters))[0] : ""
security_toml_path = "/etc/seaweedfs/security.toml"
s3_config_path = "/etc/seaweedfs/s3_config.json"
# Boot fetch entries: cert files + security.toml + (optional) S3 config.
fetch_entries = var.enable_security ? concat(
[for m in module.security[0].secret_manifest : { param = "${local.ssm_prefix}/certs/${m.key}", path = m.path, mode = m.mode }],
[{ param = "${local.ssm_prefix}/security.toml", path = local.security_toml_path, mode = "0600" }],
local.deliver_s3_config ? [{ param = "${local.ssm_prefix}/s3_config.json", path = local.s3_config_path, mode = "0600" }] : [],
) : []
fetch_script = var.enable_security ? templatefile("${path.module}/templates/fetch-secrets.sh.tftpl", {
run_as_user = "seaweedfs"
entries = local.fetch_entries
}) : ""
core_security = var.enable_security ? module.security[0].core_security : var.security
}
module "security" {
count = var.enable_security ? 1 : 0
source = "../security"
internal_domain = var.internal_domain
cert_dir = var.cert_dir
ip_sans = local.all_private_ips
}
# ---- SSM SecureString parameters -------------------------------------------
resource "aws_ssm_parameter" "cert" {
for_each = var.enable_security ? { for m in module.security[0].secret_manifest : m.key => m } : {}
name = "${local.ssm_prefix}/certs/${each.key}"
type = "SecureString"
value = module.security[0].secret_contents[each.key]
tags = var.tags
}
resource "aws_ssm_parameter" "security_toml" {
count = var.enable_security ? 1 : 0
name = "${local.ssm_prefix}/security.toml"
type = "SecureString"
value = module.core.secret_files_by_node["master-${local.first_master}"][local.security_toml_path]
tags = var.tags
}
resource "aws_ssm_parameter" "s3_config" {
count = var.enable_security && local.deliver_s3_config ? 1 : 0
name = "${local.ssm_prefix}/s3_config.json"
type = "SecureString"
# identical content across s3/filer nodes; render once via the security module's
# JSON is not available here, so read it back from any node that carries it.
value = local.s3_config_source
tags = var.tags
}
locals {
# Pull the rendered S3 identity JSON from whichever node carries it.
s3_config_source = !local.deliver_s3_config ? "" : (
length(var.s3_nodes) > 0 ?
module.core.secret_files_by_node["s3-${sort(keys(var.s3_nodes))[0]}"][local.s3_config_path] :
(length(var.filers) > 0 ? module.core.secret_files_by_node["filer-${sort(keys(var.filers))[0]}"][local.s3_config_path] : "")
)
}
# ---- IAM: instance role allowed to read the SSM params ----------------------
data "aws_iam_policy_document" "assume" {
count = var.enable_security ? 1 : 0
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "ssm_read" {
count = var.enable_security ? 1 : 0
statement {
sid = "ReadSeaweedfsParams"
actions = ["ssm:GetParameter", "ssm:GetParameters"]
resources = ["arn:aws:ssm:*:*:parameter${local.ssm_prefix}/*"]
}
statement {
sid = "DecryptSecureStrings"
actions = ["kms:Decrypt"]
resources = [var.kms_key_arn] # defaults to "*"; set a CMK ARN in production
condition {
test = "StringEquals"
variable = "kms:ViaService"
values = ["ssm.*.amazonaws.com"]
}
}
}
resource "aws_iam_role" "node" {
count = var.enable_security ? 1 : 0
name_prefix = "${var.name}-node-"
assume_role_policy = data.aws_iam_policy_document.assume[0].json
tags = var.tags
}
resource "aws_iam_role_policy" "ssm_read" {
count = var.enable_security ? 1 : 0
name = "ssm-read"
role = aws_iam_role.node[0].id
policy = data.aws_iam_policy_document.ssm_read[0].json
}
resource "aws_iam_instance_profile" "node" {
count = var.enable_security ? 1 : 0
name_prefix = "${var.name}-node-"
role = aws_iam_role.node[0].name
}
+62
View File
@@ -0,0 +1,62 @@
# Cluster security group + the SeaweedFS port matrix.
# Intra-cluster: all TCP allowed within the SG (master 9333/19333, volume
# 8080/18080, filer 8888/18888, admin 33646, ...). Client-facing: only S3 (8333)
# and filer (8888). Metrics ports (9327) are never opened to clients.
resource "aws_security_group" "cluster" {
name_prefix = "${var.name}-cluster-"
description = "SeaweedFS cluster"
vpc_id = var.vpc_id
tags = merge(var.tags, { Name = "${var.name}-cluster" })
lifecycle {
create_before_destroy = true
}
}
# All intra-cluster TCP via SG self-reference.
resource "aws_vpc_security_group_ingress_rule" "intra_cluster" {
security_group_id = aws_security_group.cluster.id
referenced_security_group_id = aws_security_group.cluster.id
ip_protocol = "tcp"
from_port = 0
to_port = 65535
description = "intra-cluster (http + gRPC for all roles)"
}
resource "aws_vpc_security_group_ingress_rule" "s3" {
for_each = toset(var.client_ingress_cidrs)
security_group_id = aws_security_group.cluster.id
cidr_ipv4 = each.value
ip_protocol = "tcp"
from_port = 8333
to_port = 8333
description = "S3 gateway"
}
resource "aws_vpc_security_group_ingress_rule" "filer" {
for_each = toset(var.client_ingress_cidrs)
security_group_id = aws_security_group.cluster.id
cidr_ipv4 = each.value
ip_protocol = "tcp"
from_port = 8888
to_port = 8888
description = "filer HTTP"
}
resource "aws_vpc_security_group_ingress_rule" "ssh" {
for_each = toset(var.ssh_ingress_cidrs)
security_group_id = aws_security_group.cluster.id
cidr_ipv4 = each.value
ip_protocol = "tcp"
from_port = 22
to_port = 22
description = "SSH"
}
resource "aws_vpc_security_group_egress_rule" "all" {
security_group_id = aws_security_group.cluster.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
description = "all egress"
}
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Rendered by terraform-aws-seaweedfs. Pulls certs + secret config from SSM
# Parameter Store (SecureString) at boot using the instance role. Region is
# auto-resolved by the AWS CLI from IMDS, so no region is baked in. Requires
# the AWS CLI and a working instance profile (see security.tf).
set -euo pipefail
run_as_user="${run_as_user}"
fetch() {
local param="$1" dest="$2" mode="$3"
mkdir -p "$(dirname "$dest")"
aws ssm get-parameter --with-decryption --name "$param" \
--query 'Parameter.Value' --output text > "$dest"
chmod "$mode" "$dest"
chown "$run_as_user:$run_as_user" "$dest" 2>/dev/null || true
}
%{ for e in entries ~}
fetch "${e.param}" "${e.path}" "${e.mode}"
%{ endfor ~}
+171
View File
@@ -0,0 +1,171 @@
# =============================================================================
# terraform-aws-seaweedfs - thin AWS wrapper around the cloud-agnostic core.
#
# Reserves stable per-node addressing (ENIs with fixed private IPs) FIRST, feeds
# that address map to the core to render cloud-init, then creates instances and
# protected EBS data disks. All tiers are keyed for_each (never count), so a
# middle node can be replaced without reindexing its peers/disks.
# =============================================================================
variable "name" {
description = "Name prefix for all resources."
type = string
default = "seaweedfs"
}
variable "vpc_id" {
description = "VPC to deploy into."
type = string
}
variable "ami_id" {
description = "AMI with the weed binary installed (e.g. baked with Packer)."
type = string
}
variable "weed_binary" {
description = "Path to weed on the AMI."
type = string
default = "/usr/bin/weed"
}
variable "key_name" {
description = "EC2 key pair for SSH (optional)."
type = string
default = null
}
variable "monitoring_enabled" {
description = "Bind metrics ports and pass them through to the core."
type = bool
default = false
}
variable "enable_security" {
description = "Enable mTLS (security.toml). Cert material must be supplied via the security variable / secret store."
type = bool
default = false
}
variable "security" {
description = "Passed through to the core security variable when enable_security=false. When enable_security=true this is ignored and certs/JWT are generated by the security submodule."
type = any
default = {}
}
variable "internal_domain" {
description = "Internal domain for generated component cert CNs / peer-auth wildcard (enable_security=true)."
type = string
default = "seaweedfs.internal"
}
variable "cert_dir" {
description = "On-host directory where certs are fetched/placed."
type = string
default = "/usr/local/share/ca-certificates"
}
variable "kms_key_arn" {
description = "KMS key ARN the instance role may use to decrypt SSM SecureStrings. Defaults to \"*\" (the SSM default key); set to a specific CMK ARN in production."
type = string
default = "*"
}
variable "termination_protection" {
description = "Set disable_api_termination on stateful instances (masters, volumes)."
type = bool
default = true
}
# ---- stateful tiers: keyed node maps ---------------------------------------
variable "masters" {
description = "Master nodes keyed by stable id (m0/m1/m2). Quorum size must be odd."
type = map(object({
subnet_id = string
private_ip = string
instance_type = optional(string, "t3.medium")
}))
validation {
condition = length(var.masters) % 2 == 1
error_message = "Master quorum must be an odd number of nodes (1, 3, 5)."
}
}
variable "volumes" {
description = "Volume nodes keyed by stable id. Each owns one protected EBS data disk."
type = map(object({
subnet_id = string
availability_zone = string
private_ip = string
instance_type = optional(string, "m5.large")
rack = optional(string)
data_center = optional(string)
data_volume_size_gb = optional(number, 100)
data_volume_type = optional(string, "gp3")
data_volume_iops = optional(number, null)
}))
default = {}
}
variable "filers" {
description = "Filer nodes keyed by stable id. Each owns a protected EBS disk for its leveldb2 store."
type = map(object({
subnet_id = string
availability_zone = string
private_ip = string
instance_type = optional(string, "t3.medium")
data_volume_size_gb = optional(number, 50)
}))
default = {}
}
variable "s3_nodes" {
description = "Standalone S3 gateway nodes keyed by stable id (stateless)."
type = map(object({
subnet_id = string
private_ip = string
instance_type = optional(string, "t3.medium")
}))
default = {}
}
variable "embedded_s3" {
description = "Embed the S3 gateway in the filer instead of standalone nodes."
type = object({
enabled = optional(bool, false)
port = optional(number, 8333)
domain_name = optional(string, "")
})
default = {}
}
variable "s3_identities" {
description = "Non-admin S3 identities for the gateway config (admin key goes via EnvironmentFile)."
type = list(object({
name = string
access_key = string
secret_key = string
actions = list(string)
}))
default = []
sensitive = true
}
# ---- networking ------------------------------------------------------------
variable "client_ingress_cidrs" {
description = "CIDRs allowed to reach the client-facing S3 (8333) and filer (8888) ports."
type = list(string)
default = []
}
variable "ssh_ingress_cidrs" {
description = "CIDRs allowed SSH (22)."
type = list(string)
default = []
}
variable "tags" {
description = "Extra tags applied to all resources."
type = map(string)
default = {}
}
+12
View File
@@ -0,0 +1,12 @@
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
# >= 5.40 for the modern aws_vpc_security_group_*_rule resources.
# Provider >= 6.0 is recommended for the EBS-attachment no-replace behavior;
# bump the floor here once you standardize on it.
version = ">= 5.40"
}
}
}
+373
View File
@@ -0,0 +1,373 @@
# =============================================================================
# SeaweedFS Terraform core - pure renderer (zero cloud resources).
#
# Computes, per node: the verified `weed` argv, the full systemd ExecStart,
# config files, environment, and rendered systemd unit + cloud-init.
# =============================================================================
locals {
# ---- shared security.toml (rendered when mTLS on OR any JWT key set) ----
jwt_any = anytrue([
var.security.jwt_signing_key != "",
var.security.jwt_signing_read_key != "",
var.security.jwt_filer_signing_key != "",
var.security.jwt_filer_signing_read_key != "",
])
render_security = var.enable_security || local.jwt_any
security_toml = local.render_security ? templatefile("${path.module}/templates/security.toml.tftpl", {
enable_security = var.enable_security
cert_dir = var.security.cert_dir
allowed_wildcard_domain = var.security.allowed_wildcard_domain
allowed_common_names = var.security.allowed_common_names
jwt_signing_key = var.security.jwt_signing_key
jwt_signing_read_key = var.security.jwt_signing_read_key
jwt_filer_signing_key = var.security.jwt_filer_signing_key
jwt_filer_signing_read_key = var.security.jwt_filer_signing_read_key
}) : ""
security_files = local.render_security ? { "/etc/seaweedfs/security.toml" = local.security_toml } : {}
# ---- S3 identity JSON (non-admin identities only) ----
# Empty access_key => anonymous identity (no credentials block), per SeaweedFS
# convention where the identity literally named "anonymous" grants anon access.
s3_config_json = jsonencode({
identities = [for id in var.s3_identities : {
name = id.name
credentials = id.access_key != "" ? [{ accessKey = id.access_key, secretKey = id.secret_key }] : []
actions = id.actions
}]
})
# ---- global flags (before the subcommand) ----
global_flags = compact([
format("-v=%d", var.log_level),
var.log_to_stderr ? "-logtostderr=true" : "",
])
# ---- master peers (sorted by key for determinism) ----
master_keys = sort(keys(var.master.nodes))
master_peer_list = [for k in local.master_keys :
format("%s:%d", var.master.nodes[k].address, coalesce(var.master.nodes[k].port, var.master.port))
]
master_peers = join(",", local.master_peer_list)
# ---- first filer (default s3 target + cluster env) ----
filer_keys = sort(keys(var.filer.nodes))
first_filer = length(local.filer_keys) > 0 ? format("%s:%d", var.filer.nodes[local.filer_keys[0]].address, coalesce(var.filer.nodes[local.filer_keys[0]].port, var.filer.port)) : ""
# ---- cluster discovery env (WEED_CLUSTER_*) ----
cluster_env = merge(
{ WEED_CLUSTER_DEFAULT = var.cluster_name },
local.master_peers != "" ? { "WEED_CLUSTER_${replace(upper(var.cluster_name), "/[^A-Z0-9_]/", "_")}_MASTER" = local.master_peers } : {},
local.first_filer != "" ? { "WEED_CLUSTER_${replace(upper(var.cluster_name), "/[^A-Z0-9_]/", "_")}_FILER" = local.first_filer } : {},
)
metrics_flags_master = var.monitoring_enabled ? compact([
format("-metricsPort=%d", var.master.metrics_port),
var.master.metrics_ip != "" ? format("-metricsIp=%s", var.master.metrics_ip) : "",
]) : []
metrics_flags_volume = var.monitoring_enabled ? compact([
format("-metricsPort=%d", var.volume.metrics_port),
var.volume.metrics_ip != "" ? format("-metricsIp=%s", var.volume.metrics_ip) : "",
]) : []
metrics_flags_filer = var.monitoring_enabled ? compact([
format("-metricsPort=%d", var.filer.metrics_port),
var.filer.metrics_ip != "" ? format("-metricsIp=%s", var.filer.metrics_ip) : "",
]) : []
metrics_flags_s3 = var.monitoring_enabled ? [format("-metricsPort=%d", var.s3.metrics_port)] : []
# ===========================================================================
# MASTER nodes
# ===========================================================================
master_nodes = var.master.enabled ? {
for k in local.master_keys : "master-${k}" => {
role = "master"
name = "master-${k}"
address = var.master.nodes[k].address
disk_mounts = var.master.nodes[k].disk_mounts
data_dir = coalesce(var.master.nodes[k].data_dir, var.master.data_dir)
data_dirs = [coalesce(var.master.nodes[k].data_dir, var.master.data_dir)]
ports = {
http = coalesce(var.master.nodes[k].port, var.master.port)
metrics = var.master.metrics_port
}
env = local.cluster_env
config_files = local.security_files
argv = concat(
local.global_flags,
["master"],
[
format("-port=%d", coalesce(var.master.nodes[k].port, var.master.port)),
format("-mdir=%s", coalesce(var.master.nodes[k].data_dir, var.master.data_dir)),
format("-ip=%s", var.master.nodes[k].address),
format("-ip.bind=%s", var.master.ip_bind),
format("-peers=%s", local.master_peers),
format("-defaultReplication=%s", var.master.default_replication),
format("-volumeSizeLimitMB=%d", var.master.volume_size_limit_mb),
format("-electionTimeout=%s", var.master.election_timeout),
format("-heartbeatInterval=%s", var.master.heartbeat_interval),
],
var.master.nodes[k].grpc_port != null ? [format("-port.grpc=%d", var.master.nodes[k].grpc_port)] : (
var.master.grpc_port != null ? [format("-port.grpc=%d", var.master.grpc_port)] : []),
var.master.volume_preallocate ? ["-volumePreallocate"] : [],
var.master.garbage_threshold != null ? [format("-garbageThreshold=%s", tostring(var.master.garbage_threshold))] : [],
var.master.raft_hashicorp ? ["-raftHashicorp"] : [],
var.master.resume_state ? ["-resumeState"] : [],
var.master.disable_http ? ["-disableHttp"] : [],
var.master.white_list != "" ? [format("-whiteList=%s", var.master.white_list)] : [],
local.metrics_flags_master,
var.master.extra_args,
)
}
} : {}
# ===========================================================================
# VOLUME nodes
# ===========================================================================
volume_keys = sort(keys(var.volume.nodes))
volume_nodes = var.volume.enabled ? {
for k in local.volume_keys : "volume-${k}" => {
role = "volume"
name = "volume-${k}"
address = var.volume.nodes[k].address
disk_mounts = var.volume.nodes[k].disk_mounts
data_dirs = [for d in(var.volume.nodes[k].data_dirs != null ? var.volume.nodes[k].data_dirs : var.volume.data_dirs) : d.path]
data_dir = (var.volume.nodes[k].data_dirs != null ? var.volume.nodes[k].data_dirs : var.volume.data_dirs)[0].path
ports = {
http = coalesce(var.volume.nodes[k].port, var.volume.port)
metrics = var.volume.metrics_port
}
env = local.cluster_env
config_files = local.security_files
argv = concat(
local.global_flags,
["volume"],
[
format("-port=%d", coalesce(var.volume.nodes[k].port, var.volume.port)),
format("-dir=%s", join(",", [for d in(var.volume.nodes[k].data_dirs != null ? var.volume.nodes[k].data_dirs : var.volume.data_dirs) : d.path])),
format("-max=%s", join(",", [for d in(var.volume.nodes[k].data_dirs != null ? var.volume.nodes[k].data_dirs : var.volume.data_dirs) : tostring(d.max_volumes)])),
format("-ip=%s", var.volume.nodes[k].address),
format("-ip.bind=%s", var.volume.ip_bind),
format("-mserver=%s", local.master_peers),
format("-readMode=%s", var.volume.read_mode),
format("-compactionMBps=%d", var.volume.compaction_mbps),
format("-minFreeSpacePercent=%s", var.volume.min_free_space_percent),
format("-index=%s", var.volume.index),
],
var.volume.nodes[k].grpc_port != null ? [format("-port.grpc=%d", var.volume.nodes[k].grpc_port)] : [],
(var.volume.nodes[k].idx_dir != null && var.volume.nodes[k].idx_dir != "") ? [format("-dir.idx=%s", var.volume.nodes[k].idx_dir)] : (var.volume.idx_dir != "" ? [format("-dir.idx=%s", var.volume.idx_dir)] : []),
var.volume.nodes[k].data_center != null ? [format("-dataCenter=%s", var.volume.nodes[k].data_center)] : [],
var.volume.nodes[k].rack != null ? [format("-rack=%s", var.volume.nodes[k].rack)] : [],
var.volume.nodes[k].public_url != null ? [format("-publicUrl=%s", var.volume.nodes[k].public_url)] : [],
var.volume.file_size_limit_mb != null ? [format("-fileSizeLimitMB=%d", var.volume.file_size_limit_mb)] : [],
var.volume.images_fix_orientation ? ["-images.fix.orientation"] : [],
var.volume.white_list != "" ? [format("-whiteList=%s", var.volume.white_list)] : [],
local.metrics_flags_volume,
var.volume.extra_args,
)
}
} : {}
# ===========================================================================
# FILER nodes (+ optional embedded S3)
# ===========================================================================
filer_s3_files = var.filer.s3.enabled ? { (var.filer.s3.config_path) = local.s3_config_json } : {}
filer_nodes = var.filer.enabled ? {
for k in local.filer_keys : "filer-${k}" => {
role = "filer"
name = "filer-${k}"
address = var.filer.nodes[k].address
disk_mounts = var.filer.nodes[k].disk_mounts
data_dir = coalesce(var.filer.nodes[k].data_dir, var.filer.data_dir)
data_dirs = [coalesce(var.filer.nodes[k].data_dir, var.filer.data_dir)]
ports = {
http = coalesce(var.filer.nodes[k].port, var.filer.port)
metrics = var.filer.metrics_port
}
env = merge(local.cluster_env, var.filer.extra_env)
config_files = merge(local.security_files, local.filer_s3_files)
argv = concat(
local.global_flags,
["filer"],
[
format("-port=%d", coalesce(var.filer.nodes[k].port, var.filer.port)),
format("-ip=%s", var.filer.nodes[k].address),
format("-ip.bind=%s", var.filer.ip_bind),
format("-master=%s", local.master_peers),
format("-defaultReplicaPlacement=%s", var.filer.default_replica_placement),
format("-dirListLimit=%d", var.filer.dir_list_limit),
format("-defaultStoreDir=%s", coalesce(var.filer.nodes[k].data_dir, var.filer.data_dir)),
],
var.filer.nodes[k].grpc_port != null ? [format("-port.grpc=%d", var.filer.nodes[k].grpc_port)] : [],
var.filer.max_mb != null ? [format("-maxMB=%d", var.filer.max_mb)] : [],
var.filer.disable_dir_listing ? ["-disableDirListing"] : [],
var.filer.disable_http ? ["-disableHttp"] : [],
var.filer.encrypt_volume_data ? ["-encryptVolumeData"] : [],
var.filer.rack != "" ? [format("-rack=%s", var.filer.rack)] : [],
var.filer.data_center != "" ? [format("-dataCenter=%s", var.filer.data_center)] : [],
var.filer.filer_group != "" ? [format("-filerGroup=%s", var.filer.filer_group)] : [],
var.filer.s3.enabled ? [
"-s3",
format("-s3.port=%d", var.filer.s3.port),
format("-s3.config=%s", var.filer.s3.config_path),
] : [],
(var.filer.s3.enabled && var.filer.s3.https_port > 0) ? [format("-s3.port.https=%d", var.filer.s3.https_port)] : [],
(var.filer.s3.enabled && var.filer.s3.domain_name != "") ? [format("-s3.domainName=%s", var.filer.s3.domain_name)] : [],
local.metrics_flags_filer,
var.filer.extra_args,
)
}
} : {}
# ===========================================================================
# S3 standalone nodes
# ===========================================================================
s3_keys = sort(keys(var.s3.nodes))
s3_target = var.s3.filer_address != "" ? var.s3.filer_address : local.first_filer
s3_std_files = merge(local.security_files, { (var.s3.config_path) = local.s3_config_json })
s3_nodes = var.s3.enabled ? {
for k in local.s3_keys : "s3-${k}" => {
role = "s3"
name = "s3-${k}"
address = var.s3.nodes[k].address
disk_mounts = []
data_dir = ""
data_dirs = []
ports = {
http = coalesce(var.s3.nodes[k].port, var.s3.port)
metrics = var.s3.metrics_port
}
env = local.cluster_env
config_files = local.s3_std_files
argv = concat(
local.global_flags,
["s3"],
[
format("-port=%d", coalesce(var.s3.nodes[k].port, var.s3.port)),
format("-ip.bind=%s", var.s3.ip_bind),
format("-filer=%s", local.s3_target),
format("-config=%s", var.s3.config_path),
],
var.s3.https_port > 0 ? [format("-port.https=%d", var.s3.https_port)] : [],
var.s3.iceberg_port != null ? [format("-port.iceberg=%d", var.s3.iceberg_port)] : [],
var.s3.domain_name != "" ? [format("-domainName=%s", var.s3.domain_name)] : [],
var.s3.audit_log_config_path != "" ? [format("-auditLogConfig=%s", var.s3.audit_log_config_path)] : [],
var.s3.cert_file != "" ? [format("-cert.file=%s", var.s3.cert_file)] : [],
var.s3.key_file != "" ? [format("-key.file=%s", var.s3.key_file)] : [],
var.s3.cacert_file != "" ? [format("-cacert.file=%s", var.s3.cacert_file)] : [],
var.s3.verify_client_cert ? ["-tlsVerifyClientCert"] : [],
local.metrics_flags_s3,
var.s3.extra_args,
)
}
} : {}
# ===========================================================================
# ALL-IN-ONE nodes (weed server)
# ===========================================================================
aio_keys = sort(keys(var.all_in_one.nodes))
aio_s3_files = var.all_in_one.s3.enabled ? { (var.all_in_one.s3.config_path) = local.s3_config_json } : {}
aio_nodes = var.all_in_one.enabled ? {
for k in local.aio_keys : "all-in-one-${k}" => {
role = "server"
name = "all-in-one-${k}"
address = var.all_in_one.nodes[k].address
disk_mounts = var.all_in_one.nodes[k].disk_mounts
data_dir = coalesce(var.all_in_one.nodes[k].data_dir, var.all_in_one.data_dir)
data_dirs = [coalesce(var.all_in_one.nodes[k].data_dir, var.all_in_one.data_dir)]
ports = {
http = var.all_in_one.master_port
metrics = var.all_in_one.metrics_port
}
env = local.cluster_env
config_files = merge(local.security_files, local.aio_s3_files)
argv = concat(
local.global_flags,
["server"],
[
format("-dir=%s", coalesce(var.all_in_one.nodes[k].data_dir, var.all_in_one.data_dir)),
format("-ip=%s", var.all_in_one.nodes[k].address),
format("-ip.bind=%s", var.all_in_one.ip_bind),
"-master",
format("-master.port=%d", var.all_in_one.master_port),
format("-master.peers=%s:%d", var.all_in_one.nodes[k].address, var.all_in_one.master_port),
format("-master.defaultReplication=%s", var.all_in_one.default_replication),
format("-master.volumeSizeLimitMB=%d", var.all_in_one.volume_size_limit_mb),
"-volume",
format("-volume.port=%d", var.all_in_one.volume_port),
"-filer",
format("-filer.port=%d", var.all_in_one.filer_port),
format("-idleTimeout=%d", var.all_in_one.idle_timeout),
],
var.all_in_one.disable_http ? [format("-disableHttp=%t", var.all_in_one.disable_http)] : [],
var.all_in_one.s3.enabled ? [
"-s3",
format("-s3.port=%d", var.all_in_one.s3.port),
format("-s3.config=%s", var.all_in_one.s3.config_path),
] : [],
(var.all_in_one.s3.enabled && var.all_in_one.s3.domain_name != "") ? [format("-s3.domainName=%s", var.all_in_one.s3.domain_name)] : [],
var.monitoring_enabled ? [format("-metricsPort=%d", var.all_in_one.metrics_port)] : [],
var.all_in_one.extra_args,
)
}
} : {}
# ===========================================================================
# Merge + render systemd unit and cloud-init per node
# ===========================================================================
nodes_base = merge(local.master_nodes, local.volume_nodes, local.filer_nodes, local.s3_nodes, local.aio_nodes)
# Disk-mount script per node (empty unless the node declares disk_mounts).
mount_scripts = {
for name, n in local.nodes_base : name => length(n.disk_mounts) > 0 ? templatefile("${path.module}/templates/mount-disks.sh.tftpl", {
run_as_user = var.hardening.run_as_user
mounts = n.disk_mounts
}) : ""
}
nodes = {
for name, n in local.nodes_base : name => merge(n, {
exec_start = "${var.weed_binary} ${join(" ", n.argv)}"
# secret_files: the secret-bearing config a wrapper should deliver from a
# secret store when render_secret_files=false (else they go in cloud-init).
secret_files = n.config_files
mount_script = local.mount_scripts[name]
file_modes = { for p in keys(n.config_files) : p => "0600" }
systemd_unit = templatefile("${path.module}/templates/weed.service.tftpl", {
role = n.role
name = n.name
run_as_user = var.hardening.run_as_user
exec_start = "${var.weed_binary} ${join(" ", n.argv)}"
no_new_privileges = var.hardening.no_new_privileges
protect_system = var.hardening.protect_system
cap_drop_all = var.hardening.cap_drop_all
read_write_paths = n.data_dirs
requires_mounts_for = n.data_dirs
env_file = var.env_file
environment = n.env
})
cloud_init = templatefile("${path.module}/templates/cloud-init.yaml.tftpl", {
role = n.role
name = n.name
run_as_user = var.hardening.run_as_user
config_files = var.render_secret_files ? n.config_files : {}
file_modes = { for p in keys(n.config_files) : p => "0600" }
pre_runcmd = [for p in n.data_dirs : "install -d -o ${var.hardening.run_as_user} -g ${var.hardening.run_as_user} ${p}"]
mount_script = local.mount_scripts[name]
fetch_script = var.boot_fetch_script
systemd_unit = templatefile("${path.module}/templates/weed.service.tftpl", {
role = n.role
name = n.name
run_as_user = var.hardening.run_as_user
exec_start = "${var.weed_binary} ${join(" ", n.argv)}"
no_new_privileges = var.hardening.no_new_privileges
protect_system = var.hardening.protect_system
cap_drop_all = var.hardening.cap_drop_all
read_write_paths = n.data_dirs
requires_mounts_for = n.data_dirs
env_file = var.env_file
environment = n.env
})
})
})
}
}
+32
View File
@@ -0,0 +1,32 @@
output "nodes" {
description = "Per-node rendered artifacts keyed by node name. Each value: role, name, address, ports, data_dir(s), argv (list, after the weed binary), exec_start, env, config_files, systemd_unit, cloud_init."
value = local.nodes
sensitive = true # config_files/env may carry JWT keys or S3 identities
}
output "master_peers" {
description = "Comma-separated master peer list (address:port), as passed to -peers / -mserver / -master."
value = local.master_peers
}
output "first_filer" {
description = "address:port of the lexically-first filer node (default S3 target)."
value = local.first_filer
}
output "node_names" {
description = "List of rendered node names (non-sensitive)."
value = sort(keys(local.nodes))
}
output "cloud_init_by_node" {
description = "Map of node name -> rendered cloud-init user_data (for the per-cloud wrappers)."
value = { for name, n in local.nodes : name => n.cloud_init }
sensitive = true
}
output "secret_files_by_node" {
description = "Map of node name -> {path => content} of secret-bearing files (security.toml, S3 identity JSON). Wrappers push these to a secret store and fetch them at boot when render_secret_files=false."
value = { for name, n in local.nodes : name => n.secret_files }
sensitive = true
}
@@ -0,0 +1,44 @@
#cloud-config
# Rendered by the SeaweedFS Terraform core module for node ${name} (role ${role}).
# When render_secret_files=false, secret-bearing files are NOT here; they are
# fetched at boot by fetch-secrets.sh from the cloud secret store.
users:
- name: ${run_as_user}
system: true
shell: /usr/sbin/nologin
write_files:
%{ for path, content in config_files ~}
- path: ${path}
owner: "${run_as_user}:${run_as_user}"
permissions: '${lookup(file_modes, path, "0644")}'
content: |
${indent(6, content)}
%{ endfor ~}
%{ if mount_script != "" ~}
- path: /opt/seaweedfs/mount-disks.sh
permissions: '0755'
content: |
${indent(6, mount_script)}
%{ endif ~}
%{ if fetch_script != "" ~}
- path: /opt/seaweedfs/fetch-secrets.sh
permissions: '0755'
content: |
${indent(6, fetch_script)}
%{ endif ~}
- path: /etc/systemd/system/weed-${role}.service
permissions: '0644'
content: |
${indent(6, systemd_unit)}
runcmd:
%{ if mount_script != "" ~}
- /opt/seaweedfs/mount-disks.sh
%{ endif ~}
%{ if fetch_script != "" ~}
- /opt/seaweedfs/fetch-secrets.sh
%{ endif ~}
%{ for cmd in pre_runcmd ~}
- ${cmd}
%{ endfor ~}
- systemctl daemon-reload
- systemctl enable --now weed-${role}.service
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Rendered by the SeaweedFS Terraform core module. Formats (only if blank) and
# mounts each data disk, persisting by UUID to /etc/fstab. Safe to re-run.
set -euo pipefail
run_as_user="${run_as_user}"
# Base block device backing "/" so auto-discovery never touches the root disk.
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
root_base="$(lsblk -no pkname "$root_src" 2>/dev/null || true)"
[ -n "$root_base" ] && root_base="/dev/$root_base"
is_mounted() { findmnt -rno TARGET "$1" >/dev/null 2>&1; }
# Pick the first unmounted, non-root whole disk (single-data-disk node).
discover_dev() {
local d base
for d in /dev/nvme*n1 /dev/xvd[b-z] /dev/sd[b-z] /dev/vd[b-z]; do
[ -b "$d" ] || continue
[ "$d" = "$root_src" ] && continue
[ "$d" = "$root_base" ] && continue
base="/dev/$(lsblk -no pkname "$d" 2>/dev/null || true)"
[ "$base" = "$root_base" ] && continue
is_mounted "$d" && continue
echo "$d"; return 0
done
return 1
}
# Wait up to ~120s for one of the explicit candidate devices to appear.
wait_dev() {
local i d
for i in $(seq 1 60); do
for d in "$@"; do [ -b "$d" ] && { echo "$d"; return 0; }; done
sleep 2
done
return 1
}
mount_one() {
local mountpoint="$1" fstype="$2"; shift 2
local dev=""
if [ "$#" -gt 0 ]; then
dev="$(wait_dev "$@" || true)"
fi
if [ -z "$dev" ]; then
dev="$(discover_dev || true)"
fi
if [ -z "$dev" ]; then
echo "mount-disks: no device found for $mountpoint" >&2
return 1
fi
if ! blkid "$dev" >/dev/null 2>&1; then
echo "mount-disks: formatting $dev as $fstype"
"mkfs.$fstype" -q "$dev"
fi
mkdir -p "$mountpoint"
local uuid; uuid="$(blkid -s UUID -o value "$dev")"
if [ -z "$uuid" ]; then
echo "mount-disks: failed to read a filesystem UUID for $dev" >&2
return 1
fi
if ! grep -q "$uuid" /etc/fstab; then
printf 'UUID=%s %s %s defaults,nofail 0 2\n' "$uuid" "$mountpoint" "$fstype" >> /etc/fstab
fi
is_mounted "$mountpoint" || mount "$mountpoint"
chown -R "$run_as_user:$run_as_user" "$mountpoint"
echo "mount-disks: $dev mounted at $mountpoint"
}
%{ for m in mounts ~}
mount_one "${m.mountpoint}" "${m.fstype}"${length(m.devices) > 0 ? " ${join(" ", m.devices)}" : ""}
%{ endfor ~}
@@ -0,0 +1,53 @@
# SeaweedFS security.toml rendered by the Terraform core module.
# Rendered when enable_security OR any non-default JWT signing option is set.
# Presence of this file does NOT imply mTLS: the [grpc] TLS block is emitted
# only under enable_security; a JWT-only config carries [jwt.*] and no TLS.
%{ if enable_security ~}
[grpc]
ca = "${cert_dir}/ca/tls.crt"
# Peer-identity authorization: without an allow-list, mTLS collapses to
# "trusted the CA" with no authZ. Set allowed_wildcard_domain / commonNames.
%{ if allowed_wildcard_domain != "" ~}
allowed_wildcard_domain = "${allowed_wildcard_domain}"
%{ endif ~}
[grpc.master]
cert = "${cert_dir}/master/tls.crt"
key = "${cert_dir}/master/tls.key"
%{ if allowed_common_names != "" ~}
allowed_commonNames = "${allowed_common_names}"
%{ endif ~}
[grpc.volume]
cert = "${cert_dir}/volume/tls.crt"
key = "${cert_dir}/volume/tls.key"
[grpc.filer]
cert = "${cert_dir}/filer/tls.crt"
key = "${cert_dir}/filer/tls.key"
[grpc.client]
cert = "${cert_dir}/client/tls.crt"
key = "${cert_dir}/client/tls.key"
%{ endif ~}
%{ if jwt_signing_key != "" ~}
[jwt.signing]
key = "${jwt_signing_key}"
%{ endif ~}
%{ if jwt_signing_read_key != "" ~}
[jwt.signing.read]
key = "${jwt_signing_read_key}"
%{ endif ~}
%{ if jwt_filer_signing_key != "" ~}
[jwt.filer_signing]
key = "${jwt_filer_signing_key}"
%{ endif ~}
%{ if jwt_filer_signing_read_key != "" ~}
[jwt.filer_signing.read]
key = "${jwt_filer_signing_read_key}"
%{ endif ~}
@@ -0,0 +1,39 @@
[Unit]
Description=SeaweedFS ${role} (${name})
Documentation=https://github.com/seaweedfs/seaweedfs/wiki
After=network-online.target
Wants=network-online.target
%{ for path in requires_mounts_for ~}
RequiresMountsFor=${path}
%{ endfor ~}
[Service]
Type=simple
User=${run_as_user}
Group=${run_as_user}
ExecStart=${exec_start}
Restart=always
RestartSec=5
LimitNOFILE=1048576
TimeoutStopSec=60
# Hardening (OpenShift SCC analogs; relax via the hardening variable)
NoNewPrivileges=${no_new_privileges}
%{ if protect_system ~}
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
%{ for path in read_write_paths ~}
ReadWritePaths=${path}
%{ endfor ~}
%{ endif ~}
%{ if cap_drop_all ~}
CapabilityBoundingSet=
AmbientCapabilities=
%{ endif ~}
EnvironmentFile=-${env_file}
%{ for k, v in environment ~}
Environment="${k}=${v}"
%{ endfor ~}
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,234 @@
# Plan-level tests for the core renderer. No cloud credentials required.
# cd terraform/modules/core && tofu test
variables {
weed_binary = "/usr/bin/weed"
}
# ---------------------------------------------------------------------------
run "master_peers_and_count" {
command = plan
variables {
master = {
nodes = {
m0 = { address = "10.0.0.10" }
m1 = { address = "10.0.0.11" }
m2 = { address = "10.0.0.12" }
}
}
volume = { enabled = false }
filer = { enabled = false }
}
assert {
condition = output.master_peers == "10.0.0.10:9333,10.0.0.11:9333,10.0.0.12:9333"
error_message = "master -peers list is wrong"
}
assert {
condition = length(output.node_names) == 3
error_message = "expected exactly 3 master nodes"
}
}
# ---------------------------------------------------------------------------
run "volume_uses_mserver_not_master" {
command = plan
variables {
master = { nodes = { m0 = { address = "10.0.0.10" } } }
volume = { nodes = { v0 = { address = "10.0.0.20", rack = "rack-a", data_center = "dc1" } } }
filer = { enabled = false }
}
assert {
condition = contains(output.nodes["volume-v0"].argv, "-mserver=10.0.0.10:9333")
error_message = "volume must pass the master list via -mserver (verified flag), not -master"
}
assert {
condition = !contains(output.nodes["volume-v0"].argv, "-master=10.0.0.10:9333")
error_message = "volume should not use -master for the master list"
}
assert {
condition = contains(output.nodes["volume-v0"].argv, "-rack=rack-a") && contains(output.nodes["volume-v0"].argv, "-dataCenter=dc1")
error_message = "per-node rack/dataCenter must render"
}
}
# ---------------------------------------------------------------------------
run "filer_uses_master_and_store_dir" {
command = plan
variables {
master = { nodes = { m0 = { address = "10.0.0.10" } } }
volume = { enabled = false }
filer = { nodes = { f0 = { address = "10.0.0.30", data_dir = "/srv/filer" } } }
}
assert {
condition = contains(output.nodes["filer-f0"].argv, "-master=10.0.0.10:9333")
error_message = "filer must use -master for the master list"
}
assert {
condition = contains(output.nodes["filer-f0"].argv, "-defaultStoreDir=/srv/filer")
error_message = "filer leveldb2 store dir must render via -defaultStoreDir"
}
}
# ---------------------------------------------------------------------------
run "metrics_gated_off_by_default" {
command = plan
variables {
master = { nodes = { m0 = { address = "10.0.0.10" } } }
volume = { enabled = false }
filer = { enabled = false }
}
assert {
condition = length([for a in output.nodes["master-m0"].argv : a if startswith(a, "-metricsPort")]) == 0
error_message = "metrics port must NOT be bound when monitoring is disabled"
}
}
run "metrics_on_when_enabled" {
command = plan
variables {
monitoring_enabled = true
master = { nodes = { m0 = { address = "10.0.0.10" } }, metrics_port = 9327 }
volume = { enabled = false }
filer = { enabled = false }
}
assert {
condition = contains(output.nodes["master-m0"].argv, "-metricsPort=9327")
error_message = "metrics port must bind when monitoring is enabled"
}
}
# ---------------------------------------------------------------------------
run "security_toml_off_by_default" {
command = plan
variables {
master = { nodes = { m0 = { address = "10.0.0.10" } } }
volume = { enabled = false }
filer = { enabled = false }
}
assert {
condition = length(keys(output.nodes["master-m0"].config_files)) == 0
error_message = "security.toml must not render when security is off and no JWT keys set"
}
}
run "security_toml_on_with_mtls" {
command = plan
variables {
enable_security = true
security = {
allowed_wildcard_domain = ".seaweedfs.internal"
jwt_signing_key = "test-signing-key-not-a-real-secret-0123456789"
}
master = { nodes = { m0 = { address = "10.0.0.10" } } }
volume = { enabled = false }
filer = { enabled = false }
}
assert {
condition = contains(keys(output.nodes["master-m0"].config_files), "/etc/seaweedfs/security.toml")
error_message = "security.toml must render when enable_security is true"
}
}
# ---------------------------------------------------------------------------
run "all_in_one_renders_server" {
command = plan
variables {
master = { enabled = false }
volume = { enabled = false }
filer = { enabled = false }
all_in_one = {
enabled = true
nodes = { a0 = { address = "10.0.0.40" } }
s3 = { enabled = true }
}
}
assert {
condition = contains(output.nodes["all-in-one-a0"].argv, "server")
error_message = "all-in-one must invoke the weed server subcommand"
}
assert {
condition = contains(output.nodes["all-in-one-a0"].argv, "-s3") && contains(output.nodes["all-in-one-a0"].argv, "-s3.port=8333")
error_message = "all-in-one S3 must render when enabled"
}
}
# ---------------------------------------------------------------------------
run "security_toml_mtls_only_no_jwt" {
command = plan
variables {
enable_security = true
master = { nodes = { m0 = { address = "10.0.0.10" } } }
volume = { enabled = false }
filer = { enabled = false }
}
assert {
condition = contains(keys(output.nodes["master-m0"].config_files), "/etc/seaweedfs/security.toml")
error_message = "security.toml must render for mTLS even without JWT keys"
}
assert {
condition = !strcontains(output.nodes["master-m0"].config_files["/etc/seaweedfs/security.toml"], "[jwt.signing]")
error_message = "the jwt.signing block must be omitted when no signing key is set"
}
}
run "mount_fetch_and_secret_delivery" {
command = plan
variables {
enable_security = true
render_secret_files = false
boot_fetch_script = "#!/usr/bin/env bash\necho fetch\n"
security = {
jwt_signing_key = "test-signing-key-not-a-real-secret-0123456789"
}
master = { nodes = { m0 = { address = "10.0.0.10", disk_mounts = [{ mountpoint = "/data", fstype = "xfs" }] } } }
volume = { enabled = false }
filer = { enabled = false }
}
assert {
condition = strcontains(output.nodes["master-m0"].cloud_init, "/opt/seaweedfs/mount-disks.sh")
error_message = "cloud-init must write+run the mount script when disk_mounts is set"
}
assert {
condition = strcontains(output.nodes["master-m0"].cloud_init, "/opt/seaweedfs/fetch-secrets.sh")
error_message = "cloud-init must write+run the fetch script when boot_fetch_script is set"
}
assert {
condition = !strcontains(output.nodes["master-m0"].cloud_init, "[jwt.signing]")
error_message = "security.toml must NOT be inlined in cloud-init when render_secret_files=false"
}
assert {
condition = contains(keys(output.nodes["master-m0"].secret_files), "/etc/seaweedfs/security.toml")
error_message = "secret_files must expose security.toml for secret-store delivery"
}
}
run "mount_script_absent_without_disks" {
command = plan
variables {
master = { nodes = { m0 = { address = "10.0.0.10" } } }
volume = { enabled = false }
filer = { enabled = false }
}
assert {
condition = !strcontains(output.nodes["master-m0"].cloud_init, "mount-disks.sh")
error_message = "no mount script should render when the node declares no disk_mounts"
}
}
run "s3_standalone_targets_filer" {
command = plan
variables {
master = { nodes = { m0 = { address = "10.0.0.10" } } }
volume = { enabled = false }
filer = { nodes = { f0 = { address = "10.0.0.30" } } }
s3 = { enabled = true, nodes = { s0 = { address = "10.0.0.50" } } }
}
assert {
condition = contains(output.nodes["s3-s0"].argv, "-filer=10.0.0.30:8888")
error_message = "standalone S3 must point -filer at the first filer node"
}
assert {
condition = contains(output.nodes["s3-s0"].argv, "-config=/etc/seaweedfs/s3_config.json")
error_message = "standalone S3 must reference the rendered config path"
}
}
+318
View File
@@ -0,0 +1,318 @@
# =============================================================================
# SeaweedFS Terraform core module - input variables
#
# This module is a PURE RENDERER. It creates zero cloud resources. It takes a
# per-node address map plus configuration and emits, per node, the rendered
# `weed` argv, systemd unit, cloud-init, and config files. Both the per-cloud
# infra wrappers (consume cloud_init/systemd_unit) and the local test harness
# (consume argv/config_files) read its `nodes` output.
#
# Flag names below are verified against the real `weed` binary, not the chart.
# Notably: volume uses -mserver for the master list; gRPC is -port.grpc
# (auto = http+10000); minFreeSpacePercent is a string.
# =============================================================================
variable "weed_binary" {
description = "Path to the weed executable on the target host."
type = string
default = "/usr/bin/weed"
}
variable "cluster_name" {
description = "Logical cluster name (WEED_CLUSTER_DEFAULT)."
type = string
default = "sw"
}
variable "log_level" {
description = "Global glog verbosity (-v)."
type = number
default = 1
}
variable "log_to_stderr" {
description = "Log to stderr (journald) instead of -logdir. Recommended on systemd."
type = bool
default = true
}
variable "monitoring_enabled" {
description = "Gate the -metricsPort/-metrics.address flags. When false the metrics port is not bound, matching the chart."
type = bool
default = false
}
variable "enable_security" {
description = "Emit the security.toml [grpc] mTLS block and reference per-component certs. Independent of JWT."
type = bool
default = false
}
# ----------------------------------------------------------------------------
# Security / secrets (the core only references paths + renders security.toml;
# cert/secret material is generated by the wrapper, never defaulted here).
# ----------------------------------------------------------------------------
variable "security" {
description = "mTLS + JWT signing configuration. Secret values must be supplied (never defaulted in module code)."
type = object({
cert_dir = optional(string, "/usr/local/share/ca-certificates")
allowed_wildcard_domain = optional(string, "")
allowed_common_names = optional(string, "")
jwt_signing_key = optional(string, "")
jwt_signing_read_key = optional(string, "")
jwt_filer_signing_key = optional(string, "")
jwt_filer_signing_read_key = optional(string, "")
})
default = {}
sensitive = true
}
variable "hardening" {
description = "systemd hardening directives (OpenShift SCC analogs). Relax per field if needed."
type = object({
run_as_user = optional(string, "seaweedfs")
no_new_privileges = optional(bool, true)
protect_system = optional(bool, true)
cap_drop_all = optional(bool, true)
})
default = {}
}
variable "env_file" {
description = "Path to the systemd EnvironmentFile holding secret env (DB creds, S3 admin key) fetched at boot. Optional (-prefixed in the unit, so a missing file is tolerated)."
type = string
default = "/etc/seaweedfs/weed.env"
}
variable "render_secret_files" {
description = <<-EOT
When true (default), secret-bearing config (security.toml, S3 identity JSON)
is written directly into cloud-init user_data. Convenient for the local test
harness and simple deployments. Set to false to keep secrets OUT of user_data
(and out of the cloud metadata service): the files are then exposed only via
the secret_files_by_node output, and the wrapper is expected to deliver them
from a secret store via boot_fetch_script.
EOT
type = bool
default = true
}
variable "boot_fetch_script" {
description = "Optional shell script written to /opt/seaweedfs/fetch-secrets.sh (root, 0755) and run before the weed unit starts. Used by wrappers to pull certs/secrets from a cloud secret store at boot."
type = string
default = ""
}
# ----------------------------------------------------------------------------
# Master (Raft quorum). Keyed `nodes` map -> for_each, never count.
# ----------------------------------------------------------------------------
variable "master" {
description = "Master tier. nodes is a map keyed by stable node id (m0/m1/m2)."
type = object({
enabled = optional(bool, true)
nodes = optional(map(object({
address = string
port = optional(number)
grpc_port = optional(number)
data_dir = optional(string)
disk_mounts = optional(list(object({
mountpoint = string
fstype = optional(string, "ext4")
devices = optional(list(string), [])
})), [])
})), {})
port = optional(number, 9333)
grpc_port = optional(number, null) # null => weed auto = port+10000
metrics_port = optional(number, 9327)
metrics_ip = optional(string, "")
ip_bind = optional(string, "0.0.0.0")
data_dir = optional(string, "/var/lib/seaweedfs/master")
default_replication = optional(string, "000")
volume_size_limit_mb = optional(number, 1000)
volume_preallocate = optional(bool, false)
garbage_threshold = optional(number, null)
disable_http = optional(bool, false)
raft_hashicorp = optional(bool, false)
resume_state = optional(bool, true) # deliberate deviation: binary default false
election_timeout = optional(string, "10s")
heartbeat_interval = optional(string, "300ms")
white_list = optional(string, "")
extra_args = optional(list(string), [])
})
default = {}
}
# ----------------------------------------------------------------------------
# Volume (stateful disk owners). Keyed `nodes` map.
# ----------------------------------------------------------------------------
variable "volume" {
description = "Volume tier. Each node owns one or more data dirs (-> -dir / -max)."
type = object({
enabled = optional(bool, true)
nodes = optional(map(object({
address = string
port = optional(number)
grpc_port = optional(number)
public_url = optional(string)
rack = optional(string)
data_center = optional(string)
data_dirs = optional(list(object({
path = string
max_volumes = optional(number, 0) # 0 => auto on non-windows
})))
idx_dir = optional(string)
disk_mounts = optional(list(object({
mountpoint = string
fstype = optional(string, "ext4")
devices = optional(list(string), [])
})), [])
})), {})
port = optional(number, 8080)
metrics_port = optional(number, 9327)
metrics_ip = optional(string, "")
ip_bind = optional(string, "0.0.0.0")
data_dirs = optional(list(object({
path = string
max_volumes = optional(number, 0)
})), [{ path = "/data", max_volumes = 0 }])
idx_dir = optional(string, "")
read_mode = optional(string, "proxy")
compaction_mbps = optional(number, 50)
min_free_space_percent = optional(string, "1")
file_size_limit_mb = optional(number, null)
index = optional(string, "memory")
images_fix_orientation = optional(bool, false)
white_list = optional(string, "")
extra_args = optional(list(string), [])
})
default = {}
}
# ----------------------------------------------------------------------------
# Filer (+ optional embedded S3). Keyed `nodes` map.
# ----------------------------------------------------------------------------
variable "filer" {
description = "Filer tier. Default leveldb2-replicated HA (each filer has its own store)."
type = object({
enabled = optional(bool, true)
nodes = optional(map(object({
address = string
port = optional(number)
grpc_port = optional(number)
data_dir = optional(string)
disk_mounts = optional(list(object({
mountpoint = string
fstype = optional(string, "ext4")
devices = optional(list(string), [])
})), [])
})), {})
port = optional(number, 8888)
metrics_port = optional(number, 9327)
metrics_ip = optional(string, "")
ip_bind = optional(string, "0.0.0.0")
data_dir = optional(string, "/var/lib/seaweedfs/filer") # -defaultStoreDir for leveldb2
default_replica_placement = optional(string, "000")
dir_list_limit = optional(number, 100000)
max_mb = optional(number, null)
disable_dir_listing = optional(bool, false)
disable_http = optional(bool, false)
encrypt_volume_data = optional(bool, false)
rack = optional(string, "")
data_center = optional(string, "")
filer_group = optional(string, "")
extra_env = optional(map(string), {})
extra_args = optional(list(string), [])
s3 = optional(object({
enabled = optional(bool, false)
port = optional(number, 8333)
https_port = optional(number, 0)
domain_name = optional(string, "")
config_path = optional(string, "/etc/seaweedfs/s3_config.json")
}), {})
})
default = {}
}
# ----------------------------------------------------------------------------
# S3 gateway (standalone). Keyed `nodes` map (stateless, but kept keyed for
# parity; wrappers may instead drive an ASG).
# ----------------------------------------------------------------------------
variable "s3" {
description = "Standalone S3 gateway tier."
type = object({
enabled = optional(bool, false)
nodes = optional(map(object({
address = string
port = optional(number)
grpc_port = optional(number)
})), {})
port = optional(number, 8333)
https_port = optional(number, 0)
iceberg_port = optional(number, null)
metrics_port = optional(number, 9327)
ip_bind = optional(string, "0.0.0.0")
domain_name = optional(string, "")
filer_address = optional(string, "") # override; else first filer node
config_path = optional(string, "/etc/seaweedfs/s3_config.json")
audit_log_config_path = optional(string, "")
cert_file = optional(string, "")
key_file = optional(string, "")
cacert_file = optional(string, "")
verify_client_cert = optional(bool, false)
extra_args = optional(list(string), [])
})
default = {}
}
# ----------------------------------------------------------------------------
# S3 identities -> rendered into the s3 config JSON (non-admin only; the admin
# credential is delivered via EnvironmentFile, never the JSON).
# ----------------------------------------------------------------------------
variable "s3_identities" {
description = "Non-admin S3 identities rendered into the config JSON. Admin key goes in the EnvironmentFile, not here."
type = list(object({
name = string
access_key = string
secret_key = string
actions = list(string)
}))
default = []
sensitive = true
}
# ----------------------------------------------------------------------------
# All-in-one (single `weed server` process).
# ----------------------------------------------------------------------------
variable "all_in_one" {
description = "All-in-one tier (weed server). Mutually exclusive with the distributed tiers."
type = object({
enabled = optional(bool, false)
nodes = optional(map(object({
address = string
data_dir = optional(string)
disk_mounts = optional(list(object({
mountpoint = string
fstype = optional(string, "ext4")
devices = optional(list(string), [])
})), [])
})), {})
data_dir = optional(string, "/data")
ip_bind = optional(string, "0.0.0.0")
master_port = optional(number, 9333)
volume_port = optional(number, 8080)
filer_port = optional(number, 8888)
default_replication = optional(string, "000")
volume_size_limit_mb = optional(number, 1000)
idle_timeout = optional(number, 30)
metrics_port = optional(number, 9324)
disable_http = optional(bool, false)
s3 = optional(object({
enabled = optional(bool, false)
port = optional(number, 8333)
config_path = optional(string, "/etc/seaweedfs/s3/s3_config.json")
domain_name = optional(string, "")
}), {})
extra_args = optional(list(string), [])
})
default = {}
}
+6
View File
@@ -0,0 +1,6 @@
terraform {
# 1.3+ is required for optional() object attributes with defaults.
# The published wrappers pin a single honest floor;
# the core itself only needs language features available since 1.3.
required_version = ">= 1.3.0"
}
+68
View File
@@ -0,0 +1,68 @@
locals {
components = ["master", "volume", "filer", "client"]
ip_sans = distinct(concat(["127.0.0.1"], var.ip_sans))
}
# ---- CA ---------------------------------------------------------------------
resource "tls_private_key" "ca" {
algorithm = var.key_algorithm
ecdsa_curve = var.ecdsa_curve
rsa_bits = var.rsa_bits
}
resource "tls_self_signed_cert" "ca" {
private_key_pem = tls_private_key.ca.private_key_pem
is_ca_certificate = true
subject {
common_name = "SeaweedFS CA"
organization = "SeaweedFS"
}
validity_period_hours = var.ca_validity_hours
allowed_uses = ["cert_signing", "crl_signing", "digital_signature"]
}
# ---- per-component leaf certs (distinct CN per component) --------------------
resource "tls_private_key" "component" {
for_each = toset(local.components)
algorithm = var.key_algorithm
ecdsa_curve = var.ecdsa_curve
rsa_bits = var.rsa_bits
}
resource "tls_cert_request" "component" {
for_each = toset(local.components)
private_key_pem = tls_private_key.component[each.key].private_key_pem
subject {
# Distinct CN per component so peer-identity authZ (allowed_wildcard_domain
# / allowed_commonNames) is meaningful. Do NOT use one CN for all.
common_name = "${each.key}.${var.internal_domain}"
organization = "SeaweedFS"
}
dns_names = distinct(concat(
["${each.key}.${var.internal_domain}", "localhost"],
var.extra_dns_sans,
))
ip_addresses = local.ip_sans
}
resource "tls_locally_signed_cert" "component" {
for_each = toset(local.components)
cert_request_pem = tls_cert_request.component[each.key].cert_request_pem
ca_private_key_pem = tls_private_key.ca.private_key_pem
ca_cert_pem = tls_self_signed_cert.ca.cert_pem
validity_period_hours = var.cert_validity_hours
early_renewal_hours = var.cert_early_renewal_hours
allowed_uses = ["digital_signature", "key_agreement", "server_auth", "client_auth"]
}
# ---- JWT signing keys -------------------------------------------------------
resource "random_password" "jwt" {
for_each = var.generate_jwt ? toset(["signing", "signing_read", "filer_signing", "filer_signing_read"]) : toset([])
length = var.jwt_length
special = false # TOML-safe
}
+65
View File
@@ -0,0 +1,65 @@
output "ca_cert_pem" {
description = "CA certificate PEM."
value = tls_self_signed_cert.ca.cert_pem
}
output "certs" {
description = "Per-component { cert_pem, private_key_pem }."
value = {
for c in local.components : c => {
cert_pem = tls_locally_signed_cert.component[c].cert_pem
private_key_pem = tls_private_key.component[c].private_key_pem
}
}
sensitive = true
}
output "jwt" {
description = "Generated JWT keys (empty strings when generate_jwt = false)."
value = {
signing = var.generate_jwt ? random_password.jwt["signing"].result : ""
signing_read = var.generate_jwt ? random_password.jwt["signing_read"].result : ""
filer_signing = var.generate_jwt ? random_password.jwt["filer_signing"].result : ""
filer_signing_read = var.generate_jwt ? random_password.jwt["filer_signing_read"].result : ""
}
sensitive = true
}
# Ready to pass straight to the core module's `security` variable.
output "core_security" {
description = "Object shaped for the core module's `security` input (cert_dir, allowed_wildcard_domain, JWT keys)."
value = {
cert_dir = var.cert_dir
allowed_wildcard_domain = ".${var.internal_domain}"
allowed_common_names = ""
jwt_signing_key = var.generate_jwt ? random_password.jwt["signing"].result : ""
jwt_signing_read_key = var.generate_jwt ? random_password.jwt["signing_read"].result : ""
jwt_filer_signing_key = var.generate_jwt ? random_password.jwt["filer_signing"].result : ""
jwt_filer_signing_read_key = var.generate_jwt ? random_password.jwt["filer_signing_read"].result : ""
}
sensitive = true
}
# Non-sensitive manifest (key, on-host path, mode) so a wrapper can for_each
# over SSM/secret-store resources without tripping the sensitive-for_each rule.
output "secret_manifest" {
description = "List of { key, path, mode } for the CA + component cert/key files (no content)."
value = concat(
[{ key = "ca/tls.crt", path = "${var.cert_dir}/ca/tls.crt", mode = "0644" }],
flatten([for c in local.components : [
{ key = "${c}/tls.crt", path = "${var.cert_dir}/${c}/tls.crt", mode = "0644" },
{ key = "${c}/tls.key", path = "${var.cert_dir}/${c}/tls.key", mode = "0600" },
]]),
)
}
# Sensitive map keyed by the manifest key -> file content.
output "secret_contents" {
description = "Map of manifest key -> PEM content."
value = merge(
{ "ca/tls.crt" = tls_self_signed_cert.ca.cert_pem },
{ for c in local.components : "${c}/tls.crt" => tls_locally_signed_cert.component[c].cert_pem },
{ for c in local.components : "${c}/tls.key" => tls_private_key.component[c].private_key_pem },
)
sensitive = true
}
+89
View File
@@ -0,0 +1,89 @@
# =============================================================================
# SeaweedFS security material generator (cloud-agnostic).
#
# Generates the CA, per-component mTLS certs with DISTINCT CommonNames, and JWT
# signing keys. Outputs a `core_security` object ready to feed the core module's
# `security` variable, plus the raw PEMs for a wrapper to deliver via a secret
# store. NOTE: TF-generated secrets live in state; prefer Vault PKI for the CA
# in production.
# =============================================================================
variable "internal_domain" {
description = "Internal domain for component CNs (e.g. master.<domain>). Drives the allowed_wildcard_domain peer-auth check."
type = string
default = "seaweedfs.internal"
}
variable "ip_sans" {
description = "IP addresses to include as SANs on every component cert (all node private IPs + 127.0.0.1 is added automatically)."
type = list(string)
default = []
}
variable "extra_dns_sans" {
description = "Additional DNS SANs to include on every component cert."
type = list(string)
default = []
}
variable "key_algorithm" {
description = "CA/leaf key algorithm: ECDSA (default, modern) or RSA."
type = string
default = "ECDSA"
validation {
condition = contains(["ECDSA", "RSA"], var.key_algorithm)
error_message = "key_algorithm must be ECDSA or RSA."
}
}
variable "ecdsa_curve" {
description = "ECDSA curve when key_algorithm = ECDSA."
type = string
default = "P256"
}
variable "rsa_bits" {
description = "RSA key size when key_algorithm = RSA."
type = number
default = 3072
}
variable "ca_validity_hours" {
description = "CA certificate validity (default 10 years)."
type = number
default = 87600
}
variable "cert_validity_hours" {
description = "Component certificate validity. Short by default; certs hot-reload so reissue is cheap."
type = number
default = 72
}
variable "cert_early_renewal_hours" {
description = "Renew component certs this many hours before expiry."
type = number
default = 24
}
variable "cert_dir" {
description = "On-host directory where certs are placed; surfaced in core_security.cert_dir."
type = string
default = "/usr/local/share/ca-certificates"
}
variable "generate_jwt" {
description = "Generate JWT signing keys (signing, signing.read, filer_signing, filer_signing.read)."
type = bool
default = true
}
variable "jwt_length" {
description = "Length of generated JWT keys (>= 32 hardened)."
type = number
default = 40
validation {
condition = var.jwt_length >= 32
error_message = "jwt_length must be >= 32 (hardened minimum)."
}
}
+13
View File
@@ -0,0 +1,13 @@
terraform {
required_version = ">= 1.3.0"
required_providers {
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.5"
}
}
}
+74
View File
@@ -0,0 +1,74 @@
# =============================================================================
# Local mTLS test harness: generate the CA + component certs + JWT keys with the
# security submodule, render security.toml with the core, then run a real
# master+volume+filer cluster with mTLS enabled on 127.0.0.1 and assert it works.
# Proves the generated security material is valid and accepted by weed.
# =============================================================================
terraform {
required_version = ">= 1.3.0"
}
variable "weed_binary" {
type = string
default = "/usr/bin/weed"
}
variable "workdir" {
type = string
default = "/tmp/seaweedfs-tftest-secure"
}
module "security" {
source = "../../modules/security"
internal_domain = "seaweedfs.internal"
ip_sans = ["127.0.0.1"]
cert_dir = "${var.workdir}/certs"
}
module "core" {
source = "../../modules/core"
weed_binary = var.weed_binary
enable_security = true
security = module.security.core_security
# We read security.toml back from the output and place it ourselves (under
# $HOME/.seaweedfs), so keep it in the rendered file set.
render_secret_files = true
# High, distinct ports so this never collides with a running SeaweedFS or the
# non-secure harness.
master = {
nodes = { m0 = { address = "127.0.0.1", port = 29336, data_dir = "${var.workdir}/master-m0" } }
election_timeout = "3s"
heartbeat_interval = "200ms"
}
volume = {
nodes = { v0 = { address = "127.0.0.1", port = 28081, data_dirs = [{ path = "${var.workdir}/volume-v0", max_volumes = 20 }] } }
}
filer = {
nodes = { f0 = { address = "127.0.0.1", port = 28889, data_dir = "${var.workdir}/filer-f0" } }
}
}
output "cluster" {
sensitive = true
value = {
for name, n in module.core.nodes : name => {
role = n.role
http_port = n.ports.http
data_dirs = n.data_dirs
argv = n.argv
env = n.env
}
}
}
output "security_toml" {
sensitive = true
value = module.core.secret_files_by_node["master-m0"]["/etc/seaweedfs/security.toml"]
}
output "certs" {
sensitive = true
value = [for m in module.security.secret_manifest : { path = m.path, mode = m.mode, content = module.security.secret_contents[m.key] }]
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Generate mTLS material + security.toml with Terraform, then run a real
# master+volume+filer cluster with mTLS enabled and assert it works.
# ./run_local_secure.sh # render + run + assert + teardown
# KEEP=1 ./run_local_secure.sh
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
export PATH="/opt/homebrew/bin:$PATH"
TOFU="${TOFU:-tofu}"
WEED="${WEED:-$(go env GOPATH 2>/dev/null || echo "$HOME/go")/bin/weed}"
WORKDIR="${WORKDIR:-/tmp/seaweedfs-tftest-secure}"
LOGDIR="$WORKDIR/logs"
RUNDIR="$WORKDIR/run"
PASS=0
FAIL=0
ok() { echo " PASS: $1"; PASS=$((PASS + 1)); }
bad() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
info() { echo "==> $1"; }
cleanup() {
info "tearing down"
if [ -d "$RUNDIR" ]; then
for pf in "$RUNDIR"/*.pid; do [ -f "$pf" ] && kill "$(cat "$pf")" 2>/dev/null; done
sleep 1
for pf in "$RUNDIR"/*.pid; do [ -f "$pf" ] && kill -9 "$(cat "$pf")" 2>/dev/null; done
fi
}
info "cleaning $WORKDIR"
case "$WORKDIR" in "" | "/" | "$HOME") echo "refusing to delete '$WORKDIR'" >&2; exit 2 ;; esac
rm -rf "$WORKDIR"
mkdir -p "$LOGDIR" "$RUNDIR" "$WORKDIR/.seaweedfs"
[ -x "$WEED" ] || { echo "weed not found at $WEED" >&2; exit 2; }
info "generating certs + rendering config with OpenTofu"
cd "$HERE"
"$TOFU" init -backend=false -input=false -no-color >/dev/null 2>&1 || { echo "tofu init failed"; exit 2; }
if ! "$TOFU" apply -auto-approve -input=false -no-color \
-var "weed_binary=$WEED" -var "workdir=$WORKDIR" >"$LOGDIR/tofu.log" 2>&1; then
echo "tofu apply failed"; tail -30 "$LOGDIR/tofu.log"; exit 2
fi
# write certs to their on-host paths
"$TOFU" output -json certs | jq -c '.[]' | while IFS= read -r c; do
p="$(echo "$c" | jq -r '.path')"; m="$(echo "$c" | jq -r '.mode')"
mkdir -p "$(dirname "$p")"
echo "$c" | jq -r '.content' > "$p"
chmod "$m" "$p"
done
# place security.toml where weed searches ($HOME/.seaweedfs)
"$TOFU" output -raw security_toml > "$WORKDIR/.seaweedfs/security.toml"
info "security.toml + $(ls "$WORKDIR"/certs | wc -l | tr -d ' ') cert dirs written"
OUT="$("$TOFU" output -json cluster)"
# derive ports from the rendered config (high range; never hardcoded)
port_of() { echo "$OUT" | jq -r --arg r "$1" '.[] | select(.role==$r) | .http_port' | head -1; }
MPORT="$(port_of master)"; VPORT="$(port_of volume)"; FPORT="$(port_of filer)"
busy=""
for p in $(echo "$OUT" | jq -r '.[].http_port'); do
lsof -nP -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1 && busy="$busy $p"
done
[ -n "$busy" ] && { echo "Required port(s) already in use:$busy -- is another SeaweedFS running?" >&2; exit 3; }
[ "${KEEP:-0}" = "1" ] || trap cleanup EXIT INT TERM
# launch a node with HOME pointed at $WORKDIR so weed loads security.toml
launch() {
n="$1"
for d in $(echo "$OUT" | jq -r --arg n "$n" '.[$n].data_dirs[]?'); do mkdir -p "$d"; done
ENVS=("HOME=$WORKDIR")
while IFS= read -r e; do [ -n "$e" ] && ENVS+=("$e"); done \
< <(echo "$OUT" | jq -r --arg n "$n" '.[$n].env | to_entries[] | "\(.key)=\(.value)"')
ARGV=()
while IFS= read -r a; do ARGV+=("$a"); done \
< <(echo "$OUT" | jq -r --arg n "$n" '.[$n].argv[]')
info "launching $n (mTLS)"
env "${ENVS[@]}" "$WEED" "${ARGV[@]}" >"$LOGDIR/$n.log" 2>&1 &
echo "$!" > "$RUNDIR/$n.pid"
}
wait_http() {
url="$1"; t="${2:-30}"; i=0
while [ "$i" -lt "$t" ]; do
curl -fsS -o /dev/null --max-time 2 "$url" 2>/dev/null && return 0
i=$((i + 1)); sleep 1
done
return 1
}
launch master-m0
QOK=0; i=0
while [ "$i" -lt 40 ]; do
st="$(curl -fsS --max-time 2 "http://127.0.0.1:$MPORT/cluster/status" 2>/dev/null)" || { i=$((i+1)); sleep 1; continue; }
[ "$(echo "$st" | jq -r '.IsLeader // false')" = "true" ] && { QOK=1; break; }
i=$((i + 1)); sleep 1
done
[ "$QOK" -eq 1 ] && ok "master elected leader under mTLS" || bad "master leader (mTLS)"
launch volume-v0
# registration (master gRPC over mTLS) confirms the volume joined the cluster
AOK=0; i=0
while [ "$i" -lt 40 ]; do
fid="$(curl -fsS --max-time 2 "http://127.0.0.1:$MPORT/dir/assign" 2>/dev/null | jq -r '.fid // ""')"
[ -n "$fid" ] && { AOK=1; break; }
i=$((i + 1)); sleep 1
done
[ "$AOK" -eq 1 ] && ok "volume registered via mTLS gRPC (fid $fid)" || bad "volume registration over mTLS"
# /healthz flips to 200 after the first heartbeat completes (slower under mTLS)
wait_http "http://127.0.0.1:$VPORT/healthz" 60 && ok "volume /healthz up (mTLS)" || bad "volume /healthz"
launch filer-f0
wait_http "http://127.0.0.1:$FPORT/" 30 && ok "filer / up (mTLS)" || bad "filer /"
# [jwt.filer_signing] is active, so the filer requires a signed JWT for writes.
# An unsigned write MUST be rejected with 401 -- this proves the JWT signing key
# rendered into security.toml is enforced (positive security assertion).
echo "smoke" > "$WORKDIR/hello.txt"
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 -X POST \
-F "file=@$WORKDIR/hello.txt" "http://127.0.0.1:$FPORT/smoke/hello.txt" 2>/dev/null)"
[ "$code" = "401" ] && ok "filer rejects unsigned write (HTTP 401) => JWT signing enforced" \
|| bad "filer JWT enforcement (expected 401, got HTTP $code)"
echo
info "RESULTS: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
+100
View File
@@ -0,0 +1,100 @@
# =============================================================================
# Local test harness: render a small SeaweedFS cluster with the core module
# and run it as real `weed` processes on 127.0.0.1 (no cloud, no docker).
#
# 3 masters (quorum) + 1 volume + 1 filer + 1 standalone S3, each on a distinct
# port. run_local_cluster.sh consumes the `cluster` output, launches the weed
# processes from the rendered argv, and asserts the cluster actually works.
# =============================================================================
terraform {
required_version = ">= 1.3.0"
}
variable "weed_binary" {
description = "Path to the weed executable used for the local cluster."
type = string
default = "/usr/bin/weed"
}
variable "workdir" {
description = "Scratch directory for per-node data dirs and config files."
type = string
default = "/tmp/seaweedfs-tftest"
}
module "core" {
source = "../../modules/core"
weed_binary = var.weed_binary
monitoring_enabled = false
enable_security = false
# High port range so the harness does not collide with a SeaweedFS cluster
# that may already be running on this dev machine (default 9333/8080/8888/8333).
master = {
nodes = {
m0 = { address = "127.0.0.1", port = 29333, data_dir = "${var.workdir}/master-m0" }
m1 = { address = "127.0.0.1", port = 29334, data_dir = "${var.workdir}/master-m1" }
m2 = { address = "127.0.0.1", port = 29335, data_dir = "${var.workdir}/master-m2" }
}
# Tighten election so a 3-node local quorum converges quickly.
election_timeout = "3s"
heartbeat_interval = "200ms"
}
volume = {
nodes = {
v0 = {
address = "127.0.0.1"
port = 28080
rack = "rack-a"
data_dirs = [{ path = "${var.workdir}/volume-v0", max_volumes = 20 }]
}
}
}
filer = {
nodes = {
f0 = { address = "127.0.0.1", port = 28888, data_dir = "${var.workdir}/filer-f0" }
}
}
s3 = {
enabled = true
nodes = { s0 = { address = "127.0.0.1", port = 28333 } }
# this weed build starts an Iceberg REST catalog on 8181 by default; disable
# it so the test does not collide with a cluster already using that port.
iceberg_port = 0
config_path = "${var.workdir}/s3_config.json"
}
# Anonymous identity so the smoke test can PUT/GET without sigv4 signing.
s3_identities = [{
name = "anonymous"
access_key = ""
secret_key = ""
actions = ["Admin", "Read", "Write", "List", "Tagging"]
}]
}
output "cluster" {
description = "Node specs consumed by run_local_cluster.sh (read via `tofu output -json`, which emits values even when sensitive)."
sensitive = true
value = {
for name, n in module.core.nodes : name => {
role = n.role
address = n.address
http_port = n.ports.http
data_dirs = n.data_dirs
argv = n.argv
env = n.env
config_files = n.config_files
exec_start = n.exec_start
}
}
}
output "master_peers" {
value = module.core.master_peers
}
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# Render a SeaweedFS cluster with the Terraform core module and run it as real
# `weed` processes locally, then assert it actually works. No cloud, no docker.
#
# ./run_local_cluster.sh # render + run + assert + teardown
# KEEP=1 ./run_local_cluster.sh # leave the cluster running after asserts
# WEED=/path/to/weed ./run_local_cluster.sh
#
# Ports come from the rendered config (high range, to avoid colliding with a
# SeaweedFS cluster already running on this machine). Exits non-zero on any
# failed assertion or if a required port is already in use.
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
export PATH="/opt/homebrew/bin:$PATH"
TOFU="${TOFU:-tofu}"
WEED="${WEED:-$(go env GOPATH 2>/dev/null || echo "$HOME/go")/bin/weed}"
WORKDIR="${WORKDIR:-/tmp/seaweedfs-tftest}"
LOGDIR="$WORKDIR/logs"
RUNDIR="$WORKDIR/run"
PASS=0
FAIL=0
ok() { echo " PASS: $1"; PASS=$((PASS + 1)); }
bad() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
info() { echo "==> $1"; }
cleanup() {
info "tearing down"
if [ -d "$RUNDIR" ]; then
for pf in "$RUNDIR"/*.pid; do [ -f "$pf" ] && kill "$(cat "$pf")" 2>/dev/null; done
sleep 1
for pf in "$RUNDIR"/*.pid; do [ -f "$pf" ] && kill -9 "$(cat "$pf")" 2>/dev/null; done
fi
}
info "cleaning $WORKDIR"
case "$WORKDIR" in "" | "/" | "$HOME") echo "refusing to delete '$WORKDIR'" >&2; exit 2 ;; esac
rm -rf "$WORKDIR"
mkdir -p "$LOGDIR" "$RUNDIR"
[ -x "$WEED" ] || { echo "weed binary not found/executable at $WEED" >&2; exit 2; }
info "rendering cluster config with OpenTofu"
cd "$HERE"
"$TOFU" init -backend=false -input=false -no-color >/dev/null 2>&1 || { echo "tofu init failed"; exit 2; }
if ! "$TOFU" apply -auto-approve -input=false -no-color \
-var "weed_binary=$WEED" -var "workdir=$WORKDIR" >"$LOGDIR/tofu.log" 2>&1; then
echo "tofu apply failed; see $LOGDIR/tofu.log"; tail -30 "$LOGDIR/tofu.log"; exit 2
fi
OUT="$("$TOFU" output -json cluster)"
[ -n "$OUT" ] || { echo "empty cluster output"; exit 2; }
# ---- derive ports by role (never hardcoded) ---------------------------------
port_of() { echo "$OUT" | jq -r --arg r "$1" '.[] | select(.role==$r) | .http_port' | head -1; }
mports() { echo "$OUT" | jq -r '.[] | select(.role=="master") | .http_port' | sort; }
MPORT1="$(mports | head -1)"
VPORT="$(port_of volume)"; FPORT="$(port_of filer)"; SPORT="$(port_of s3)"
NMASTERS="$(mports | wc -l | tr -d ' ')"
# ---- refuse to run if a required port is already taken (foreign cluster) -----
busy=""
for p in $(echo "$OUT" | jq -r '.[].http_port'); do
lsof -nP -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1 && busy="$busy $p"
done
[ -n "$busy" ] && { echo "Required port(s) already in use:$busy -- is another SeaweedFS running?" >&2; exit 3; }
[ "${KEEP:-0}" = "1" ] || trap cleanup EXIT INT TERM
node_names() { echo "$OUT" | jq -r 'keys[]'; }
node_field() { echo "$OUT" | jq -r --arg n "$1" --arg f "$2" '.[$n][$f]'; }
launch_node() {
n="$1"
for p in $(echo "$OUT" | jq -r --arg n "$n" '.[$n].config_files | keys[]?'); do
mkdir -p "$(dirname "$p")"
echo "$OUT" | jq -r --arg n "$n" --arg p "$p" '.[$n].config_files[$p]' > "$p"
done
for d in $(echo "$OUT" | jq -r --arg n "$n" '.[$n].data_dirs[]?'); do mkdir -p "$d"; done
ENVS=()
while IFS= read -r e; do [ -n "$e" ] && ENVS+=("$e"); done \
< <(echo "$OUT" | jq -r --arg n "$n" '.[$n].env | to_entries[] | "\(.key)=\(.value)"')
ARGV=()
while IFS= read -r a; do ARGV+=("$a"); done \
< <(echo "$OUT" | jq -r --arg n "$n" '.[$n].argv[]')
info "launching $n"
if [ "${#ENVS[@]}" -gt 0 ]; then
env "${ENVS[@]}" "$WEED" "${ARGV[@]}" >"$LOGDIR/$n.log" 2>&1 &
else
"$WEED" "${ARGV[@]}" >"$LOGDIR/$n.log" 2>&1 &
fi
echo "$!" > "$RUNDIR/$n.pid"
}
wait_http() {
url="$1"; t="${2:-30}"; i=0
while [ "$i" -lt "$t" ]; do
curl -fsS -o /dev/null --max-time 2 "$url" 2>/dev/null && return 0
i=$((i + 1)); sleep 1
done
return 1
}
# ---- launch masters and wait for quorum -------------------------------------
for n in $(node_names); do
case "$(node_field "$n" role)" in master) launch_node "$n";; esac
done
info "waiting for master quorum across ports: $(mports | tr '\n' ' ')"
QUORUM_OK=0; i=0
while [ "$i" -lt 40 ]; do
LEADERS=0; AGREED_LEADER=""; mismatch=0; reachable=0
for port in $(mports); do
st="$(curl -fsS --max-time 2 "http://127.0.0.1:$port/cluster/status" 2>/dev/null)" || continue
reachable=$((reachable + 1))
[ "$(echo "$st" | jq -r '.IsLeader // false')" = "true" ] && LEADERS=$((LEADERS + 1))
ldr="$(echo "$st" | jq -r '.Leader // ""')"
if [ -n "$ldr" ]; then
if [ -z "$AGREED_LEADER" ]; then AGREED_LEADER="$ldr"; elif [ "$AGREED_LEADER" != "$ldr" ]; then mismatch=1; fi
fi
done
if [ "$reachable" -eq "$NMASTERS" ] && [ "$LEADERS" -eq 1 ] && [ "$mismatch" -eq 0 ] && [ -n "$AGREED_LEADER" ]; then
QUORUM_OK=1; break
fi
i=$((i + 1)); sleep 1
done
if [ "$QUORUM_OK" -eq 1 ]; then
ok "$NMASTERS-master quorum: exactly one leader ($AGREED_LEADER), all agree"
else
bad "master quorum (reachable=$reachable leaders=$LEADERS leader='$AGREED_LEADER')"
fi
# ---- volume -----------------------------------------------------------------
for n in $(node_names); do
case "$(node_field "$n" role)" in volume) launch_node "$n";; esac
done
wait_http "http://127.0.0.1:$VPORT/healthz" 40 && ok "volume /healthz up" || bad "volume /healthz"
ASSIGN_OK=0; i=0
while [ "$i" -lt 20 ]; do
fid="$(curl -fsS --max-time 2 "http://127.0.0.1:$MPORT1/dir/assign" 2>/dev/null | jq -r '.fid // ""')"
[ -n "$fid" ] && { ASSIGN_OK=1; break; }
i=$((i + 1)); sleep 1
done
[ "$ASSIGN_OK" -eq 1 ] && ok "master assigned fid ($fid) => volume registered" || bad "master /dir/assign (no volume?)"
# ---- filer ------------------------------------------------------------------
for n in $(node_names); do
case "$(node_field "$n" role)" in filer) launch_node "$n";; esac
done
wait_http "http://127.0.0.1:$FPORT/" 30 && ok "filer / up" || bad "filer /"
PAYLOAD="seaweedfs-terraform-smoke-$$"
echo "$PAYLOAD" > "$WORKDIR/hello.txt"
curl -fsS -F "file=@$WORKDIR/hello.txt" "http://127.0.0.1:$FPORT/smoke/hello.txt" >/dev/null 2>&1
got="$(curl -fsS --max-time 5 "http://127.0.0.1:$FPORT/smoke/hello.txt" 2>/dev/null)"
[ "$got" = "$PAYLOAD" ] && ok "filer PUT/GET round-trip" || bad "filer round-trip (got '$got')"
# ---- s3 ---------------------------------------------------------------------
for n in $(node_names); do
case "$(node_field "$n" role)" in s3) launch_node "$n";; esac
done
wait_http "http://127.0.0.1:$SPORT/status" 30 && ok "s3 /status up" || bad "s3 /status"
curl -fsS -X PUT "http://127.0.0.1:$SPORT/smoke-bucket" >/dev/null 2>&1
sleep 1
curl -fsS -X PUT --data-binary "$PAYLOAD" "http://127.0.0.1:$SPORT/smoke-bucket/obj.txt" >/dev/null 2>&1
s3got="$(curl -fsS --max-time 5 "http://127.0.0.1:$SPORT/smoke-bucket/obj.txt" 2>/dev/null)"
[ "$s3got" = "$PAYLOAD" ] && ok "s3 PUT/GET round-trip" || bad "s3 round-trip (got '$s3got')"
echo
info "RESULTS: $PASS passed, $FAIL failed"
[ "${KEEP:-0}" = "1" ] && info "KEEP=1: cluster left running. Logs in $LOGDIR."
[ "$FAIL" -eq 0 ]
+1 -1
View File
@@ -22,7 +22,7 @@
<junit.version>4.13.2</junit.version>
<seaweedfs.hadoop3.client.version>4.00</seaweedfs.hadoop3.client.version>
<jackson.version>2.18.6</jackson.version> <!-- Upgraded from 2.15.3 -->
<netty.version>4.2.13.Final</netty.version> <!-- Upgraded to 4.1.125.Final for security fixes (CVE in netty-codec < 4.1.125.Final, netty-codec-http2 <= 4.1.123.Final) -->
<netty.version>4.2.15.Final</netty.version> <!-- Upgraded to 4.1.125.Final for security fixes (CVE in netty-codec < 4.1.125.Final, netty-codec-http2 <= 4.1.123.Final) -->
<parquet.version>1.15.2</parquet.version> <!-- Upgraded to 1.15.2 for security fix -->
<parquet.format.version>2.12.0</parquet.format.version>
<surefire.jvm.args>
-36
View File
@@ -1,36 +0,0 @@
.PHONY: all build test test-verbose test-short test-debug clean deps tidy
all: build test
# Build the weed binary first
build:
cd ../../weed && go build -o weed .
# Install test dependencies
deps:
go mod download
# Run all tests
test: build deps
go test -timeout 5m ./...
# Run tests with verbose output
test-verbose: build deps
go test -v -timeout 5m ./...
# Skip long-running integration tests
test-short: deps
go test -short -v ./...
# Run tests with debug output from SeaweedFS
test-debug: build deps
go test -v -timeout 5m ./... 2>&1 | tee test.log
# Clean up test artifacts
clean:
rm -f test.log
go clean -testcache
# Update go.sum
tidy:
go mod tidy
-92
View File
@@ -1,92 +0,0 @@
# SeaweedFS NFS Integration Tests
End-to-end tests that boot a real SeaweedFS cluster (`master` + `volume` +
`filer`) plus the experimental `weed nfs` frontend and drive it through the
NFSv3 wire protocol. The tests talk to the server over TCP using
`github.com/willscott/go-nfs-client`, which means they do **not** need a
kernel NFS mount, privileged ports, or any platform-specific tooling.
## Prerequisites
1. Build the `weed` binary:
```bash
cd ../../weed
go build -o weed .
```
2. Go 1.24 or later.
## Running the tests
```bash
# Build weed and run everything
make test
# Verbose output, keeps the subprocess stdout
make test-verbose
# Skip integration tests — useful when iterating on the framework itself
make test-short
# Run a single test
go test -v -run TestNfsBasicReadWrite ./...
```
Every test starts its own cluster on random loopback ports, so runs are
isolated and can execute in parallel.
## Layout
- `framework.go` — launches `weed master`, `weed volume`, `weed filer`, and
`weed nfs` as subprocesses, waits for each to accept TCP, and exposes a
`Mount()` helper that returns an `nfsclient.Target`.
- `basic_test.go` — covers the most common NFS operations:
- Read/write round-trip (`TestNfsBasicReadWrite`)
- Mkdir / ReadDirPlus / RmDir (`TestNfsMkdirAndRmdir`)
- Nested directory + leaf file (`TestNfsNestedDirectories`)
- Rename preserves content (`TestNfsRenamePreservesContent`)
- Overwrite shrinks file size (`TestNfsOverwriteShrinksFile`)
- Large binary file round-trip (`TestNfsLargeFile`)
- Arbitrary binary and empty files (`TestNfsBinaryAndEmptyFiles`)
- Symlink + Readlink (`TestNfsSymlinkRoundTrip`)
- ReadDirPlus ordering sanity (`TestNfsReadDirPlusOrdering`)
- Remove on missing path errors cleanly (`TestNfsRemoveMissingFailsCleanly`)
- FSINFO advertises non-zero limits (`TestNfsFSInfoReturnsSaneLimits`)
- Sequential append writes concatenate (`TestNfsAppendIsSequential`)
- ReadDir after remove (`TestNfsReadDirAfterRemove`)
## Debugging a failing test
Keep the cluster temp dir for inspection:
```go
config := DefaultTestConfig()
config.SkipCleanup = true
```
Enable subprocess stdout/stderr:
```go
config := DefaultTestConfig()
config.EnableDebug = true
```
Or run with `-v`, which flips `EnableDebug` automatically via `testing.Verbose()`.
## Notes
- The NFS server binds to `127.0.0.1` with `-ip.bind=127.0.0.1` and exports
`/nfs_export`. The test framework pre-creates that directory via the
filer's HTTP API before starting the NFS server — the NFS server requires
its export root to exist in the filer's namespace with a real entry, and
the filer's synthetic `/` root does not match the `Name=="/"` check the
NFS server performs during `ensureIndexedEntry`.
- Ports are allocated dynamically. Each test run opens a short-lived
listener on `127.0.0.1:0`, reads back the assigned port, closes the
listener, and hands the port to `weed master/volume/filer/nfs`. There is
a tiny race window between close and reopen that has not been a problem
in practice but is worth remembering if you see a "bind: address already
in use" failure.
- All four `weed` components are started with explicit `-port.grpc=...`
flags. Without them, the default is `-port + 10000`, which overflows
`65535` whenever the HTTP port lands above `55535` — the kernel's
ephemeral port range on macOS routinely does.
-400
View File
@@ -1,400 +0,0 @@
package nfs
import (
"bytes"
"fmt"
"io"
"os"
"path"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
nfsclient "github.com/willscott/go-nfs-client/nfs"
)
// setupFramework is a small helper that boots the cluster for a single test
// and tears everything down on completion. Every test gets a fresh filer +
// volume pair so they cannot step on each other's namespace.
func setupFramework(t *testing.T) *NfsTestFramework {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
config := DefaultTestConfig()
config.EnableDebug = testing.Verbose()
fw := NewNfsTestFramework(t, config)
require.NoError(t, fw.Setup(config), "framework setup")
t.Cleanup(fw.Cleanup)
return fw
}
// writeAll writes payload to path on the target in a single Write call. The
// NFS WRITE3 RPC chunks internally, so this exists purely so tests read
// linearly.
func writeAll(t *testing.T, target *nfsclient.Target, remotePath string, payload []byte) {
t.Helper()
file, err := target.OpenFile(remotePath, 0o644)
require.NoError(t, err, "open %s for write", remotePath)
if len(payload) > 0 {
n, err := file.Write(payload)
require.NoError(t, err, "write %s", remotePath)
require.Equal(t, len(payload), n, "short write on %s", remotePath)
}
require.NoError(t, file.Close(), "close %s", remotePath)
}
// readAll opens path on the target and returns the full file contents.
func readAll(t *testing.T, target *nfsclient.Target, remotePath string) []byte {
t.Helper()
file, err := target.Open(remotePath)
require.NoError(t, err, "open %s for read", remotePath)
defer file.Close()
content, err := io.ReadAll(file)
require.NoError(t, err, "read %s", remotePath)
return content
}
// TestNfsBasicReadWrite exercises the most common NFS path: OpenFile + Write
// + Close followed by Open + Read to verify round-trip data integrity.
func TestNfsBasicReadWrite(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
payload := []byte("hello from seaweedfs nfs integration test")
writeAll(t, target, "/hello.txt", payload)
got := readAll(t, target, "/hello.txt")
assert.Equal(t, payload, got, "round-tripped content must match")
info, err := target.Getattr("/hello.txt")
require.NoError(t, err)
assert.Equal(t, int64(len(payload)), int64(info.Filesize))
}
// TestNfsMkdirAndRmdir covers Mkdir, ReadDirPlus, and RmDir. The readdir
// assertion also verifies that the newly-created directory shows up under
// the export root the way a POSIX client would expect.
func TestNfsMkdirAndRmdir(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
_, err = target.Mkdir("/dir1", 0o755)
require.NoError(t, err)
entries, err := target.ReadDirPlus("/")
require.NoError(t, err)
found := false
for _, entry := range entries {
if entry.Name() == "dir1" {
found = true
assert.True(t, entry.IsDir(), "dir1 should be a directory")
}
}
assert.True(t, found, "expected dir1 in readdir listing")
require.NoError(t, target.RmDir("/dir1"))
// After removal, dir1 must be gone from the listing.
entries, err = target.ReadDirPlus("/")
require.NoError(t, err)
for _, entry := range entries {
assert.NotEqual(t, "dir1", entry.Name(), "dir1 should be removed")
}
}
// TestNfsNestedDirectories ensures the server can materialise a deep tree in
// a single Mkdir-per-segment sequence and that reads/writes work at the
// leaves.
func TestNfsNestedDirectories(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
for _, segment := range []string{"/a", "/a/b", "/a/b/c"} {
_, err := target.Mkdir(segment, 0o755)
require.NoError(t, err, "mkdir %s", segment)
}
payload := []byte("deep path content")
writeAll(t, target, "/a/b/c/leaf.txt", payload)
got := readAll(t, target, "/a/b/c/leaf.txt")
assert.Equal(t, payload, got)
require.NoError(t, target.Remove("/a/b/c/leaf.txt"))
require.NoError(t, target.RmDir("/a/b/c"))
require.NoError(t, target.RmDir("/a/b"))
require.NoError(t, target.RmDir("/a"))
}
// TestNfsRenamePreservesContent renames a file and makes sure the content
// at the new path matches what was written at the old one, and that the
// old path disappears. It does not assert on inode identity because pjdfstest
// already covers that and this test intentionally avoids depending on the
// mount-side identity plumbing.
func TestNfsRenamePreservesContent(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
payload := []byte("rename me")
writeAll(t, target, "/src.txt", payload)
require.NoError(t, target.Rename("/src.txt", "/dst.txt"))
_, _, err = target.Lookup("/src.txt")
assert.Error(t, err, "source should be gone after rename")
got := readAll(t, target, "/dst.txt")
assert.Equal(t, payload, got)
require.NoError(t, target.Remove("/dst.txt"))
}
// TestNfsOverwriteShrinksFile rewrites an existing file with shorter content
// and asserts Getattr reports the new (smaller) size. go-nfs-client's
// OpenFile does not pass O_TRUNC, so the test truncates explicitly via
// Setattr(size=0) before the second write — mirroring what `echo >file`
// does on a POSIX client.
func TestNfsOverwriteShrinksFile(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
writeAll(t, target, "/overwrite.txt", []byte("the quick brown fox"))
require.NoError(t, target.Setattr("/overwrite.txt", nfsclient.Sattr3{
Size: nfsclient.SetSize{SetIt: true, Size: 0},
}))
writeAll(t, target, "/overwrite.txt", []byte("short"))
info, err := target.Getattr("/overwrite.txt")
require.NoError(t, err)
assert.Equal(t, int64(len("short")), int64(info.Filesize))
got := readAll(t, target, "/overwrite.txt")
assert.Equal(t, []byte("short"), got)
require.NoError(t, target.Remove("/overwrite.txt"))
}
// TestNfsLargeFile writes a multi-megabyte payload so the write path has to
// cut chunks and flush through the volume server rather than inlining
// content in the filer entry.
func TestNfsLargeFile(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
const size = 3 * 1024 * 1024 // 3 MiB — exceeds the 4 MiB inline cutoff boundary when combined with metadata
payload := make([]byte, size)
for i := range payload {
payload[i] = byte(i % 251) // non-repeating to catch offset bugs
}
writeAll(t, target, "/big.bin", payload)
info, err := target.Getattr("/big.bin")
require.NoError(t, err)
assert.Equal(t, int64(size), int64(info.Filesize))
got := readAll(t, target, "/big.bin")
require.Equal(t, size, len(got))
assert.True(t, bytes.Equal(payload, got), "large file content must round-trip byte-for-byte")
require.NoError(t, target.Remove("/big.bin"))
}
// TestNfsBinaryAndEmptyFiles covers two edge-case payloads the write path
// tends to regress on: arbitrary binary bytes and zero-length files.
func TestNfsBinaryAndEmptyFiles(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
t.Run("AllByteValues", func(t *testing.T) {
payload := make([]byte, 256)
for i := range payload {
payload[i] = byte(i)
}
writeAll(t, target, "/binary.bin", payload)
assert.Equal(t, payload, readAll(t, target, "/binary.bin"))
require.NoError(t, target.Remove("/binary.bin"))
})
t.Run("EmptyFile", func(t *testing.T) {
writeAll(t, target, "/empty.txt", nil)
info, err := target.Getattr("/empty.txt")
require.NoError(t, err)
assert.Equal(t, int64(0), int64(info.Filesize))
require.NoError(t, target.Remove("/empty.txt"))
})
}
// TestNfsSymlinkRoundTrip covers Symlink and Readlink through the nfs server.
// Readlink returns the target path; the server does not auto-traverse it.
func TestNfsSymlinkRoundTrip(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
// Symlink uses a different RPC than open+create, and our server routes it
// through the billy Change interface.
require.NoError(t, target.Symlink("/target.txt", "/link.txt"))
// The underlying target does not need to exist for readlink to succeed.
file, _, err := target.Lookup("/link.txt")
require.NoError(t, err, "lookup symlink")
assert.True(t, file.Mode()&os.ModeSymlink != 0, "expected symlink mode, got %s", file.Mode())
require.NoError(t, target.Remove("/link.txt"))
}
// TestNfsReadDirPlusOrdering creates a handful of files with distinct names
// and ensures ReadDirPlus surfaces every one of them. The server pages
// listings from the filer, so we want to make sure nothing is truncated.
func TestNfsReadDirPlusOrdering(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
_, err = target.Mkdir("/listing", 0o755)
require.NoError(t, err)
names := []string{"alpha.txt", "beta.txt", "gamma.txt", "delta.txt", "epsilon.txt"}
for _, name := range names {
writeAll(t, target, path.Join("/listing", name), []byte(name))
}
entries, err := target.ReadDirPlus("/listing")
require.NoError(t, err)
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
if entry.Name() == "." || entry.Name() == ".." {
continue
}
seen[entry.Name()] = struct{}{}
}
for _, name := range names {
_, ok := seen[name]
assert.True(t, ok, "expected %s in directory listing", name)
}
for _, name := range names {
require.NoError(t, target.Remove(path.Join("/listing", name)))
}
require.NoError(t, target.RmDir("/listing"))
}
// TestNfsRemoveMissingFailsCleanly asserts that removing a non-existent path
// surfaces an error instead of silently succeeding. A bug where the server
// returned NFS3_OK on missing entries would hide metadata drift.
func TestNfsRemoveMissingFailsCleanly(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
err = target.Remove("/does_not_exist.txt")
require.Error(t, err, "removing a missing file must error")
// NFS3 surfaces this as NFS3ERR_NOENT; make sure the error text is
// recognisable without locking us into the library's exact wording.
assert.True(t,
strings.Contains(strings.ToLower(err.Error()), "noent") ||
strings.Contains(strings.ToLower(err.Error()), "not exist") ||
strings.Contains(strings.ToLower(err.Error()), "no such"),
"unexpected error shape: %v", err)
}
// TestNfsFSInfoReturnsSaneLimits pokes at FSINFO so we catch regressions
// where the server advertises zero read/write limits (which would make
// clients fall back to the 8 KiB floor and slow every test that follows).
func TestNfsFSInfoReturnsSaneLimits(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
info, err := target.FSInfo()
require.NoError(t, err)
require.NotNil(t, info)
assert.Greater(t, info.RTPref, uint32(0), "rtpref must be positive")
assert.Greater(t, info.WTPref, uint32(0), "wtpref must be positive")
}
// TestNfsAppendIsSequential writes two chunks to the same file in separate
// Open cycles and asserts the concatenation is preserved. The second write
// uses O_APPEND (the default Open path in go-nfs-client does not pass
// flags, so we explicitly reopen after writing the first chunk).
func TestNfsAppendIsSequential(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
const prefix = "part1-"
const suffix = "part2"
writeAll(t, target, "/concat.txt", []byte(prefix))
file, err := target.OpenFile("/concat.txt", 0o644)
require.NoError(t, err)
// Seek to end before writing so we append rather than overwrite. go-nfs
// client's File.Seek uses the same offset tracking as Write so this is
// enough to place the second chunk after the first.
_, err = file.Seek(int64(len(prefix)), io.SeekStart)
require.NoError(t, err)
_, err = file.Write([]byte(suffix))
require.NoError(t, err)
require.NoError(t, file.Close())
got := readAll(t, target, "/concat.txt")
assert.Equal(t, prefix+suffix, string(got))
require.NoError(t, target.Remove("/concat.txt"))
}
// Regression: readdir should not emit stale entries after a remove. This is
// the scenario the PR's meta cache invalidation logic was written to fix.
func TestNfsReadDirAfterRemove(t *testing.T) {
fw := setupFramework(t)
target, cleanup, err := fw.Mount()
require.NoError(t, err)
defer cleanup()
_, err = target.Mkdir("/churn", 0o755)
require.NoError(t, err)
for i := 0; i < 5; i++ {
writeAll(t, target, path.Join("/churn", fmt.Sprintf("f%d.txt", i)), []byte{byte(i)})
}
// Remove the middle one and re-list.
require.NoError(t, target.Remove("/churn/f2.txt"))
entries, err := target.ReadDirPlus("/churn")
require.NoError(t, err)
for _, entry := range entries {
assert.NotEqual(t, "f2.txt", entry.Name(), "removed file should not reappear in listing")
}
for i := 0; i < 5; i++ {
if i == 2 {
continue
}
require.NoError(t, target.Remove(path.Join("/churn", fmt.Sprintf("f%d.txt", i))))
}
require.NoError(t, target.RmDir("/churn"))
}
-423
View File
@@ -1,423 +0,0 @@
package nfs
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/testutil"
"github.com/stretchr/testify/require"
nfsclient "github.com/willscott/go-nfs-client/nfs"
"github.com/willscott/go-nfs-client/nfs/rpc"
)
// NfsTestFramework boots a minimal SeaweedFS cluster (master + volume + filer)
// plus the experimental `weed nfs` frontend and hands out NFSv3 RPC clients
// that talk to it. Everything is driven via subprocesses so the tests exercise
// the same binary an operator would deploy, and no kernel mount is required.
type NfsTestFramework struct {
t *testing.T
tempDir string
dataDir string
masterProcess *os.Process
volumeProcess *os.Process
filerProcess *os.Process
nfsProcess *os.Process
masterAddr string
masterGrpc int
volumeAddr string
volumeGrpc int
filerAddr string
filerGrpc int
nfsAddr string
exportRoot string
weedBinary string
isSetup bool
skipCleanup bool
}
// TestConfig controls how the framework boots the cluster.
type TestConfig struct {
NumVolumes int
EnableDebug bool
SkipCleanup bool // keep temp dir on failure for inspection
// ExportRoot is the filer path the NFS server exports. Defaults to "/"
// so tests can use any path, with a single warning logged by the server.
ExportRoot string
}
// DefaultTestConfig returns the defaults used by most tests. A dedicated
// /nfs_export subtree is used as the NFS export root because the NFS server
// requires the export directory to exist in the filer's namespace and carry
// a non-zero inode — passing "/" would succeed only for filer setups that
// have already backfilled the root inode.
func DefaultTestConfig() *TestConfig {
return &TestConfig{
NumVolumes: 3,
EnableDebug: false,
SkipCleanup: false,
ExportRoot: "/nfs_export",
}
}
// NewNfsTestFramework allocates a framework bound to the current test. Call
// Setup next to actually start the cluster.
func NewNfsTestFramework(t *testing.T, config *TestConfig) *NfsTestFramework {
if config == nil {
config = DefaultTestConfig()
}
tempDir, err := os.MkdirTemp("", "seaweedfs_nfs_test_")
require.NoError(t, err)
// testutil.MustAllocatePorts holds every listener open until the full
// batch has been reserved, which avoids the "close-then-hope" race my
// original per-port helper had. We need seven ports: four HTTP (master,
// volume, filer, nfs) and three gRPC (master, volume, filer — nfs has
// no gRPC endpoint).
ports := testutil.MustAllocatePorts(t, 7)
exportRoot := config.ExportRoot
if exportRoot == "" {
exportRoot = "/"
}
return &NfsTestFramework{
t: t,
tempDir: tempDir,
dataDir: filepath.Join(tempDir, "data"),
masterAddr: fmt.Sprintf("127.0.0.1:%d", ports[0]),
masterGrpc: ports[1],
volumeAddr: fmt.Sprintf("127.0.0.1:%d", ports[2]),
volumeGrpc: ports[3],
filerAddr: fmt.Sprintf("127.0.0.1:%d", ports[4]),
filerGrpc: ports[5],
nfsAddr: fmt.Sprintf("127.0.0.1:%d", ports[6]),
exportRoot: exportRoot,
weedBinary: findWeedBinary(),
isSetup: false,
skipCleanup: config.SkipCleanup,
}
}
// Setup starts the SeaweedFS cluster and the NFS frontend, waiting for each
// component to accept connections before moving on.
func (f *NfsTestFramework) Setup(config *TestConfig) error {
if f.isSetup {
return fmt.Errorf("framework already setup")
}
dirs := []string{
f.dataDir,
filepath.Join(f.dataDir, "master"),
filepath.Join(f.dataDir, "volume"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", dir, err)
}
}
if err := f.startMaster(config); err != nil {
return fmt.Errorf("failed to start master: %v", err)
}
if !testutil.WaitForPort(portFromAddr(f.masterAddr), testutil.SeaweedMiniStartupTimeout) {
return fmt.Errorf("master not ready at %s", f.masterAddr)
}
if err := f.startVolumeServer(config); err != nil {
return fmt.Errorf("failed to start volume server: %v", err)
}
if !testutil.WaitForPort(portFromAddr(f.volumeAddr), testutil.SeaweedMiniStartupTimeout) {
return fmt.Errorf("volume server not ready at %s", f.volumeAddr)
}
if err := f.startFiler(config); err != nil {
return fmt.Errorf("failed to start filer: %v", err)
}
if !testutil.WaitForPort(portFromAddr(f.filerAddr), testutil.SeaweedMiniStartupTimeout) {
return fmt.Errorf("filer not ready at %s", f.filerAddr)
}
// Pre-create the export root in the filer's namespace. The NFS server
// expects its export directory to exist with a real inode; uploading a
// placeholder file creates the parent directory implicitly and then
// removing the file leaves the empty directory in place.
if f.exportRoot != "/" {
if err := f.ensureExportRootExists(); err != nil {
return fmt.Errorf("failed to pre-create export root %s: %v", f.exportRoot, err)
}
}
if err := f.startNfsServer(config); err != nil {
return fmt.Errorf("failed to start NFS server: %v", err)
}
if !testutil.WaitForPort(portFromAddr(f.nfsAddr), testutil.SeaweedMiniStartupTimeout) {
return fmt.Errorf("NFS server not ready at %s", f.nfsAddr)
}
// Let the NFS server finish wiring up its gRPC subscription to the filer
// before the first client call hits MOUNT/LOOKUP.
time.Sleep(500 * time.Millisecond)
f.isSetup = true
return nil
}
// Cleanup stops all processes. Temp state is preserved if SkipCleanup is set.
func (f *NfsTestFramework) Cleanup() {
processes := []*os.Process{f.nfsProcess, f.filerProcess, f.volumeProcess, f.masterProcess}
for _, proc := range processes {
if proc != nil {
_ = proc.Signal(syscall.SIGTERM)
_, _ = proc.Wait()
}
}
if !f.skipCleanup {
_ = os.RemoveAll(f.tempDir)
}
}
// NfsAddr returns the TCP address the NFS server is listening on.
func (f *NfsTestFramework) NfsAddr() string { return f.nfsAddr }
// FilerAddr returns the TCP address of the filer.
func (f *NfsTestFramework) FilerAddr() string { return f.filerAddr }
// ExportRoot returns the path the NFS server exports.
func (f *NfsTestFramework) ExportRoot() string { return f.exportRoot }
// Mount opens an NFSv3 MOUNT+NFS connection against the running NFS server
// and returns a Target that tests can drive like a mini-VFS. Caller is
// responsible for calling the returned cleanup func to Unmount and close the
// TCP connection.
func (f *NfsTestFramework) Mount() (*nfsclient.Target, func(), error) {
var (
client *rpc.Client
err error
)
// The NFS server's TCP listener may already be accepting connections when
// waitForService returns, but the RPC program registration can trail it
// by a few milliseconds. Retry the dial to absorb that small window.
for attempt := 0; attempt < 20; attempt++ {
client, err = rpc.DialTCP("tcp", f.nfsAddr, false)
if err == nil {
break
}
time.Sleep(25 * time.Millisecond)
}
if err != nil {
return nil, nil, fmt.Errorf("dial NFS: %w", err)
}
// Note: do not set Mount.Addr here. When Addr is non-empty, the go-nfs
// client re-dials via portmapper and concatenates `:111` onto the
// address, which produces "too many colons" for a raw `host:port`
// string. Reusing the existing RPC client avoids that path entirely.
mounter := &nfsclient.Mount{Client: client}
target, err := mounter.Mount(f.exportRoot, rpc.AuthNull)
if err != nil {
client.Close()
return nil, nil, fmt.Errorf("mount %s: %w", f.exportRoot, err)
}
cleanup := func() {
_ = mounter.Unmount()
client.Close()
}
return target, cleanup, nil
}
func (f *NfsTestFramework) startMaster(config *TestConfig) error {
_, masterPort := splitHostPort(f.masterAddr)
args := []string{
"master",
"-ip=127.0.0.1",
fmt.Sprintf("-port=%d", masterPort),
fmt.Sprintf("-port.grpc=%d", f.masterGrpc),
"-mdir=" + filepath.Join(f.dataDir, "master"),
"-raftBootstrap",
"-peers=none",
}
return f.startProcess(&f.masterProcess, config, args)
}
func (f *NfsTestFramework) startVolumeServer(config *TestConfig) error {
_, volumePort := splitHostPort(f.volumeAddr)
// pb.ServerAddress encodes a non-default gRPC port as `host:port.grpc`.
// See weed/pb/server_address.go — the dot, not a colon, is the separator
// between the HTTP port and the gRPC port.
masterWithGrpc := fmt.Sprintf("%s.%d", f.masterAddr, f.masterGrpc)
args := []string{
"volume",
"-master=" + masterWithGrpc,
"-ip=127.0.0.1",
fmt.Sprintf("-port=%d", volumePort),
fmt.Sprintf("-port.grpc=%d", f.volumeGrpc),
"-dir=" + filepath.Join(f.dataDir, "volume"),
fmt.Sprintf("-max=%d", config.NumVolumes),
}
return f.startProcess(&f.volumeProcess, config, args)
}
func (f *NfsTestFramework) startFiler(config *TestConfig) error {
_, filerPort := splitHostPort(f.filerAddr)
masterWithGrpc := fmt.Sprintf("%s.%d", f.masterAddr, f.masterGrpc)
args := []string{
"filer",
"-master=" + masterWithGrpc,
"-ip=127.0.0.1",
fmt.Sprintf("-port=%d", filerPort),
fmt.Sprintf("-port.grpc=%d", f.filerGrpc),
}
return f.startProcess(&f.filerProcess, config, args)
}
func (f *NfsTestFramework) startNfsServer(config *TestConfig) error {
_, nfsPort := splitHostPort(f.nfsAddr)
// `host:port.grpc` encoding — see pb/server_address.go.
filerWithGrpc := fmt.Sprintf("%s.%d", f.filerAddr, f.filerGrpc)
args := []string{
"nfs",
"-filer=" + filerWithGrpc,
"-ip.bind=127.0.0.1",
fmt.Sprintf("-port=%d", nfsPort),
"-filer.path=" + f.exportRoot,
}
return f.startProcess(&f.nfsProcess, config, args)
}
func (f *NfsTestFramework) startProcess(target **os.Process, config *TestConfig, args []string) error {
cmd := exec.Command(f.weedBinary, args...)
cmd.Dir = f.tempDir
if config.EnableDebug {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
if err := cmd.Start(); err != nil {
return err
}
*target = cmd.Process
return nil
}
// portFromAddr returns just the port number from a `host:port` string.
// testutil.WaitForPort takes an int port, not a full address.
func portFromAddr(addr string) int {
_, port := splitHostPort(addr)
return port
}
// ensureExportRootExists posts a placeholder file to f.exportRoot via the
// filer's HTTP API, then deletes it. That roundtrip implicitly creates the
// target directory so the NFS server has something to mount. We bypass
// weed/pb here because the HTTP client is simpler and needs no gRPC stubs.
func (f *NfsTestFramework) ensureExportRootExists() error {
exportRoot := strings.TrimRight(f.exportRoot, "/")
if exportRoot == "" {
return nil
}
placeholder := exportRoot + "/.nfs_test_init"
filerURL := "http://" + f.filerAddr + placeholder
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", ".nfs_test_init")
if err != nil {
return err
}
if _, err := io.WriteString(part, ""); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodPost, filerURL, &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := httpClient.Do(req)
if err != nil {
return err
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("filer POST %s returned status %d", filerURL, resp.StatusCode)
}
// Delete the placeholder; the directory stays behind.
deleteReq, err := http.NewRequest(http.MethodDelete, filerURL, nil)
if err != nil {
return err
}
deleteResp, err := httpClient.Do(deleteReq)
if err != nil {
return err
}
_, _ = io.Copy(io.Discard, deleteResp.Body)
deleteResp.Body.Close()
if deleteResp.StatusCode/100 != 2 && deleteResp.StatusCode != http.StatusNotFound {
return fmt.Errorf("filer DELETE %s returned status %d", filerURL, deleteResp.StatusCode)
}
return nil
}
func splitHostPort(addr string) (string, int) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return "", 0
}
var port int
_, _ = fmt.Sscanf(portStr, "%d", &port)
return host, port
}
// findWeedBinary locates the weed binary, preferring the local build in the
// checkout so tests run against the code under review rather than whatever is
// on $PATH.
func findWeedBinary() string {
if _, thisFile, _, ok := runtime.Caller(0); ok {
thisDir := filepath.Dir(thisFile)
candidates := []string{
filepath.Join(thisDir, "../../weed/weed"),
filepath.Join(thisDir, "../weed/weed"),
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
abs, _ := filepath.Abs(candidate)
return abs
}
}
}
cwd, _ := os.Getwd()
candidates := []string{
filepath.Join(cwd, "../../weed/weed"),
filepath.Join(cwd, "../weed/weed"),
filepath.Join(cwd, "./weed"),
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
abs, _ := filepath.Abs(candidate)
return abs
}
}
if path, err := exec.LookPath("weed"); err == nil {
return path
}
return "weed"
}
-21
View File
@@ -1,21 +0,0 @@
module seaweedfs-nfs-tests
go 1.25.0
// test/testutil lives inside the main seaweedfs module; pull it in via a
// local replace so this integration suite can reuse the shared port
// allocator and readiness helpers instead of reinventing them.
replace github.com/seaweedfs/seaweedfs => ../..
require (
github.com/seaweedfs/seaweedfs v0.0.0-00010101000000-000000000000
github.com/stretchr/testify v1.11.1
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-14
View File
@@ -1,14 +0,0 @@
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 h1:UVArwN/wkKjMVhh2EQGC0tEc1+FqiLlvYXY5mQ2f8Wg=
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93/go.mod h1:Nfe4efndBz4TibWycNE+lqyJZiMX4ycx+QKV8Ta0f/o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886 h1:DtrBtkgTJk2XGt4T7eKdKVkd9A5NCevN2e4inLXtsqA=
github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886/go.mod h1:Tq++Lr/FgiS3X48q5FETemXiSLGuYMQT2sPjYNPJSwA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-193
View File
@@ -1,193 +0,0 @@
//go:build linux
package nfs
// End-to-end mount tests that drive the real Linux NFS client (mount.nfs +
// in-tree kernel) against a running `weed nfs` subprocess. These exist to
// catch regressions that the existing framework can't see, because the
// framework drives the server with willscott/go-nfs-client — the same RPC
// library the server uses internally — so any bug shared between the two
// (XDR layout, version dispatch, RPC framing) round-trips invisibly.
//
// Two real bugs hit recently were exactly that shape:
// 1. NFSv4 mis-routed to the v3 SETATTR handler (#9262). The client
// library never sends NFSv4, so the test suite never noticed; the
// Linux kernel mount path did notice, with EIO.
// 2. UDP MOUNT v3 missing. Only TCP MOUNT was advertised; the kernel
// defaults mountproto=udp in many setups, so the in-tree client
// surfaced EPROTONOSUPPORT during MOUNT setup.
//
// These tests mount over the actual loopback interface using mount.nfs and
// shell out to /bin/mount and /bin/umount. They require root (mount(2) is
// privileged) and Linux (the in-tree NFS client is what's being exercised);
// they t.Skip cleanly when either prerequisite is missing.
//
// Run locally with:
//
// cd test/nfs
// sudo go test -v -run TestKernelMount ./...
//
// CI runs them via .github/workflows/nfs-tests.yml after installing
// nfs-common (mount.nfs + helpers).
import (
"errors"
"fmt"
"net"
"os"
"os/exec"
"strings"
"testing"
)
// kernelMountSkipIfUnsupported skips the test when the host can't run a
// real NFS mount. The combined check belongs in one place so the three
// kernel-mount tests stay focused on what they're actually verifying.
func kernelMountSkipIfUnsupported(t *testing.T) {
t.Helper()
if os.Geteuid() != 0 {
t.Skip("kernel mount test requires root; mount(2) is privileged")
}
if _, err := exec.LookPath("mount.nfs"); err != nil {
t.Skipf("mount.nfs not installed: %v (CI installs the nfs-common package)", err)
}
}
// kernelMount runs /bin/mount with the given options against the framework's
// running NFS server, returns the mountpoint and an unmount closure. We pass
// explicit port=/mountport= options so the kernel never queries portmap.
// That keeps the harness honest about what it's testing — the NFS / MOUNT
// wire protocol — and avoids colliding with a system rpcbind on shared CI
// runners (port 111 is privileged and frequently in use already).
func kernelMount(t *testing.T, fw *NfsTestFramework, optsTemplate string) (string, func()) {
t.Helper()
host, portStr, err := net.SplitHostPort(fw.NfsAddr())
if err != nil {
t.Fatalf("split nfs addr %q: %v", fw.NfsAddr(), err)
}
mountpoint, err := os.MkdirTemp("", "weed-nfs-kmount-")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
opts := strings.ReplaceAll(optsTemplate, "{port}", portStr)
target := fmt.Sprintf("%s:%s", host, fw.ExportRoot())
cmd := exec.Command("mount", "-t", "nfs", "-o", opts, target, mountpoint)
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.RemoveAll(mountpoint)
t.Fatalf("mount %s -o %s failed: %v\nmount output:\n%s", target, opts, err, out)
}
teardown := func() {
// -f to bail out faster if the server's already gone.
_ = exec.Command("umount", "-f", mountpoint).Run()
_ = os.RemoveAll(mountpoint)
}
return mountpoint, teardown
}
func newKernelMountFramework(t *testing.T) *NfsTestFramework {
t.Helper()
cfg := DefaultTestConfig()
fw := NewNfsTestFramework(t, cfg)
if err := fw.Setup(cfg); err != nil {
fw.Cleanup()
t.Fatalf("framework setup: %v", err)
}
t.Cleanup(fw.Cleanup)
return fw
}
// TestKernelMountV3TCP exercises the most common mount form: NFSv3 + MOUNT
// v3, both over TCP. This is what the existing go-nfs-client tests cover at
// the protocol layer, but running it through mount.nfs and the kernel
// confirms that the wire format we emit decodes cleanly under a different
// XDR/RPC parser.
func TestKernelMountV3TCP(t *testing.T) {
kernelMountSkipIfUnsupported(t)
fw := newKernelMountFramework(t)
mountpoint, undo := kernelMount(t, fw,
"nfsvers=3,nolock,port={port},mountport={port},proto=tcp,mountproto=tcp")
defer undo()
if _, err := os.Stat(mountpoint); err != nil {
t.Errorf("stat mountpoint: %v", err)
}
if _, err := os.ReadDir(mountpoint); err != nil {
t.Errorf("readdir mountpoint: %v", err)
}
}
// TestKernelMountV3MountProtoUDP is the regression test for the UDP MOUNT
// v3 responder. mountproto=udp forces the kernel to call MOUNT over UDP
// only; before the responder existed the kernel hit nothing (MOUNT was
// advertised TCP-only) and surfaced EPROTONOSUPPORT during mount setup.
func TestKernelMountV3MountProtoUDP(t *testing.T) {
kernelMountSkipIfUnsupported(t)
fw := newKernelMountFramework(t)
mountpoint, undo := kernelMount(t, fw,
"nfsvers=3,nolock,port={port},mountport={port},proto=tcp,mountproto=udp")
defer undo()
if _, err := os.Stat(mountpoint); err != nil {
t.Errorf("stat mountpoint: %v", err)
}
}
// TestKernelMountV4RejectsCleanly is the regression test for the NFSv4
// PROG_MISMATCH path (#9262). The server only speaks NFSv3, but the
// previous behaviour was to mis-route v4 COMPOUND to the v3 SETATTR
// handler and write garbage; the kernel surfaced EIO instead of a
// version-mismatch error and (depending on distro) didn't fall back to
// v3. The version filter now answers PROG_MISMATCH so the kernel sees
// "v4 not supported" cleanly.
//
// The test asserts:
// 1. mount.nfs exits non-zero (no silent success against a v3 server);
// 2. the failure message mentions protocol/version/io, which is what the
// kernel surfaces when it gets PROG_MISMATCH instead of garbage. A
// pre-fix server returns "mount system call failed" with no further
// context, so a regression collapses the assertion onto that branch.
func TestKernelMountV4RejectsCleanly(t *testing.T) {
kernelMountSkipIfUnsupported(t)
fw := newKernelMountFramework(t)
host, portStr, err := net.SplitHostPort(fw.NfsAddr())
if err != nil {
t.Fatalf("split nfs addr: %v", err)
}
mountpoint, err := os.MkdirTemp("", "weed-nfs-kmount-v4-")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
defer os.RemoveAll(mountpoint)
target := fmt.Sprintf("%s:%s", host, fw.ExportRoot())
cmd := exec.Command("mount", "-t", "nfs", "-o",
fmt.Sprintf("vers=4,port=%s", portStr),
target, mountpoint)
out, err := cmd.CombinedOutput()
defer exec.Command("umount", "-f", mountpoint).Run()
if err == nil {
t.Fatalf("v4 mount unexpectedly succeeded against v3-only server\nmount output:\n%s", out)
}
// Don't pin the exact error string — different distros print slightly
// different things — but require some hint that the kernel saw a
// protocol-level failure rather than a generic "mount system call
// failed". Without the version filter, mount.nfs prints the latter
// alone; with it, the former.
lower := strings.ToLower(string(out))
if !strings.Contains(lower, "protocol") &&
!strings.Contains(lower, "version") &&
!strings.Contains(lower, "i/o") {
t.Errorf("v4 mount failure didn't mention protocol/version/io; output:\n%s", out)
}
// Also require a non-zero exit so a future change that makes mount(2)
// silently succeed (e.g. by relaxing the version filter) shows up
// here even if the message phrasing changes.
var ee *exec.ExitError
if !errors.As(err, &ee) {
t.Errorf("expected mount to exit non-zero with ExitError, got %v", err)
}
}
+29 -18
View File
@@ -30,21 +30,22 @@ type VolumeServer struct {
address string
baseDir string
mu sync.Mutex
receivedFiles map[string]uint64
mountRequests []*volume_server_pb.VolumeEcShardsMountRequest
deleteRequests []*volume_server_pb.VolumeDeleteRequest
markReadonlyCalls int
markWritableCalls int
readFileStatusCalls int
vacuumGarbageRatio float64
vacuumCheckCalls int
vacuumCompactCalls int
vacuumCommitCalls int
vacuumCleanupCalls int
volumeCopyCalls int
volumeMountCalls int
tailReceiverCalls int
mu sync.Mutex
receivedFiles map[string]uint64
mountRequests []*volume_server_pb.VolumeEcShardsMountRequest
deleteRequests []*volume_server_pb.VolumeDeleteRequest
markReadonlyCalls int
markWritableCalls int
readFileStatusCalls int
vacuumGarbageRatio float64
vacuumCommitReadOnly bool
vacuumCheckCalls int
vacuumCompactCalls int
vacuumCommitCalls int
vacuumCleanupCalls int
volumeCopyCalls int
volumeMountCalls int
tailReceiverCalls int
}
// NewVolumeServer starts a test volume server using the provided base directory.
@@ -115,6 +116,13 @@ func (v *VolumeServer) SetVacuumGarbageRatio(ratio float64) {
v.vacuumGarbageRatio = ratio
}
// SetVacuumCommitReadOnly sets the IsReadOnly value returned by VacuumVolumeCommit.
func (v *VolumeServer) SetVacuumCommitReadOnly(readOnly bool) {
v.mu.Lock()
defer v.mu.Unlock()
v.vacuumCommitReadOnly = readOnly
}
// VacuumStats returns the vacuum RPC call counts.
func (v *VolumeServer) VacuumStats() (check, compact, commit, cleanup int) {
v.mu.Lock()
@@ -301,9 +309,11 @@ func (v *VolumeServer) VolumeEcShardsUnmount(ctx context.Context, req *volume_se
// VolumeEcShardsDelete is a no-op stub paired with VolumeEcShardsUnmount
// above; the fake server doesn't persist shard files beyond what
// ReceiveFile wrote, so there's nothing to remove.
// ReceiveFile wrote, so there's nothing to remove. It still echoes the
// full_teardown acknowledgement so the worker doesn't treat the fake as a
// pre-upgrade server that silently skipped the teardown.
func (v *VolumeServer) VolumeEcShardsDelete(ctx context.Context, req *volume_server_pb.VolumeEcShardsDeleteRequest) (*volume_server_pb.VolumeEcShardsDeleteResponse, error) {
return &volume_server_pb.VolumeEcShardsDeleteResponse{}, nil
return &volume_server_pb.VolumeEcShardsDeleteResponse{FullTeardownDone: req.FullTeardown}, nil
}
func (v *VolumeServer) VolumeEcShardsInfo(ctx context.Context, req *volume_server_pb.VolumeEcShardsInfoRequest) (*volume_server_pb.VolumeEcShardsInfoResponse, error) {
@@ -428,8 +438,9 @@ func (v *VolumeServer) VacuumVolumeCompact(req *volume_server_pb.VacuumVolumeCom
func (v *VolumeServer) VacuumVolumeCommit(ctx context.Context, req *volume_server_pb.VacuumVolumeCommitRequest) (*volume_server_pb.VacuumVolumeCommitResponse, error) {
v.mu.Lock()
v.vacuumCommitCalls++
readOnly := v.vacuumCommitReadOnly
v.mu.Unlock()
return &volume_server_pb.VacuumVolumeCommitResponse{}, nil
return &volume_server_pb.VacuumVolumeCommitResponse{IsReadOnly: readOnly}, nil
}
func (v *VolumeServer) VacuumVolumeCleanup(ctx context.Context, req *volume_server_pb.VacuumVolumeCleanupRequest) (*volume_server_pb.VacuumVolumeCleanupResponse, error) {
+63 -18
View File
@@ -15,6 +15,24 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
func vacuumJobSpec(volumeID uint32, server string) *plugin_pb.JobSpec {
return &plugin_pb.JobSpec{
JobId: fmt.Sprintf("vacuum-job-%d", volumeID),
JobType: "vacuum",
Parameters: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(volumeID)},
},
"server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: server},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "vac-test"},
},
},
}
}
func TestVacuumExecutionIntegration(t *testing.T) {
volumeID := uint32(202)
@@ -31,26 +49,10 @@ func TestVacuumExecutionIntegration(t *testing.T) {
source := pluginworkers.NewVolumeServer(t, "")
source.SetVacuumGarbageRatio(0.6)
job := &plugin_pb.JobSpec{
JobId: fmt.Sprintf("vacuum-job-%d", volumeID),
JobType: "vacuum",
Parameters: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(volumeID)},
},
"server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: source.Address()},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "vac-test"},
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := harness.Plugin().ExecuteJob(ctx, job, nil, 1)
result, err := harness.Plugin().ExecuteJob(ctx, vacuumJobSpec(volumeID, source.Address()), nil, 1)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.Success)
@@ -59,5 +61,48 @@ func TestVacuumExecutionIntegration(t *testing.T) {
require.GreaterOrEqual(t, checkCalls, 2)
require.GreaterOrEqual(t, compactCalls, 1)
require.GreaterOrEqual(t, commitCalls, 1)
require.GreaterOrEqual(t, cleanupCalls, 1)
// Cleanup is only invoked when Phase 1 (Compact) fails to roll back
// the .cpd/.cpx/.cpldb temp files; on the success path Commit
// consumes them (rename .cpd → .dat, .cpx → .idx, .cpldb → .ldb via
// the leveldb needle map) so no Cleanup call is needed. Matches
// topology.vacuumOneVolumeId which only calls batchVacuumVolumeCleanup
// on the Compact-failure branch.
require.Equal(t, 0, cleanupCalls)
// Phase 3 marks each replica writable so master returns it to the
// writables layout. See upstream seaweedfs#9685.
require.GreaterOrEqual(t, source.MarkWritableCount(), 1)
}
// A replica that commits still read-only (operator-set, EIO-quarantined,
// disk-space-low) must not be force-marked writable: master built-in vacuum
// skips it via SetVolumeAvailable, and it recovers on its own ReadOnly=false
// heartbeat.
func TestVacuumExecutionSkipsMarkWritableWhenReadOnly(t *testing.T) {
volumeID := uint32(203)
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handler := vacuum.NewVacuumHandler(dialOption, 1)
harness := pluginworkers.NewHarness(t, pluginworkers.HarnessConfig{
WorkerOptions: pluginworker.WorkerOptions{
GrpcDialOption: dialOption,
},
Handlers: []pluginworker.JobHandler{handler},
})
harness.WaitForJobType("vacuum")
source := pluginworkers.NewVolumeServer(t, "")
source.SetVacuumGarbageRatio(0.6)
source.SetVacuumCommitReadOnly(true)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := harness.Plugin().ExecuteJob(ctx, vacuumJobSpec(volumeID, source.Address()), nil, 1)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.Success)
_, _, commitCalls, _ := source.VacuumStats()
require.GreaterOrEqual(t, commitCalls, 1)
require.Equal(t, 0, source.MarkWritableCount())
}
+10 -8
View File
@@ -1,21 +1,23 @@
# the tests only support python 3.6, not newer
#FROM ubuntu:latest
FROM python:3.6.15-slim-buster
FROM python:3.6.15-slim-bullseye
# Installed required system deps
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get install -y git-core sudo tzdata
&& DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get install -y -qq git-core sudo tzdata ca-certificates libxml2-dev libxslt1-dev zlib1g-dev python3-lxml gcc python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Install python deps
RUN pip install virtualenv
# Clone Ceph S3 tests
RUN git clone https://github.com/ceph/s3-tests.git
COPY fix_s3_tests_bucket_conflicts.py /tmp/fix_s3_tests_bucket_conflicts.py
ENV S3_TESTS_PATH=s3-tests
RUN git clone https://github.com/ceph/s3-tests.git \
&& python3 /tmp/fix_s3_tests_bucket_conflicts.py
WORKDIR s3-tests
# Pin to a certain commit on ceph/s3-tests
# https://github.com/ceph/s3-tests/commit/9a6a1e9f197fc9fb031b809d1e057635c2ff8d4e
RUN git checkout 9a6a1e9f197fc9fb031b809d1e057635c2ff8d4e
RUN ./bootstrap
RUN pip install -r requirements.txt \
&& pip install tox \
&& pip install -e .
+13 -2
View File
@@ -210,10 +210,21 @@ func TestS3PolicyVariablesUsernameIsolation(t *testing.T) {
"Sid": "DenyOtherFolders",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
"Action": ["s3:GetObject", "s3:PutObject"],
"NotResource": "arn:aws:s3:::%s/${aws:username}/*"
}, {
"Sid": "DenyListOtherPrefixes",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::%s",
"Condition": {
"StringNotLike": {
"s3:prefix": ["${aws:username}/*", "${aws:username}"]
}
}
}]
}`, bucketName, bucketName, bucketName)
}`, bucketName, bucketName, bucketName, bucketName)
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
Bucket: aws.String(bucketName),
+107
View File
@@ -0,0 +1,107 @@
{
"realm": "seaweedfs-test",
"enabled": true,
"roles": {
"realm": [
{ "name": "s3-admin" },
{ "name": "s3-read-only" },
{ "name": "s3-write-only" },
{ "name": "s3-read-write" }
]
},
"clients": [
{
"clientId": "seaweedfs-s3",
"enabled": true,
"secret": "seaweedfs-s3-secret",
"publicClient": false,
"serviceAccountsEnabled": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"fullScopeAllowed": true,
"redirectUris": ["*"],
"webOrigins": ["*"],
"protocol": "openid-connect",
"protocolMappers": [
{
"name": "realm-roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": {
"claim.name": "roles",
"jsonType.label": "String",
"multivalued": "true",
"usermodel.realmRoleMapping.rolePrefix": "",
"access.token.claim": "true",
"id.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "audience-mapper",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.client.audience": "seaweedfs-s3",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "admin-user",
"enabled": true,
"email": "admin-user@test.com",
"firstName": "Test",
"lastName": "User",
"emailVerified": true,
"requiredActions": [],
"realmRoles": ["s3-admin"],
"credentials": [
{ "type": "password", "value": "adminuser123", "temporary": false }
]
},
{
"username": "read-user",
"enabled": true,
"email": "read-user@test.com",
"firstName": "Test",
"lastName": "User",
"emailVerified": true,
"requiredActions": [],
"realmRoles": ["s3-read-only"],
"credentials": [
{ "type": "password", "value": "readuser123", "temporary": false }
]
},
{
"username": "write-user",
"enabled": true,
"email": "write-user@test.com",
"firstName": "Test",
"lastName": "User",
"emailVerified": true,
"requiredActions": [],
"realmRoles": ["s3-read-write"],
"credentials": [
{ "type": "password", "value": "writeuser123", "temporary": false }
]
},
{
"username": "write-only-user",
"enabled": true,
"email": "write-only-user@test.com",
"firstName": "Test",
"lastName": "User",
"emailVerified": true,
"requiredActions": [],
"realmRoles": ["s3-write-only"],
"credentials": [
{ "type": "password", "value": "writeonlyuser123", "temporary": false }
]
}
]
}
+5 -1
View File
@@ -10,7 +10,11 @@ MASTER_PORT := 9333
VOLUME_PORT := 8080
FILER_PORT := 8888
TEST_TIMEOUT := 10m
TEST_PATTERN := TestVersioning
# Run every test in the suite by default. "TestVersioning" used to leave whole
# tests (bucket creation, suspended delete, directory/version listing) ungated.
# Opt-in stress tests are matched by "." but self-skip without ENABLE_STRESS_TESTS
# (they run with it set in their own targets), so they don't actually execute here.
TEST_PATTERN := .
.DEFAULT_GOAL := help
+28 -17
View File
@@ -36,7 +36,7 @@ func TestBucketCreationBehavior(t *testing.T) {
expectedError: "",
},
{
name: "Create existing bucket with same owner - should return BucketAlreadyExists",
name: "Create existing bucket with same owner - should return BucketAlreadyOwnedByYou",
setupFunc: func(t *testing.T, bucketName string) {
// Create bucket first
_, err := client.CreateBucket(ctx, &s3.CreateBucketInput{
@@ -46,8 +46,8 @@ func TestBucketCreationBehavior(t *testing.T) {
},
bucketName: "test-same-owner-same-settings-" + fmt.Sprintf("%d", time.Now().Unix()),
objectLockEnabled: nil,
expectedStatusCode: 409, // SeaweedFS now returns BucketAlreadyExists in all cases
expectedError: "BucketAlreadyExists",
expectedStatusCode: 409, // idempotent recreate by the same owner
expectedError: "BucketAlreadyOwnedByYou",
},
{
name: "Create bucket with same owner but different Object Lock settings - should fail",
@@ -71,7 +71,7 @@ func TestBucketCreationBehavior(t *testing.T) {
expectedError: "",
},
{
name: "Create bucket with Object Lock enabled twice - should fail",
name: "Create bucket with Object Lock enabled twice - should return BucketAlreadyOwnedByYou",
setupFunc: func(t *testing.T, bucketName string) {
// Create bucket with Object Lock first
_, err := client.CreateBucket(ctx, &s3.CreateBucketInput{
@@ -83,7 +83,7 @@ func TestBucketCreationBehavior(t *testing.T) {
bucketName: "test-object-lock-duplicate-" + fmt.Sprintf("%d", time.Now().Unix()),
objectLockEnabled: aws.Bool(true),
expectedStatusCode: 409,
expectedError: "BucketAlreadyExists",
expectedError: "BucketAlreadyOwnedByYou",
},
}
@@ -130,16 +130,27 @@ func TestBucketCreationBehavior(t *testing.T) {
}
}
// TestBucketCreationWithDifferentUsers tests bucket creation with different identity contexts
// TestBucketCreationWithDifferentUsers tests bucket creation across owners: a
// name taken by one identity is reported as BucketAlreadyExists to another
// (versus BucketAlreadyOwnedByYou for the owner re-creating it).
func TestBucketCreationWithDifferentUsers(t *testing.T) {
// This test would require setting up different S3 credentials/identities
// For now, we'll skip this as it requires more complex setup
t.Skip("Different user testing requires IAM setup - implement when IAM is configured")
ctx := context.Background()
// Tracked prefix + run id so cleanupAllTestBuckets sweeps it if the test leaks.
bucketName := getNewBucketName()
// TODO: Implement when we have proper IAM/user management in test setup
// Should test:
// 1. User A creates bucket
// 2. User B tries to create same bucket -> should fail with BucketAlreadyExists
// User A owns the bucket.
clientA := getS3Client(t)
_, err := clientA.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucketName)})
require.NoError(t, err, "User A should create the bucket")
defer cleanupBucketForCreationTest(t, clientA, bucketName)
// User B is a different identity (s3_tests_alt in the test S3 config).
clientB := getS3ClientWithCredentials(t,
"NOPQRSTUVWXYZABCDEFG", "nopqrstuvwxyzabcdefghijklmnabcdefghijklm")
_, err = clientB.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucketName)})
require.Error(t, err, "User B should not be able to create a bucket owned by User A")
assert.Contains(t, err.Error(), "BucketAlreadyExists",
"Different-owner recreate should return BucketAlreadyExists, got: %v", err)
}
// TestBucketCreationVersioningInteraction tests interaction between bucket creation and versioning
@@ -171,8 +182,8 @@ func TestBucketCreationVersioningInteraction(t *testing.T) {
ObjectLockEnabledForBucket: aws.Bool(true),
})
assert.Error(t, err, "Expected second bucket creation to fail")
assert.Contains(t, err.Error(), "BucketAlreadyExists",
"Expected BucketAlreadyExists error, got: %v", err)
assert.Contains(t, err.Error(), "BucketAlreadyOwnedByYou",
"Expected BucketAlreadyOwnedByYou error, got: %v", err)
}
// TestBucketCreationErrorMessages tests that proper error messages are returned
@@ -197,8 +208,8 @@ func TestBucketCreationErrorMessages(t *testing.T) {
require.Error(t, err, "Expected bucket creation to fail")
// Check that it's the right type of error
assert.Contains(t, err.Error(), "BucketAlreadyExists",
"Expected BucketAlreadyExists error, got: %v", err)
assert.Contains(t, err.Error(), "BucketAlreadyOwnedByYou",
"Expected BucketAlreadyOwnedByYou error, got: %v", err)
}
// cleanupBucketForCreationTest removes a bucket and all its contents
@@ -122,7 +122,7 @@ func TestListObjectVersionsIncludesDirectories(t *testing.T) {
assert.Equal(t, int64(0), *version.Size, "Directory %s should have size 0", expectedDir)
assert.True(t, *version.IsLatest, "Directory %s should be marked as latest", expectedDir)
assert.Equal(t, "\"d41d8cd98f00b204e9800998ecf8427e\"", *version.ETag, "Directory %s should have MD5 of empty string as ETag", expectedDir)
assert.Equal(t, types.ObjectStorageClassStandard, version.StorageClass, "Directory %s should have STANDARD storage class", expectedDir)
assert.Equal(t, types.ObjectVersionStorageClassStandard, version.StorageClass, "Directory %s should have STANDARD storage class", expectedDir)
break
}
}
@@ -159,6 +159,85 @@ func TestListObjectVersionsIncludesDirectories(t *testing.T) {
assert.Equal(t, len(testFiles), fileCount, "Should find exactly %d files", len(testFiles))
}
// TestListObjectVersionsDeepPrefixExcludesAncestorDirectories verifies that a
// prefix+delimiter version listing does not leak ancestor directory markers that
// the listing only descends through to reach the prefix. Veeam's immutable
// backup repository (versioning + object lock) issues exactly this request from
// Cloud.FindLastCheckpointId and aborts when it sees an unexpected key like
// "Veeam/" that does not match the deep prefix it asked for. The version listing
// must match ListObjectsV2 / AWS: only keys at or under the prefix appear.
func TestListObjectVersionsDeepPrefixExcludesAncestorDirectories(t *testing.T) {
bucketName := "test-versioning-deep-prefix"
client := setupS3Client(t)
_, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
})
require.NoError(t, err)
defer cleanupBucket(t, client, bucketName)
_, err = client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{
Bucket: aws.String(bucketName),
VersioningConfiguration: &types.VersioningConfiguration{
Status: types.BucketVersioningStatusEnabled,
},
})
require.NoError(t, err)
// Explicit directory markers for every parent path, as Veeam creates them.
ancestorMarkers := []string{
"Veeam/",
"Veeam/Backup/",
"Veeam/Backup/job1/",
"Veeam/Backup/job1/Clients/",
"Veeam/Backup/job1/Clients/aaaa/",
"Veeam/Backup/job1/Clients/aaaa/bbbb/",
}
prefix := "Veeam/Backup/job1/Clients/aaaa/bbbb/Metadata/"
checkpointKey := prefix + "Checkpoint"
for _, dirKey := range append(ancestorMarkers, prefix) {
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(dirKey),
Body: strings.NewReader(""),
})
require.NoError(t, err, "Failed to create directory marker %s", dirKey)
}
_, err = client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(checkpointKey),
Body: strings.NewReader("checkpoint"),
})
require.NoError(t, err)
listResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
Bucket: aws.String(bucketName),
Prefix: aws.String(prefix),
Delimiter: aws.String("/"),
})
require.NoError(t, err)
var gotKeys []string
for _, v := range listResp.Versions {
gotKeys = append(gotKeys, *v.Key)
assert.True(t, strings.HasPrefix(*v.Key, prefix),
"version key %q must start with the requested prefix %q", *v.Key, prefix)
}
// Only the prefix's own marker and the real object at/under it may appear.
assert.ElementsMatch(t, []string{prefix, checkpointKey}, gotKeys,
"deep-prefix version listing must not include ancestor directory markers")
// None of the ancestor markers ("Veeam/", ...) may surface as versions.
for _, marker := range ancestorMarkers {
assert.NotContains(t, gotKeys, marker,
"ancestor directory marker %q must not appear in a deep-prefix version listing", marker)
}
}
// TestListObjectVersionsDeleteMarkers tests that delete markers are properly separated from versions
// This test verifies the fix for the issue where delete markers were incorrectly categorized as versions
func TestListObjectVersionsDeleteMarkers(t *testing.T) {
@@ -537,13 +616,18 @@ func TestSuspendedVersioningDeleteBehavior(t *testing.T) {
})
require.NoError(t, err)
// Verify the null version was actually deleted (not a delete marker created)
// Verify the null content version is gone and the delete marker stands in.
listResp, err = client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
Bucket: aws.String(bucketName),
})
require.NoError(t, err)
assert.Len(t, listResp.Versions, 3, "Should be back to 3 versions after deleting null version")
assert.Empty(t, listResp.DeleteMarkers, "Should have no delete markers during suspended versioning delete")
// A suspended-versioning DELETE removes the null version and records a single
// null delete marker that overwrites any prior one (S3 spec), so the object
// reads as deleted without markers piling up.
require.Len(t, listResp.DeleteMarkers, 1, "Suspended delete should record exactly one null delete marker")
assert.Equal(t, "null", aws.ToString(listResp.DeleteMarkers[0].VersionId), "Suspended delete marker should have version id null")
assert.Equal(t, objectKey, aws.ToString(listResp.DeleteMarkers[0].Key), "Delete marker should be for the deleted object")
// Verify null version is gone
nullVersionFound = false
@@ -596,7 +680,7 @@ func TestSuspendedVersioningDeleteBehavior(t *testing.T) {
Key: aws.String(objectKey),
})
require.NoError(t, err)
assert.Equal(t, "true", deleteResp.DeleteMarker, "Should create delete marker when versioning is enabled")
assert.True(t, aws.ToBool(deleteResp.DeleteMarker), "Should create delete marker when versioning is enabled")
// Verify final state
listResp, err = client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
@@ -604,7 +688,19 @@ func TestSuspendedVersioningDeleteBehavior(t *testing.T) {
})
require.NoError(t, err)
assert.Len(t, listResp.Versions, 4, "Should have 3 original versions + 1 new version")
assert.Len(t, listResp.DeleteMarkers, 1, "Should have 1 delete marker")
// The historical null suspended marker plus the new enabled-mode delete marker.
require.Len(t, listResp.DeleteMarkers, 2, "Should have the null suspended marker and the enabled-mode delete marker")
var sawNull, sawEnabled bool
for _, dm := range listResp.DeleteMarkers {
assert.Equal(t, objectKey, aws.ToString(dm.Key), "Delete marker should be for the deleted object")
if aws.ToString(dm.VersionId) == "null" {
sawNull = true
} else {
sawEnabled = true
}
}
assert.True(t, sawNull, "Suspended phase should leave a null delete marker")
assert.True(t, sawEnabled, "Enabled delete should add a real-version-id delete marker")
t.Logf("Successfully verified suspended versioning delete behavior")
}
@@ -664,7 +760,8 @@ func TestVersionedObjectListBehavior(t *testing.T) {
assert.NotContains(t, *listedObject.Key, versionId, "Object key should not contain version ID")
// Verify object properties
assert.Equal(t, int64(len(content)), listedObject.Size, "Object size should match")
require.NotNil(t, listedObject.Size, "Object size should not be nil")
assert.Equal(t, int64(len(content)), *listedObject.Size, "Object size should match")
assert.NotNil(t, listedObject.ETag, "Object should have ETag")
assert.NotNil(t, listedObject.LastModified, "Object should have LastModified")
@@ -674,7 +771,7 @@ func TestVersionedObjectListBehavior(t *testing.T) {
assert.NotEmpty(t, listedObject.Owner.DisplayName, "Owner DisplayName should not be empty")
t.Logf("Listed object: Key=%s, Size=%d, Owner.ID=%s, Owner.DisplayName=%s",
*listedObject.Key, listedObject.Size, *listedObject.Owner.ID, *listedObject.Owner.DisplayName)
*listedObject.Key, *listedObject.Size, *listedObject.Owner.ID, *listedObject.Owner.DisplayName)
// Test list-objects-v2 operation as well
listV2Resp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
+25 -3
View File
@@ -36,8 +36,8 @@ type S3TestConfig struct {
// Default test configuration - should match s3tests.conf
var defaultConfig = &S3TestConfig{
Endpoint: firstNonEmpty(os.Getenv("S3_ENDPOINT"), "http://localhost:8333"), // Default SeaweedFS S3 port
MasterEndpoint: firstNonEmpty(os.Getenv("MASTER_ENDPOINT"), "http://127.0.0.1:9333"), // Default SeaweedFS master HTTP port
Endpoint: firstNonEmpty(os.Getenv("S3_ENDPOINT"), "http://localhost:8333"), // Default SeaweedFS S3 port
MasterEndpoint: firstNonEmpty(os.Getenv("MASTER_ENDPOINT"), "http://127.0.0.1:9333"), // Default SeaweedFS master HTTP port
AccessKey: "some_access_key1",
SecretKey: "some_secret_key1",
Region: "us-east-1",
@@ -50,7 +50,7 @@ var defaultConfig = &S3TestConfig{
// cleanupAllTestBuckets uses it to find stale buckets from prior tests/runs.
// Add the new prefix here whenever a test introduces one.
var allTestBucketPrefixes = []string{
"test-versioning-", // covers test-versioning-, test-versioning-directories, test-versioning-interaction-, test-versioned-acl/list
"test-versioning-", // covers test-versioning-, test-versioning-directories, test-versioning-interaction-, test-versioned-acl/list
"test-versioned-",
"test-error-messages-",
"test-delete-markers",
@@ -104,6 +104,28 @@ func getS3Client(t *testing.T) *s3.Client {
})
}
// getS3ClientWithCredentials creates an S3 client authenticating as a specific
// identity, so tests can act as a different owner than the default client.
func getS3ClientWithCredentials(t *testing.T, accessKey, secretKey string) *s3.Client {
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion(defaultConfig.Region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(
func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
URL: defaultConfig.Endpoint,
SigningRegion: defaultConfig.Region,
HostnameImmutable: true,
}, nil
})),
)
require.NoError(t, err)
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
})
}
// getNewBucketName generates a unique bucket name
func getNewBucketName() string {
timestamp := time.Now().UnixNano()
@@ -68,7 +68,6 @@ func TestLakekeeperIntegration(t *testing.T) {
env.StartSeaweedFS(t)
fmt.Printf(">>> SeaweedFS started.\n")
// Run python script in docker to test STS and S3 operations
runLakekeeperRepro(t, env)
}
@@ -80,7 +79,6 @@ func TestLakekeeperTableBucketIntegration(t *testing.T) {
env.StartSeaweedFS(t)
fmt.Printf(">>> SeaweedFS started.\n")
// Run python script in docker to test STS and S3 Tables operations
runLakekeeperTableBucketRepro(t, env)
}
+53 -8
View File
@@ -7,6 +7,7 @@ import (
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
@@ -24,6 +25,7 @@ type MultiVolumeCluster struct {
logsDir string
keepLogs bool
volumeServerCount int
disksPerServer int
masterPort int
masterGrpcPort int
@@ -31,6 +33,7 @@ type MultiVolumeCluster struct {
volumePorts []int
volumeGrpcPorts []int
volumePubPorts []int
volumeDiskDirs [][]string // per server: the data directories (one DiskLocation each)
masterCmd *exec.Cmd
volumeCmds []*exec.Cmd
@@ -38,13 +41,25 @@ type MultiVolumeCluster struct {
cleanupOnce sync.Once
}
// StartMultiVolumeCluster starts a cluster with a specified number of volume servers
// StartMultiVolumeCluster starts a cluster with serverCount volume servers, one
// data directory (DiskLocation) each.
func StartMultiVolumeCluster(t testing.TB, profile matrix.Profile, serverCount int) *MultiVolumeCluster {
return StartMultiVolumeClusterWithDisks(t, profile, serverCount, 1)
}
// StartMultiVolumeClusterWithDisks starts serverCount volume servers, each with
// disksPerServer data directories passed to -dir as a comma list so every
// directory becomes its own DiskLocation. Lets tests exercise per-disk EC
// layouts (e.g. shards on one disk, a stub/index on a sibling disk).
func StartMultiVolumeClusterWithDisks(t testing.TB, profile matrix.Profile, serverCount, disksPerServer int) *MultiVolumeCluster {
t.Helper()
if serverCount < 1 {
t.Fatalf("serverCount must be at least 1, got %d", serverCount)
}
if disksPerServer < 1 {
t.Fatalf("disksPerServer must be at least 1, got %d", disksPerServer)
}
weedBinary, err := FindOrBuildWeedBinary()
if err != nil {
@@ -60,10 +75,21 @@ func StartMultiVolumeCluster(t testing.TB, profile matrix.Profile, serverCount i
logsDir := filepath.Join(baseDir, "logs")
masterDataDir := filepath.Join(baseDir, "master")
// Create directories for master and all volume servers
// Create directories for master and all volume servers. With one disk the
// layout stays at baseDir/volumeN; multi-disk servers get baseDir/volumeN/diskD.
dirs := []string{configDir, logsDir, masterDataDir}
volumeDiskDirs := make([][]string, serverCount)
for i := 0; i < serverCount; i++ {
dirs = append(dirs, filepath.Join(baseDir, fmt.Sprintf("volume%d", i)))
serverDir := filepath.Join(baseDir, fmt.Sprintf("volume%d", i))
volumeDiskDirs[i] = make([]string, disksPerServer)
for d := 0; d < disksPerServer; d++ {
if disksPerServer == 1 {
volumeDiskDirs[i][d] = serverDir
} else {
volumeDiskDirs[i][d] = filepath.Join(serverDir, fmt.Sprintf("disk%d", d))
}
dirs = append(dirs, volumeDiskDirs[i][d])
}
}
for _, dir := range dirs {
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
@@ -98,11 +124,13 @@ func StartMultiVolumeCluster(t testing.TB, profile matrix.Profile, serverCount i
logsDir: logsDir,
keepLogs: keepLogs,
volumeServerCount: serverCount,
disksPerServer: disksPerServer,
masterPort: masterPort,
masterGrpcPort: masterGrpcPort,
volumePorts: make([]int, serverCount),
volumeGrpcPorts: make([]int, serverCount),
volumePubPorts: make([]int, serverCount),
volumeDiskDirs: volumeDiskDirs,
volumeCmds: make([]*exec.Cmd, serverCount),
}
@@ -133,8 +161,7 @@ func StartMultiVolumeCluster(t testing.TB, profile matrix.Profile, serverCount i
// Start all volume servers
for i := 0; i < serverCount; i++ {
volumeDataDir := filepath.Join(baseDir, fmt.Sprintf("volume%d", i))
if err = c.startVolume(i, volumeDataDir); err != nil {
if err = c.startVolume(i, c.volumeDiskDirs[i]); err != nil {
// Log current server's log for debugging startup failures
volumeLog := fmt.Sprintf("volume%d.log", i)
c.Stop()
@@ -207,13 +234,17 @@ func (c *MultiVolumeCluster) startMaster(dataDir string) error {
return c.masterCmd.Start()
}
func (c *MultiVolumeCluster) startVolume(index int, dataDir string) error {
func (c *MultiVolumeCluster) startVolume(index int, dataDirs []string) error {
logName := fmt.Sprintf("volume%d.log", index)
logFile, err := os.Create(filepath.Join(c.logsDir, logName))
if err != nil {
return err
}
maxPerDir := make([]string, len(dataDirs))
for i := range dataDirs {
maxPerDir[i] = "16"
}
args := []string{
"-config_dir=" + c.configDir,
"volume",
@@ -221,8 +252,8 @@ func (c *MultiVolumeCluster) startVolume(index int, dataDir string) error {
"-port=" + strconv.Itoa(c.volumePorts[index]),
"-port.grpc=" + strconv.Itoa(c.volumeGrpcPorts[index]),
"-port.public=" + strconv.Itoa(c.volumePubPorts[index]),
"-dir=" + dataDir,
"-max=16",
"-dir=" + strings.Join(dataDirs, ","),
"-max=" + strings.Join(maxPerDir, ","),
"-master=127.0.0.1:" + strconv.Itoa(c.masterPort),
"-readMode=" + c.profile.ReadMode,
"-concurrentUploadLimitMB=" + strconv.Itoa(c.profile.ConcurrentUploadLimitMB),
@@ -301,3 +332,17 @@ func (c *MultiVolumeCluster) VolumePublicURL(index int) string {
func (c *MultiVolumeCluster) BaseDir() string {
return c.baseDir
}
// VolumeDiskDir returns the data directory backing disk diskIndex on volume
// server serverIndex — i.e. the directory for that DiskLocation, for file
// inspection in tests.
func (c *MultiVolumeCluster) VolumeDiskDir(serverIndex, diskIndex int) string {
if serverIndex < 0 || serverIndex >= len(c.volumeDiskDirs) {
return ""
}
disks := c.volumeDiskDirs[serverIndex]
if diskIndex < 0 || diskIndex >= len(disks) {
return ""
}
return disks[diskIndex]
}
+1 -1
View File
@@ -301,7 +301,7 @@ func (r *runner) fetchVolumeList(ctx context.Context) (*master_pb.VolumeListResp
}
var response *master_pb.VolumeListResponse
err := pb.WithMasterClient(false, pb.ServerAddress(masterAddress), r.grpcDialOption, false, func(client master_pb.SeaweedClient) error {
err := pb.WithMasterClient(context.Background(), false, pb.ServerAddress(masterAddress), r.grpcDialOption, false, func(client master_pb.SeaweedClient) error {
callCtx, cancel := context.WithTimeout(ctx, r.cfg.RequestTimeout)
defer cancel()
+7 -1
View File
@@ -21,11 +21,17 @@ install-templ:
# Generate templ files
.PHONY: generate
generate: install-templ
generate: install-templ static-gz
@echo "Generating templ files..."
@templ generate ./view
@echo "Generated: $(TEMPL_GO_FILES)"
# Regenerate the gzipped static mirror embedded into the binary
.PHONY: static-gz
static-gz:
@echo "Generating gzipped static assets..."
@go run gen_static_gz.go
# Clean generated files
.PHONY: clean-templ
clean-templ:
+51
View File
@@ -30,6 +30,7 @@ type AdminData struct {
VolumeServers []VolumeServer `json:"volume_servers"`
FilerNodes []FilerNode `json:"filer_nodes"`
MessageBrokers []MessageBrokerNode `json:"message_brokers"`
S3Nodes []S3Node `json:"s3_nodes"`
DataCenters []DataCenter `json:"datacenters"`
LastUpdated time.Time `json:"last_updated"`
@@ -115,6 +116,12 @@ type MessageBrokerNode struct {
LastUpdated time.Time `json:"last_updated"`
}
type S3Node struct {
Address string `json:"address"`
DataCenter string `json:"datacenter"`
LastUpdated time.Time `json:"last_updated"`
}
// GetAdminData retrieves admin data as a struct (for reuse by both JSON and HTML handlers)
func (s *AdminServer) GetAdminData(username string) (AdminData, error) {
if username == "" {
@@ -144,6 +151,9 @@ func (s *AdminServer) GetAdminData(username string) (AdminData, error) {
// Get message broker nodes status
messageBrokers := s.getMessageBrokerNodesStatus()
// Get S3 nodes status
s3Nodes := s.getS3NodesStatus()
// Get volume size limit from master configuration
var volumeSizeLimitMB uint64 = 30000 // Default to 30GB
err = s.WithMasterClient(func(client master_pb.SeaweedClient) error {
@@ -183,6 +193,7 @@ func (s *AdminServer) GetAdminData(username string) (AdminData, error) {
VolumeServers: volumeServersData.VolumeServers,
FilerNodes: filerNodes,
MessageBrokers: messageBrokers,
S3Nodes: s3Nodes,
DataCenters: topology.DataCenters,
LastUpdated: topology.UpdatedAt,
TotalEcVolumes: totalEcVolumes,
@@ -360,3 +371,43 @@ func (s *AdminServer) getMessageBrokerNodesStatus() []MessageBrokerNode {
return messageBrokers
}
// getS3NodesStatus checks status of all S3 nodes using master's ListClusterNodes
func (s *AdminServer) getS3NodesStatus() []S3Node {
var s3Nodes []S3Node
// Get S3 nodes from master using ListClusterNodes
err := s.WithMasterClient(func(client master_pb.SeaweedClient) error {
resp, err := client.ListClusterNodes(context.Background(), &master_pb.ListClusterNodesRequest{
ClientType: cluster.S3Type,
})
if err != nil {
return err
}
// Process each S3 node
for _, node := range resp.ClusterNodes {
s3Nodes = append(s3Nodes, S3Node{
Address: pb.ServerAddress(node.Address).ToHttpAddress(),
DataCenter: node.DataCenter,
LastUpdated: time.Now(),
})
}
return nil
})
if err != nil {
currentMaster := s.masterClient.GetMaster(context.Background())
glog.Errorf("Failed to get S3 nodes from master %s: %v", currentMaster, err)
// Return empty list if we can't get S3 info from master
return []S3Node{}
}
// Sort S3 nodes by address for consistent ordering on page refresh
sort.Slice(s3Nodes, func(i, j int) bool {
return s3Nodes[i].Address < s3Nodes[j].Address
})
return s3Nodes
}
+47 -1
View File
@@ -1189,6 +1189,52 @@ func (s *AdminServer) GetClusterBrokers() (*ClusterBrokersData, error) {
}, nil
}
// GetClusterS3Servers retrieves cluster S3 servers data
func (s *AdminServer) GetClusterS3Servers() (*ClusterS3ServersData, error) {
var s3Servers []S3ServerInfo
// Get S3 server information from master using ListClusterNodes
err := s.WithMasterClient(func(client master_pb.SeaweedClient) error {
resp, err := client.ListClusterNodes(context.Background(), &master_pb.ListClusterNodesRequest{
ClientType: cluster.S3Type,
})
if err != nil {
return err
}
// Process each S3 server node
for _, node := range resp.ClusterNodes {
createdAt := time.Unix(0, node.CreatedAtNs)
s3ServerInfo := S3ServerInfo{
Address: pb.ServerAddress(node.Address).ToHttpAddress(),
DataCenter: node.DataCenter,
Version: node.Version,
CreatedAt: createdAt,
}
s3Servers = append(s3Servers, s3ServerInfo)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to get S3 server nodes from master: %w", err)
}
// Sort S3 servers by address for consistent ordering on page refresh
sort.Slice(s3Servers, func(i, j int) bool {
return s3Servers[i].Address < s3Servers[j].Address
})
return &ClusterS3ServersData{
S3Servers: s3Servers,
TotalS3Servers: len(s3Servers),
LastUpdated: time.Now(),
}, nil
}
// GetAllFilers method moved to client_management.go
// GetVolumeDetails method moved to volume_management.go
@@ -1778,7 +1824,7 @@ func collectCollectionStats(topologyInfo *master_pb.TopologyInfo) map[string]col
shards := erasure_coding.ShardsInfoFromVolumeEcShardInformationMessage(ecShardInfo)
data := collectionMap[collection]
data.PhysicalSize += int64(shards.TotalSize())
data.LogicalSize += int64(shards.MinusParityShards().TotalSize())
data.LogicalSize += int64(shards.MinusParityShards(erasure_coding.DataShardsCount).TotalSize())
collectionMap[collection] = data
// fileCount is volume-wide (same .ecx on every shard
+3 -3
View File
@@ -4,7 +4,7 @@ import (
"context"
"fmt"
"io"
"path/filepath"
"path"
"strings"
"time"
@@ -325,7 +325,7 @@ func (s *AdminServer) GetConsumerGroupOffsets(namespace, topicName string) ([]Co
// Only process directories that are versions (start with "v")
if versionResp.Entry != nil && versionResp.Entry.IsDirectory && strings.HasPrefix(versionResp.Entry.Name, "v") {
versionDir := filepath.Join(topicDir, versionResp.Entry.Name)
versionDir := path.Join(topicDir, versionResp.Entry.Name)
// List all partition directories under the version directory (e.g., 0315-0630)
partitionStream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{
@@ -360,7 +360,7 @@ func (s *AdminServer) GetConsumerGroupOffsets(namespace, topicName string) ([]Co
continue
}
partitionDir := filepath.Join(versionDir, partitionResp.Entry.Name)
partitionDir := path.Join(versionDir, partitionResp.Entry.Name)
// List all .offset files in this partition directory
offsetStream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{

Some files were not shown because too many files have changed in this diff Show More