Compare commits

..
Author SHA1 Message Date
Chris Lu af32985e01 fix(volume): sticky EIO quarantine; track streamed reads
Two follow-ups on PR #9382:

1. Quarantine wasn't sticky. Once CollectHeartbeat crossed the streak
   threshold and hid the replica, a subsequent successful read called
   checkReadWriteError(nil), wiping the streak; the next heartbeat
   then re-announced the suspect replica as read-only and master could
   send reads back to a disk that already failed IoErrorTolerance.
   Added an ioErrorQuarantined sticky flag set on the first heartbeat
   that observes the threshold and cleared only by MarkVolumeWritable
   (resetIoErrorState). clearIoError continues to reset just the
   streak so successful ops don't accumulate phantom errors.

2. Streamed reads bypassed the EIO counter. readNeedleDataInto and
   ReadNeedleBlob — the hot paths for large/range GETs — returned
   ReadNeedleData / needle.ReadNeedleBlob errors without threading
   them through checkReadWriteError, so a disk failing only on those
   paths would never trip IoErrorTolerance. Both now route the
   backend error through the tracker, and a fully clean
   readNeedleDataInto call clears the streak.

Tests cover the sticky flag (TestQuarantineIsSticky) and the streamed
read path (TestReadNeedleBlobTracksEIO via a fake EIO backend).
2026-05-09 09:26:37 -07:00
Chris Lu c0e1cbe9ed review: fix race, accounting, recovery, non-EIO streak break
Addressing PR #9382 review:

- Data race on lastIoError: guard lastIoError + lastIoErrorCount with a
  RWMutex and expose them through note/clear/get helpers so the
  heartbeat reader sees a consistent snapshot. Verified with -race.
- Collection-size accounting: when a volume is quarantined for sustained
  EIO, skip the entire per-volume bookkeeping (`continue`) instead of
  flipping shouldDeleteVolume — the old branch subtracted a size that
  was never added, dragging the collection gauge to zero / negative.
- Recoverability: MarkVolumeWritable now also calls clearIoError so an
  operator can rejoin a quarantined replica. The next failed op
  re-arms the streak if the disk is still bad.
- Non-EIO streak break: a non-EIO error (e.g. ENOSPC) now resets the
  consecutive-EIO counter, so a sequence EIO,EIO,ENOSPC,EIO is treated
  as a streak of one — the counter only tracks consecutive EIOs.

Reads already call checkReadWriteError (volume_read.go), so successful
reads also clear the streak — no change needed there.
2026-05-08 23:33:33 -07:00
Chris Lu 729c3bf387 fix(volume): don't nuke local data on transient IO error (#9378)
A single syscall.EIO from any read/write/delete set v.lastIoError, and
the next CollectHeartbeat then called Volume.Destroy on the replica —
removing the .dat/.idx/.vif/.sdx/.ldb/.rdb files. A brief NFS / fabric
/ controller blip hitting several replicas at once could cascade into
removal of the last healthy copy, with no recovery for non-tiered
volumes.

Now require IoErrorTolerance (3) consecutive EIOs before acting, and on
that threshold mark the volume read-only and stop announcing it to the
master so re-replication kicks in from healthy peers — never delete
the data files. The on-disk copy stays for operator inspection /
recovery.
2026-05-08 22:19:42 -07:00
491 changed files with 8536 additions and 39895 deletions
+3 -4
View File
@@ -1,10 +1,9 @@
name: "docker: build latest container"
# Manual fallback only. On tag push, container_release_unified.yml already
# re-tags the released versioned image as `latest` / `latest_large_disk`,
# so a full rebuild here is unnecessary. Run this manually if you need to
# rebuild `latest` from an arbitrary ref.
on:
push:
tags:
- '*'
workflow_dispatch:
inputs:
source_ref:
+1 -123
View File
@@ -29,7 +29,6 @@ on:
permissions:
contents: read
security-events: write
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref_name }}
@@ -358,128 +357,7 @@ jobs:
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"
# Report-only trivy scan: uploads fixable HIGH/CRITICAL findings to GitHub
# Security for visibility, but never blocks the release. Releases (including
# `latest`) ship regardless — vulnerabilities are tracked, not gated, since
# we sometimes need to publish through known findings (e.g. unfixed upstream
# CVE, base-image lag).
trivy-scan:
runs-on: ubuntu-latest
needs: [build]
if: github.event_name == 'push'
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- source_suffix: ""
variant: normal
- source_suffix: _large_disk
variant: large_disk
steps:
- name: Login to GHCR
uses: docker/login-action@v4.1.0
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Trivy report (${{ matrix.variant }})
# Pin to SHA - mutable tags were compromised (GHSA-69fq-xp46-6x23)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: image
# Scan the multi-arch tag on GHCR (already pushed by the build job).
# Trivy scans the runner's native platform; OS packages are identical
# across architectures since they all share the same alpine base.
image-ref: ghcr.io/chrislusf/seaweedfs:${{ env.RELEASE_TAG }}${{ matrix.source_suffix }}
scanners: vuln
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
limit-severities-for-sarif: true
format: sarif
output: trivy-results.sarif
exit-code: '0'
- name: Upload Trivy scan results to GitHub Security
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy-results.sarif
category: trivy-${{ matrix.variant }}
# Point `latest` (and `latest_large_disk`) at the just-released versioned
# image. crane tag adds an extra tag to an existing manifest — no rebuild,
# no QEMU, no separate workflow. Replaces the old container_latest.yml
# rebuild that often failed or lagged behind the release. Independent of
# trivy-scan: vuln findings are reported but do not block `latest`.
tag-latest:
runs-on: ubuntu-latest
needs: [copy-to-dockerhub]
if: github.event_name == 'push'
strategy:
matrix:
include:
- source_suffix: ""
latest_tag: latest
- source_suffix: _large_disk
latest_tag: latest_large_disk
steps:
- name: Login to Docker Hub
uses: docker/login-action@v4.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4.1.0
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Install crane
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: Re-tag ${{ env.RELEASE_TAG }}${{ matrix.source_suffix }} as ${{ matrix.latest_tag }}
run: |
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
}
SRC_TAG="${{ env.RELEASE_TAG }}${{ matrix.source_suffix }}"
DST_TAG="${{ matrix.latest_tag }}"
echo "Tagging ghcr.io/chrislusf/seaweedfs:${SRC_TAG} as ${DST_TAG}"
retry_with_backoff crane tag "ghcr.io/chrislusf/seaweedfs:${SRC_TAG}" "${DST_TAG}"
echo "Tagging chrislusf/seaweedfs:${SRC_TAG} as ${DST_TAG}"
retry_with_backoff crane tag "chrislusf/seaweedfs:${SRC_TAG}" "${DST_TAG}"
echo "✓ Successfully copied ${{ matrix.variant }} to Docker Hub"
helm-release:
runs-on: ubuntu-latest
+1 -1
View File
@@ -11,4 +11,4 @@ jobs:
- name: 'Checkout Repository'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: 'Dependency Review'
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48
+49
View File
@@ -0,0 +1,49 @@
name: EC Integration Tests
on:
push:
branches: [ master ]
paths:
- 'weed/admin/**'
- 'weed/worker/**'
- 'test/erasure_coding/admin_dockertest/**'
- '.github/workflows/ec-integration.yml'
pull_request:
branches: [ master ]
paths:
- 'weed/admin/**'
- 'weed/worker/**'
- 'test/erasure_coding/admin_dockertest/**'
- '.github/workflows/ec-integration.yml'
jobs:
ec-integration-test:
runs-on: ubuntu-latest
timeout-minutes: 15
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 weed binary
run: |
cd weed
go build -o ../weed_bin
- name: Run EC integration tests
run: |
cd test/erasure_coding/admin_dockertest
go test -v -timeout 15m ec_integration_test.go
- name: Upload test logs on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: ec-test-logs
path: test/erasure_coding/admin_dockertest/tmp/logs/
retention-days: 7
-129
View File
@@ -68,135 +68,6 @@ jobs:
grep -q "security-config" /tmp/security.yaml
echo "✓ Security configuration renders correctly"
echo ""
echo "=== Testing IAM gRPC opt-in path ==="
# Regression test: the filer registers the IAM gRPC service the
# Admin UI Users tab calls only when jwt.filer_signing.key is in
# security.toml. Operators must be able to enable that without
# the cert-manager mTLS bundle.
# Install PyYAML explicitly: this block runs before the later
# security+S3 block that does the same install, and we don't
# want to rely on the runner image shipping it.
pip install pyyaml -q
python3 - "$CHART_DIR" <<'PYEOF'
import subprocess, sys, yaml
chart = sys.argv[1]
def render(values):
args = ["helm", "template", "test", chart]
for k, v in values.items():
args += ["--set", f"{k}={v}"]
return subprocess.check_output(args, text=True)
def docs(manifest):
return [d for d in yaml.safe_load_all(manifest) if d]
def configmap(manifest, name):
for d in docs(manifest):
if d.get("kind") == "ConfigMap" and d["metadata"]["name"] == name:
return d
return None
def workload_mounts(manifest, name):
for d in docs(manifest):
if d.get("kind") not in ("Deployment", "StatefulSet"):
continue
if d["metadata"]["name"] != name:
continue
pod = d["spec"]["template"]["spec"]
vols = {v["name"] for v in pod.get("volumes", [])}
mounts = set()
for c in pod.get("containers", []):
for vm in c.get("volumeMounts", []):
mounts.add(vm["name"])
return vols, mounts
return None, None
failed = []
# Case 1: defaults. The chart historically rendered nothing
# security-related; preserve that so this PR is non-breaking on
# existing installs.
out = render({})
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)")
# Case 2: filerWrite=true alone is the documented opt-in for
# the Admin UI Users tab. Configmap must render with
# [jwt.filer_signing] and NO [grpc.*] sections (cert paths
# only exist with mTLS).
out = render({
"global.seaweedfs.securityConfig.jwtSigning.filerWrite": "true",
"admin.enabled": "true",
})
cm = configmap(out, "test-seaweedfs-security-config")
if cm is None:
failed.append("filerWrite=true: security ConfigMap missing")
else:
toml = cm["data"]["security.toml"]
if "[jwt.filer_signing]" not in toml:
failed.append("filerWrite=true: security.toml missing [jwt.filer_signing]")
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.*]")
# Case 3: filer + admin pods must MOUNT the security ConfigMap
# under filerWrite=true so the JWT key reaches both processes.
# Cert volumes must NOT be present (no mTLS).
for wl in ("test-seaweedfs-filer", "test-seaweedfs-admin"):
vols, mounts = workload_mounts(out, wl)
if vols is None:
failed.append(f"filerWrite=true: workload {wl} not found")
continue
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")
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)}")
# Case 4: enableSecurity=true must still render the full toml
# with both [jwt.signing] and [grpc.*]. Guards against the
# decoupling change accidentally regressing the mTLS path.
out = render({"global.seaweedfs.enableSecurity": "true"})
cm = configmap(out, "test-seaweedfs-security-config")
if cm is None:
failed.append("enableSecurity=true: security ConfigMap missing")
else:
toml = cm["data"]["security.toml"]
missing = [s for s in ("[jwt.signing]", "[grpc.master]") if s not in toml]
if missing:
failed.append(f"enableSecurity=true: security.toml missing {missing}")
else:
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
# the helper review caught.
for null_path in ("global.seaweedfs.securityConfig",
"global.seaweedfs.securityConfig.jwtSigning"):
try:
out = render({null_path: "null"})
except subprocess.CalledProcessError as e:
failed.append(f"{null_path}=null: render failed: {e.output[:200] if e.output else e}")
continue
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")
if failed:
print("\nFAIL:", file=sys.stderr)
for f in failed:
print(f" - {f}", file=sys.stderr)
sys.exit(1)
PYEOF
echo "✓ IAM gRPC decoupling tests passed"
echo "=== Testing with monitoring enabled ==="
helm template test $CHART_DIR \
--set global.seaweedfs.monitoring.enabled=true \
+5 -31
View File
@@ -248,33 +248,7 @@ jobs:
s3-lifecycle-tests:
name: S3 Lifecycle Tests
runs-on: ubuntu-22.04
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
# One job per test so each gets a fresh `weed mini` server, avoiding
# the cross-test volume-pool exhaustion that surfaced when several
# TTL-pinned bucket collections piled up in a single run.
test:
- TestLifecycleAbortIncompleteMultipartUpload
- TestLifecycleAdminDispatchSucceedsWithCustomFilerGrpcPort
- TestLifecycleBootstrapWalkOnExistingObjects
- TestLifecycleConfigUpdateBetweenSweeps
- TestLifecycleDeleteBucketLifecycleStopsDispatching
- TestLifecycleDisabledRuleSkipsObject
- TestLifecycleEmptyBucketSweepIsNoOp
- TestLifecycleExpirationDateInThePast
- TestLifecycleExpirationFiresOnBackdatedObject
- TestLifecycleExpiredDeleteMarkerCleanup
- TestLifecycleMultipleBucketsInOneSweep
- TestLifecycleMultipleRulesInOneBucket
- TestLifecycleNewerNoncurrentVersions
- TestLifecycleNoncurrentVersionExpiration
- TestLifecycleSizeFilterGreaterThan
- TestLifecycleSkipsObjectLockedObjects
- TestLifecycleSuspendedVersioningExpiration
- TestLifecycleTagFilter
- TestLifecycleVersionedBucketCreatesDeleteMarker
timeout-minutes: 15
steps:
- name: Check out code
@@ -290,12 +264,12 @@ jobs:
run: |
go install -buildvcs=false
- name: Run ${{ matrix.test }}
timeout-minutes: 8
- name: Run S3 Lifecycle Tests
timeout-minutes: 12
working-directory: test/s3/lifecycle
run: |
set -x
make test-with-server TEST_PATTERN='^${{ matrix.test }}$$'
make test-with-server
- name: Show server logs on failure
if: failure()
@@ -312,7 +286,7 @@ jobs:
if: failure()
uses: actions/upload-artifact@v7
with:
name: s3-lifecycle-test-logs-${{ matrix.test }}
name: s3-lifecycle-test-logs
path: test/s3/lifecycle/weed-test*.log
retention-days: 3
@@ -71,10 +71,8 @@ jobs:
echo "Waiting for SeaweedFS S3 gateway to be ready via proxy..."
S3_READY=0
for i in $(seq 1 30); do
# Check logs first for the readiness line. weed mini's progress
# board prints " S3 ready (Xs)"; older builds and the
# standalone S3 binary log "S3 (gateway|service) ... ready".
if docker compose logs seaweedfs 2>&1 | grep -qE "S3 (gateway|service).*(started|ready)|S3[[:space:]]+ready"; then
# Check logs first for startup message (weed mini says "S3 service is ready")
if docker compose logs seaweedfs 2>&1 | grep -qE "S3 (gateway|service).*(started|ready)"; then
echo "SeaweedFS S3 gateway is ready"
S3_READY=1
break
@@ -1,110 +0,0 @@
name: "S3 SDK V2 Route Disambiguation Tests"
on:
push:
branches: [ master ]
paths:
- 'weed/s3api/**'
- 'test/s3/sdk_v2_routing/**'
- '.github/workflows/s3-sdk-v2-routing-tests.yml'
pull_request:
branches: [ master ]
paths:
- 'weed/s3api/**'
- 'test/s3/sdk_v2_routing/**'
- '.github/workflows/s3-sdk-v2-routing-tests.yml'
concurrency:
group: ${{ github.head_ref || github.ref }}/s3-sdk-v2-routing-tests
cancel-in-progress: true
permissions:
contents: read
jobs:
s3-sdk-v2-routing-tests:
name: S3 SDK V2 Routing Tests
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Install SeaweedFS
run: |
cd weed && go install -buildvcs=false
- name: Start weed mini (S3 on :8333)
# Pins the regression for issue #9559: AWS SDK V2 / Hadoop s3a
# listing a bucket literally named "buckets" must get an XML
# ListObjectsV2 response, not the JSON ListTableBuckets body
# served by the S3 Tables REST endpoint on the same path.
run: |
mkdir -p /tmp/seaweedfs-sdk-v2-routing
cat > /tmp/seaweedfs-sdk-v2-routing-s3.json <<'JSON'
{
"identities": [
{
"name": "admin",
"credentials": [
{"accessKey": "some_access_key1", "secretKey": "some_secret_key1"}
],
"actions": ["Admin", "Read", "Write"]
}
]
}
JSON
AWS_ACCESS_KEY_ID=some_access_key1 \
AWS_SECRET_ACCESS_KEY=some_secret_key1 \
weed mini \
-dir=/tmp/seaweedfs-sdk-v2-routing \
-s3.port=8333 \
-s3.config=/tmp/seaweedfs-sdk-v2-routing-s3.json \
-ip=127.0.0.1 \
> /tmp/weed-mini.log 2>&1 &
echo $! > /tmp/weed-mini.pid
for i in $(seq 1 30); do
if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8333/ | grep -qE "^(200|403)$"; then
echo "weed mini is ready"
exit 0
fi
sleep 1
done
echo "weed mini failed to start within 30s"
tail -50 /tmp/weed-mini.log
exit 1
- name: Run SDK V2 routing tests
env:
S3_ENDPOINT: http://127.0.0.1:8333
AWS_ACCESS_KEY_ID: some_access_key1
AWS_SECRET_ACCESS_KEY: some_secret_key1
AWS_REGION: us-east-1
run: go test -v -timeout=5m ./test/s3/sdk_v2_routing/...
- name: Stop weed mini
if: always()
run: |
if [ -f /tmp/weed-mini.pid ]; then
kill "$(cat /tmp/weed-mini.pid)" 2>/dev/null || true
fi
- name: Show server log on failure
if: failure()
run: |
echo "=== weed mini log (last 200 lines) ==="
tail -n 200 /tmp/weed-mini.log 2>/dev/null || echo "no log available"
- name: Archive log
if: failure()
uses: actions/upload-artifact@v7
with:
name: s3-sdk-v2-routing-server-log
path: /tmp/weed-mini.log
retention-days: 3
-120
View File
@@ -1,120 +0,0 @@
name: "Samba on FUSE Integration"
on:
push:
branches: [ master, main ]
paths:
- 'weed/mount/**'
- 'weed/filer/**'
- 'weed/cluster/**'
- 'test/samba/**'
- '.github/workflows/samba-integration.yml'
pull_request:
branches: [ master, main ]
paths:
- 'weed/mount/**'
- 'weed/filer/**'
- 'weed/cluster/**'
- 'test/samba/**'
- '.github/workflows/samba-integration.yml'
workflow_dispatch:
concurrency:
group: samba-integration/${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
samba-integration:
name: samba-integration
runs-on: ubuntu-22.04
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Start local Docker registry
run: docker run -d --restart=always -p 5000:5000 --name registry registry:2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build weed race binary
run: |
cd docker
make binary_race
- name: Build SeaweedFS e2e image
uses: docker/build-push-action@v7
with:
context: docker
file: docker/Dockerfile.e2e
tags: localhost:5000/chrislusf/seaweedfs:e2e
push: true
cache-from: type=gha,scope=samba-e2e
cache-to: type=gha,mode=max,scope=samba-e2e
- name: Tag e2e image for docker compose
run: |
docker pull localhost:5000/chrislusf/seaweedfs:e2e
docker tag localhost:5000/chrislusf/seaweedfs:e2e chrislusf/seaweedfs:e2e
- name: Build samba image
uses: docker/build-push-action@v7
with:
context: test/samba
build-contexts: |
chrislusf/seaweedfs:e2e=docker-image://localhost:5000/chrislusf/seaweedfs:e2e
tags: localhost:5000/chrislusf/seaweedfs:samba
push: true
cache-from: type=gha,scope=samba-harness
cache-to: type=gha,mode=max,scope=samba-harness
- name: Tag samba image for docker compose
run: |
docker pull localhost:5000/chrislusf/seaweedfs:samba
docker tag localhost:5000/chrislusf/seaweedfs:samba chrislusf/seaweedfs:samba
- name: Start SeaweedFS cluster and Samba
run: |
docker compose -f test/samba/docker-compose.yml up --wait
- name: Run Samba test battery
run: |
set -o pipefail
docker compose -f test/samba/docker-compose.yml exec -T samba \
/run_inside_container.sh 2>&1 | tee /tmp/samba-output.log
- name: Collect logs
if: always()
run: |
mkdir -p /tmp/samba-docker-logs
for svc in master volume filer samba; do
docker compose -f test/samba/docker-compose.yml logs "$svc" \
> "/tmp/samba-docker-logs/${svc}.log" 2>&1 || true
done
- name: Tear down
if: always()
run: |
docker compose -f test/samba/docker-compose.yml down -v
- name: Upload logs
if: always()
uses: actions/upload-artifact@v7
with:
name: samba-integration-results
path: |
/tmp/samba-output.log
/tmp/samba-docker-logs/
retention-days: 7
+7 -9
View File
@@ -509,22 +509,20 @@ SeaweedFS Filer uses off-the-shelf stores, such as MySql, Postgres, Sqlite, Mong
### Compared to MinIO ###
Please note, as Apr 25, 2026 MinIO ceased developement. It's strongly discouraged to use that unmaintained software with multiple security bugs.
MinIO follows AWS S3 closely and is ideal for testing for S3 API. It has good UI, policies, versionings, etc. SeaweedFS is trying to catch up here. It is also possible to put MinIO as a gateway in front of SeaweedFS later.
MinIO followed AWS S3 closely and was ideal for testing for S3 API. It had good UI, policies, versionings, etc. SeaweedFS is trying to catch up here.
MinIO metadata are in simple files. Each file write will incur extra writes to corresponding meta file.
MinIO metadata were in simple files. Each file write will incur extra writes to corresponding meta file.
MinIO did not have optimization for lots of small files. The files were simply stored as is to local disks.
MinIO does not have optimization for lots of small files. The files are simply stored as is to local disks.
Plus the extra meta file and shards for erasure coding, it only amplifies the LOSF problem.
MinIO had multiple disk IO to read one file. SeaweedFS has O(1) disk reads, even for erasure coded files.
MinIO has multiple disk IO to read one file. SeaweedFS has O(1) disk reads, even for erasure coded files.
MinIO had full-time erasure coding. SeaweedFS uses replication on hot data for faster speed and optionally applies erasure coding on warm data.
MinIO has full-time erasure coding. SeaweedFS uses replication on hot data for faster speed and optionally applies erasure coding on warm data.
MinIO did not have POSIX-like API support.
MinIO does not have POSIX-like API support.
MinIO had specific requirements on storage layout. It is not flexible to adjust capacity. In SeaweedFS, just start one volume server pointing to the master. That's all.
MinIO has specific requirements on storage layout. It is not flexible to adjust capacity. In SeaweedFS, just start one volume server pointing to the master. That's all.
## Dev Plan ##
+25 -34
View File
@@ -110,9 +110,7 @@ This is more state than the previous "single subscription, single cursor" claim
**Cursor-skip semantics.** `last_processed[filer_id]` points at the *last resolved event* for that shard. Resume must skip events with position `<= cursor[filer_id]` (using the `(ts, offset)` order per shard) — strict `<=`, not `<`, otherwise the last processed event is replayed on every resume. Equivalently: deliver events with `(event.ts, event.offset) > cursor[filer_id]`. Phase 3 introduces a new API variant that takes per-shard `MessagePosition`s and applies this comparator — see "API change" below. The existing `pb.ReadLogFileRefs` filter `logEntry.TsNs <= startTsNs` (`weed/pb/filer_pb_direct_read.go:333`) is the wrong shape because it can't disambiguate equal-ts events.
> **Implementation note:** the API addition described below was not built. The shipped reader uses `SubscribeMetadata` with a single global cursor (the filer's server-side `MetaAggregator` already does the merge). The pseudocode is preserved as design context for a future per-shard architecture.
**~~Required API addition~~ (Phase 3 dependency, not built)**: `pb.ReadLogFileRefsWithPosition` extending the current `ReadLogFileRefs`:
**Required API addition** (Phase 3 dependency): `pb.ReadLogFileRefsWithPosition` extending the current `ReadLogFileRefs`:
- Take a per-shard `start_positions: map<filer_id, MessagePosition>` instead of a single `startTsNs`.
- Per-event callback signature `eachLogEntry(event, filer_id, position) → CallbackResult`.
@@ -567,8 +565,6 @@ Policy changes during a bucket bootstrap: the bootstrap is bound to a specific `
### Reader — single subscription, client-side merge
> **Implementation note (post-Phase 3):** the production reader uses `client.SubscribeMetadata(...)` directly with a single global cursor. The filer's `SubscribeMetadata` server (`weed/server/filer_grpc_server_sub_meta.go`) already aggregates across peer filers — it reads disk-persisted logs from every filer via `sendLogFileRefs` then drains `MetaAggregator.MetaLogBuffer` for the in-memory tail — so the worker doesn't have to assemble its own multi-filer merge. The per-filer-shard cursor model below was designed before this was wired and was not built; the proto fields (`last_processed_original` map keyed by `filer_id`, `tail_drained_streams`) remain as forward-compat stubs. The pseudocode below describes the alternative the design considered, kept here for context. See "Multi-filer durability" and the obsoleted Phase 6 section.
The reader is one cluster-singleton task subscribed to one filer at a time. `Filer.CollectLogFileRefs(start_position, stop_ts)` returns chunk refs from log files in directory order. The worker passes those refs to `pb.ReadLogFileRefsWithPosition` (the new API — see Task #19), which heap-merges per-filer chunks by event ts client-side and applies the per-shard `(ts, offset)` skip filter. Chunk *bodies* are read in parallel from volume servers via the returned fileIds.
Cursors are per-filer-shard. `last_processed_original[D.seconds]` is `map<filer_id, MessagePosition>`; `MessagePosition = {ts_ns, offset}` disambiguates equal-ts events within a per-filer chunk. Resume skips events with `(event.ts, event.offset) <= cursor[filer_id]` — strict `<=` so the last resolved event isn't replayed.
@@ -1398,8 +1394,6 @@ Why a separate lane matters here:
### Multi-filer durability
> **Implementation note (post-Phase 3):** the worker did not end up building its own multi-filer merge. `SubscribeMetadata` on the filer server (`weed/server/filer_grpc_server_sub_meta.go:154`) routes through `MetaAggregator` when peer filers exist: it reads from every filer's disk-persisted logs first (via `sendLogFileRefs`/`Filer.CollectLogFileRefs`), then drains the aggregator's in-memory tail. Workers get an aggregated, durable stream from a single subscription. The per-filer-shard cursor architecture below describes what would be needed if the worker had to assemble the merge itself; we kept it here as design context and as a reference for the proto stubs (`LOST_LOG`, `tail_drained_streams`) that remain forward-compatible if the architecture ever changes. The genuine residual gap — auto-degrading the worker on `ResumeFromDiskError` — is tracked separately as a narrow follow-up rather than a Phase.
`MetaAggregator` is **in-memory only** — `weed/filer/meta_aggregator.go:39`: *"MetaAggregator only aggregates data 'on the fly'. The logs are not re-persisted to disk. The old data comes from what each LocalMetadata persisted on disk."*
So the aggregator can't be the worker's source of truth: a worker restart, a crash, or a peer outage all lose unpersisted aggregated events. The persistent source is per-filer logs at `/topics/.system/log/<date>/<hour-min>.<filerId>` — written by each filer for its own events, durable, and queryable via `Filer.CollectLogFileRefs` and `Filer.ReadPersistedLogBuffer`.
@@ -1910,13 +1904,11 @@ Phasing changed: **the back-stamp cannot be removed until the worker can take ov
## Phase 3 — Shared cluster reader + delay-group sweeps (non-versioned)
> **Implementation note:** the shipped reader uses `client.SubscribeMetadata(...)` with a single global `(ts_ns, offset)` cursor (`weed/s3api/s3lifecycle/reader/reader.go`). The filer's `SubscribeMetadata` server already aggregates persisted + in-memory logs across peer filers, so the per-filer-shard cursor map and the `RetainedLogRangePerShard` / `EarliestRetainedPositionPerShard` filer RPCs (Task #19) were not built. The tail-drain matrix and lost-log tests below were written against that unbuilt architecture and are obsoleted; the only residual gap — auto-degrading on `ResumeFromDiskError` — is tracked as a separate narrow follow-up. The retention mode-gate, engine routing, predicate-change pass, SKIPPED_OBJECT_LOCK, TRANSPORT_ERROR, and cursor-trail tests remain valid and were exercised against the production reader/router/engine stack.
- Implement `s3.lifecycle.read` task: cluster singleton, subscribed to one filer at a time. ~~`Filer.CollectLogFileRefs` returns chunk refs in directory order; the worker uses `pb.ReadLogFileRefsWithPosition` (Task #19) to heap-merge per-filer chunks by event ts client-side, with per-shard `MessagePosition` skip filtering.~~ The shipped task uses a direct `SubscribeMetadata` subscription; the filer server-side does the multi-filer merge. Cursor is one global `(ts_ns, offset)` position resumed via `SinceNs`. Failover to another filer endpoint on connection error; cursor is portable (one ts). Periodic checkpointing bounds crash redo.
- ~~**Tail-drain tests required in this phase**~~ — obsoleted by the single-cursor architecture; see Phase 6 for context.
- Implement `s3.lifecycle.read` task: cluster singleton, subscribed to one filer at a time. `Filer.CollectLogFileRefs` returns chunk refs in directory order; the worker uses `pb.ReadLogFileRefsWithPosition` (Task #19) to heap-merge per-filer chunks by event ts client-side, with per-shard `MessagePosition` skip filtering. Worker reads chunk bodies in parallel from volume servers. Cursors are per-filer-shard: `last_processed_original: map<delay_seconds, map<filer_id, MessagePosition>>` and `last_processed_predicate: map<filer_id, MessagePosition>` — `LogEntry.Offset` is per-buffer (per filerId), not globally unique, so per-shard cursors are required. Resume skips events with `(ts, offset) <= cursor[filer_id]` (strict `<=`). Listing uses the low-water-mark across per-filer cursors. New shards seeded lazily via `seedNewlyDiscoveredShardsLazily` using `EarliestRetainedPositionPerShard` (Task #19), and seeding clears any matching `tail_drained_streams` marker. Tail-drain GC at task entry uses `RetainedLogRangePerShard` (Task #19) to remove cursor entries where `cursor >= range.latest`, record the matching `tail_drained_streams` key, and downgrade to `scan_only` with `LOST_LOG` if logs vanished before a stream caught up. Failover to another filer endpoint on connection error; per-filer cursor map is portable. Periodic checkpointing (default every 1000 events or 5s) bounds crash redo.
- **Tail-drain tests required in this phase**: (a) cursor at exactly `range.latest` for a shard → tail-drained, entry GC'd, matching `tail_drained_streams` key updated, low-water-mark recovers; (b) cursor before `range.latest` → not GC'd, entry preserved; (c) shard removed from cluster but logs still retained → not GC'd until each stream cursor catches up to `range.latest`; (d) verify low-water-mark advances after GC (otherwise the tail-drain wasn't effective); (e) **lost-log**: shard's logs pruned with a stream cursor still behind `range.latest` and no matching `tail_drained_streams[...]` marker → cursor NOT GC'd, all reader-driven rules promoted to `mode = scan_only` with `degraded_reason = LOST_LOG`, `lifecycle_lost_log_total{filer_id}` increments, warning log emitted; verify `s3.lifecycle.reseed -ack-lost-log --reason <text>` is the only path that clears the lost-log cursor entries and degraded flag together; (f) cross-stream guard: a tail-drained 30-day original stream does not permit GC of the same filer's 60-day original stream or predicate stream; (g) rejoin guard: when a previously tail-drained shard reappears and is lazily seeded, the matching `tail_drained_streams` marker is cleared.
- **Retention mode-gate tests** (per `eventLogHorizon` table, kinds beyond age-based): inject a rule of each reader-driven kind and toggle `metaLogRetention` above and below `eventLogHorizon(rule) + bootstrapLookbackMin`; verify the rule is `event_driven` above and `scan_only` with `degraded_reason = RETENTION_BELOW_HORIZON` below. Cover `EXPIRATION_DAYS`, `NONCURRENT_DAYS`, `ABORT_MPU`, `NEWER_NONCURRENT`, `EXPIRED_DELETE_MARKER`. Verify `EXPIRATION_DATE` bypasses the gate regardless of retention.
- Engine drives event routing: `engine.MatchOriginalWrite(event, delay=D)` and `engine.MatchPredicateChange(event)`.
- **One sweep per delay group** (e.g. `1d`, `30d`, `60d`, `90d`); each sweep advances `reader_state.last_processed_original[D.seconds]` (a single global `(ts_ns, offset)` `MessagePosition`). Single predicate-change sweep clamped to `min(now - smallDelay, now - flushSafetyLag)` advances `reader_state.last_processed_predicate` (also a single global position).
- **One sweep per delay group** (e.g. `1d`, `30d`, `60d`, `90d`); each sweep advances `reader_state.last_processed_original[D.seconds]` (a `map<filer_id, MessagePosition>` — per-shard cursors, not a single position). Single predicate-change sweep clamped to `min(now - smallDelay, now - flushSafetyLag)` advances `reader_state.last_processed_predicate` (also per-shard).
- One filer fetch per event (`fetchLiveEntryOnce`), reused across all candidate rules.
- Per-event resolution-or-stop: `Resolved` outcomes advance the cursor; `TRANSPORT_ERROR` halts the batch and the cursor stays.
- Engine refreshes when the reader observes a lifecycle-xattr `UpdateEntry` event.
@@ -1949,26 +1941,20 @@ Phasing changed: **the back-stamp cannot be removed until the worker can take ov
- `AbortIncompleteMultipartUpload` driven by events under `.uploads/`.
- Tests covering: new version PUT, delete marker creation, non-current expiration, expired-delete-marker, MPU abort.
## Phase 6 — Multi-filer durable replay (obsoleted)
## Phase 6 — Multi-filer durable replay (mostly done in Phase 3)
**Status: not built. Architecture moved on; the production reader uses the filer's existing aggregation surface.**
The shared-reader design (Phase 3) already reads from the shared filer namespace, so multi-filer correctness is largely subsumed. This phase fills in:
What the original Phase 6 set out to add — per-filer-shard cursor maps, `EarliestRetainedPositionPerShard` and `RetainedLogRangePerShard` filer RPCs, `tail_drained_streams` GC, `CollectLogFileRefs` heap-merge, lost-log degraded promotion — was designed under the assumption that the lifecycle worker would have to assemble its own multi-filer view because `MetaAggregator` is in-memory only.
`SubscribeMetadata` already does that work server-side: when the filer has peer filers it reads disk-persisted logs from every filer first (via `sendLogFileRefs` → `Filer.CollectLogFileRefs`) and only then drains the in-memory aggregator. A single subscription returns a durable, multi-filer-merged stream. The Phase 3 shipped reader (`weed/s3api/s3lifecycle/reader/reader.go`) consumes that stream with one global `(ts_ns, offset)` cursor and never needed the per-shard cursor map.
What the design's per-shard architecture would have bought us, and why each piece is no longer required:
| Phase 6 mechanism | Why it's no longer required |
|---|---|
| Per-filer-shard cursor map | Single global cursor suffices because the filer presents one merged stream. Resume-replay overhead is bounded by `LogFlushInterval` (~1m) and the dispatcher's tick interval (~200ms), so the worst-case redundant work per batch is one minute of events that the cursor compare immediately skips. |
| `RetainedLogRangePerShard` / `EarliestRetainedPositionPerShard` filer RPCs | No per-shard cursor → no need for per-shard probe. `SubscribeMetadata` already returns `ResumeFromDiskError` when the cursor predates retained logs. |
| Tail-drain GC + `tail_drained_streams` | No per-shard cursor → no entries to GC. Departed filers' events are absorbed into the aggregated stream and the global cursor advances past them. |
| Lazy seeding `seedNewlyDiscoveredShardsLazily` | No per-shard cursor → no shards to seed. New filers' events appear automatically through the aggregator. |
Forward-compat stubs that stay in the proto: `LifecycleState.DegradedReason.LOST_LOG`, `ReaderState.tail_drained_streams`, `last_processed_original` keyed by `filer_id`. They cost nothing and keep the on-disk state file readable if a future deployment ever needs a per-shard architecture.
The single residual gap — and the only piece worth building — is auto-degrading the reader when `SubscribeMetadata` returns `ResumeFromDiskError`. Tracked as a separate narrow follow-up (catch the error in `reader.Run`, mark reader-driven actions `scan_only` with `degraded_reason = LOST_LOG`, increment the counter, advance the cursor to retained-earliest, expose `s3.lifecycle.reseed -ack-lost-log` to flip back to event_driven once safety scans cover the gap). It is a ~50-line change, not a Phase.
- Per-shard cursors keyed by `filerId` are already in the reader-state proto (Phase 3).
- Filer-set discovery: list `/topics/.system/log/<date>/` and observe filerId suffixes (Phase 3).
- Tail-drain GC implementation using `RetainedLogRangePerShard` (Task #19): cursor entries where `cursor >= range.latest` are removed at task entry AND the stream-specific key is recorded in `reader_state.tail_drained_streams`. Phase 3 tests verify single-shard tail-drain; Phase 6 adds multi-filer convergence tests:
- Two filers actively writing, racing writes against the same key — events from both shards merged correctly, per-shard cursors advance independently.
- Filer A departs (stops writing); its logs are still retained → cursor for A continues to advance until A's `range.latest`; not GC'd before then.
- Filer A reaches `range.latest` for one stream (e.g. original delay 30d), then its logs are pruned: probe returns no range, but the matching `tail_drained_streams[(ORIGINAL,30d,A)]` entry is set → that stream cursor is GC'd as safe; no degradation.
- Filer A later reappears and writes retained logs for that same stream: lazy seeding creates a new cursor and clears `tail_drained_streams[(ORIGINAL,30d,A)]`, so the old safe-drain marker cannot mask a future lost-log event.
- **Lost-log: Filer A's logs are pruned BEFORE another stream cursor reaches `range.latest`** (probe returns no range AND the matching `tail_drained_streams[...]` entry is unset): the reader does NOT GC that cursor; instead, all reader-driven rules are promoted to `mode = scan_only` with `degraded_reason = LOST_LOG`; `lifecycle_lost_log_total{filer_id="A"}` increments; warning logged. Verify cursor stays put until `s3.lifecycle.reseed -ack-lost-log --reason <text>` clears the lost-log cursor entries and degraded flag.
- Worker restart mid-merge → resumes per-filer cursors correctly; both shards continue from their persisted positions; `tail_drained_streams` survives restart.
- Failover to a different primary filer mid-batch — cursor portable, no events skipped or replayed.
## Phase 7 — Observability
@@ -1993,7 +1979,7 @@ The single residual gap — and the only piece worth building — is auto-degrad
- Date-rule deletions (`SCAN_AT_DATE` mode: a single scheduled bucket-level bootstrap at `rule.date` deletes all matches inline).
- Periodic safety scans (re-run bootstrap on the per-kind cadence) catch drift over time.
Not-yet-due age-based discovery via meta-log events arrives in Phase 3 (the reader). Versioned buckets remain unhandled until Phase 5; the worker skips them with a logged warning until then. The existing back-stamp keeps simple `Expiration.Days` working in parallel.
- Phase 3 brings steady-state event-driven expiration. The reader is a cluster singleton subscribed to one filer via `SubscribeMetadata`, which the filer's `MetaAggregator` already merges across peer filers. Single global `(ts_ns, offset)` cursor; multi-filer clusters work the same as single-filer clusters from this point forward.
- Phase 3 brings steady-state event-driven expiration. The reader is a cluster singleton subscribed to one filer; the worker heap-merges per-filer log chunks client-side via `pb.ReadLogFileRefsWithPosition` (Task #19) with per-shard `MessagePosition` cursors. Multi-filer clusters work the same as single-filer clusters from this point forward.
- Phase 4 makes `PUT` constant-time. Required dependency: Phase 2 + Phase 3 shipped and stable.
- Phases 5–8 complete the surface.
@@ -2087,11 +2073,16 @@ test-fast: build-weed-10sec
Run: `make -C test/s3/lifecycle test-with-server`. Phase mapping: end-to-end tests land in the same phase as the capability they cover.
## Layer 4 — Multi-filer convergence (obsoleted)
## Layer 4 — Multi-filer convergence (Phase 6 only)
**Not built.** Phase 6 was obsoleted by `SubscribeMetadata`'s server-side aggregation (see the Phase 6 section). With a single global cursor on a server-merged stream, there are no per-filer-shard convergence properties for the worker to test — any merge correctness lives inside the filer's `MetaAggregator`, which has its own coverage in `weed/filer/`.
Location: `test/plugin_workers/lifecycle/multi_filer_test.go` (component) plus a multi-filer scenario in `test/s3/lifecycle/` if needed.
If `ResumeFromDiskError` auto-degrade lands as a follow-up (task #21), its tests live with the reader package, not in a separate multi-filer harness.
Patterns to copy: `test/multi_master/`, `test/metadata_subscribe/` — both spin up multi-node clusters in-process.
Cases:
- Racing writes to the same key on different filers, **including events with identical `event.ts`** — reader merges per-filer logs by `(event.ts, filer_id, event.offset)` (deterministic ordering across shards because `event.offset` is per-buffer/per-filer and not globally unique). Test asserts the same merge order across runs and that equal-ts events from different filers don't swap relative position.
- Worker restart mid-merge → resumes per-filer watermarks correctly.
- Filer leaves cluster → topology refresh skips it; remaining filers' logs continue.
## CI gating
-4
View File
@@ -42,10 +42,6 @@ RUN if [ -f "/prebuilt/weed-volume-${TARGETARCH}" ]; then \
echo "Skipping Rust build for $TARGETARCH (unsupported)" && \
touch /weed-volume; \
fi
# Pre-built binaries arrive via GitHub Actions artifacts, which drop the
# executable bit, so the copied file is 0644 and exec fails with "Permission
# denied". Restore it (no-op for the empty placeholder, which stays size 0).
RUN chmod 0755 /weed-volume
FROM alpine AS final
LABEL author="Chris Lu"
+76 -80
View File
@@ -15,6 +15,7 @@ require (
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1
github.com/eapache/go-resiliency v1.6.0 // indirect
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect
@@ -33,7 +34,7 @@ require (
github.com/google/btree v1.1.3
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/gorilla/mux v1.8.1
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -45,7 +46,7 @@ require (
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12
github.com/karlseguin/ccache/v2 v2.0.8
github.com/klauspost/compress v1.18.6
github.com/klauspost/compress v1.18.5
github.com/klauspost/reedsolomon v1.14.0
github.com/kurin/blazer v0.5.3
github.com/linxGnu/grocksdb v1.10.7
@@ -60,14 +61,14 @@ require (
github.com/posener/complete v1.2.3
github.com/pquerna/cachecontrol v0.2.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.20.1
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/seaweedfs/goexif v1.0.3
github.com/seaweedfs/raft v1.1.8
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/viper v1.21.0
@@ -91,16 +92,16 @@ require (
gocloud.dev v0.45.0
gocloud.dev/pubsub/natspubsub v0.45.0
gocloud.dev/pubsub/rabbitpubsub v0.45.0
golang.org/x/crypto v0.51.0
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
golang.org/x/net v0.54.0
golang.org/x/crypto v0.50.0
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
golang.org/x/image v0.38.0
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.44.0
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/sys v0.43.0
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.278.0
google.golang.org/api v0.274.0
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
@@ -112,7 +113,7 @@ require (
)
require (
cloud.google.com/go/kms v1.31.0
cloud.google.com/go/kms v1.30.0
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0
github.com/Jille/raft-grpc-transport v1.6.1
github.com/ThreeDotsLabs/watermill v1.5.1
@@ -122,14 +123,14 @@ require (
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 v1.41.6
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/cognusion/imaging v1.0.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
github.com/cognusion/imaging v1.0.2
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-git/go-billy/v5 v5.8.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
@@ -140,19 +141,19 @@ require (
github.com/linkedin/goavro/v2 v2.15.0
github.com/minio/crc64nvme v1.1.1
github.com/orcaman/concurrent-map/v2 v2.0.1
github.com/parquet-go/parquet-go v0.30.1
github.com/parquet-go/parquet-go v0.28.0
github.com/pkg/sftp v1.13.10
github.com/rabbitmq/amqp091-go v1.11.0
github.com/rclone/rclone v1.74.1
github.com/rabbitmq/amqp091-go v1.10.0
github.com/rclone/rclone v1.73.5
github.com/rdleal/intervalst v1.5.0
github.com/redis/go-redis/v9 v9.19.0
github.com/redis/go-redis/v9 v9.18.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/shirou/gopsutil/v4 v4.26.2
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 v0.0.3
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
@@ -175,9 +176,8 @@ require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.39 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.38 // indirect
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/apache/arrow-go/v18 v18.5.2-0.20260220015023-a886a5722b87 // indirect
@@ -190,7 +190,8 @@ require (
github.com/boombuler/barcode v1.1.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cockroachdb/apd/v3 v3.2.1 // indirect
github.com/cockroachdb/errors v1.11.3 // indirect
github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect
@@ -223,7 +224,7 @@ require (
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260331173834-036f908d0160 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -256,10 +257,10 @@ require (
github.com/pierrre/geohash v1.0.0 // indirect
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/quic-go/quic-go v0.57.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/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sasha-s/go-deadlock v0.3.1 // indirect
@@ -284,56 +285,56 @@ require (
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
golang.org/x/mod v0.35.0 // indirect
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
gonum.org/v1/gonum v0.17.0 // indirect
)
require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth v0.19.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.7.0 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.7.0
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
github.com/Files-com/files-sdk-go/v3 v3.3.82 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
github.com/IBM/go-sdk-core/v5 v5.21.2 // indirect
github.com/IBM/go-sdk-core/v5 v5.21.0 // indirect
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/abbot/go-http-auth v0.4.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
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/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // 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/feature/s3/manager v1.22.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // 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/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // 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
@@ -347,13 +348,13 @@ require (
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.15.0 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.13.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/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
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 // indirect
github.com/d4l3k/messagediff v1.2.1 // indirect
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
@@ -367,7 +368,7 @@ require (
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
@@ -375,19 +376,19 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/errors v0.22.6 // indirect
github.com/go-openapi/errors v0.22.4 // indirect
github.com/go-openapi/strfmt v0.25.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0
@@ -404,7 +405,7 @@ require (
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb // indirect
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
@@ -418,9 +419,9 @@ require (
github.com/lanrat/extsort v1.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lpar/date v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4
@@ -435,8 +436,8 @@ require (
github.com/oklog/ulid v1.3.1 // indirect
github.com/onsi/ginkgo/v2 v2.23.3 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/oracle/oci-go-sdk/v65 v65.111.0 // indirect
github.com/panjf2000/ants/v2 v2.11.5 // indirect
github.com/oracle/oci-go-sdk/v65 v65.104.0 // indirect
github.com/panjf2000/ants/v2 v2.11.3 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
@@ -452,7 +453,7 @@ require (
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
github.com/relvacode/iso8601 v1.7.0 // indirect
github.com/rfjakob/eme v1.2.0 // indirect
github.com/rfjakob/eme v1.1.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
@@ -465,11 +466,11 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
github.com/tarantool/go-iproto v1.1.0 // indirect
github.com/tiancaiamao/gp v0.0.0-20221230034425-4025bc8a4d4a // indirect
github.com/tikv/pd/client v0.0.0-20230329114254-1948c247c2b1 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/tinylib/msgp v1.5.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twmb/murmur3 v1.1.8 // indirect
@@ -489,8 +490,8 @@ require (
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/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/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
@@ -498,10 +499,10 @@ require (
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/term v0.43.0
golang.org/x/time v0.15.0
golang.org/x/term v0.42.0
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
@@ -509,17 +510,12 @@ require (
modernc.org/libc v1.72.0 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
storj.io/common v0.0.0-20260225132117-99155641c30a // indirect
storj.io/common v0.0.0-20251107171817-6221ae45072c // indirect
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
storj.io/infectious v0.0.2 // indirect
storj.io/picobuf v0.0.4 // indirect
storj.io/uplink v1.14.0 // indirect
storj.io/uplink v1.13.1 // indirect
)
// replace github.com/seaweedfs/raft => /Users/chrislu/go/src/github.com/seaweedfs/raft
// apache/thrift v0.23.0 uses math.MaxUint32 as an untyped int constant in
// lib/go/thrift/framed_transport.go, which overflows int on 32-bit GOARCHes
// (e.g. openbsd/arm, linux/arm). Pin to v0.22.0 until upstream fixes it.
replace github.com/apache/thrift => github.com/apache/thrift v0.22.0
+152 -154
View File
@@ -94,8 +94,8 @@ cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVo
cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo=
cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0=
cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ=
cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0=
@@ -298,8 +298,8 @@ cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxs
cloud.google.com/go/kms v1.5.0/go.mod h1:QJS2YY0eJGBg3mnDfuaCyLauWwBJiHRboYxJ++1xJNg=
cloud.google.com/go/kms v1.6.0/go.mod h1:Jjy850yySiasBUDi6KFUwUv2n1+o7QZFyuUJg6OgjA0=
cloud.google.com/go/kms v1.9.0/go.mod h1:qb1tPTgfF9RQP8e1wq4cLFErVuTJv7UsSC915J8dh3w=
cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE=
cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U=
cloud.google.com/go/kms v1.30.0 h1:TSEZopy/OXojbVCQS9Rrx0jFqdKWvO31+N8ncXbxG/s=
cloud.google.com/go/kms v1.30.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U=
cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic=
cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI=
cloud.google.com/go/language v1.7.0/go.mod h1:DJ6dYN/W+SQOjF8e1hLQXMF21AkH2w9wiPzPCJa2MIE=
@@ -553,24 +553,24 @@ gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zum
git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.7.0 h1:BM85pSYlVYQHdq00nxyPoOkyLF5NArJG3bOsrmbwr4k=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.7.0/go.mod h1:QYjP2cB7ZYtS/8jAbE0VSBZde/tjExqGjp+8JY6/+ts=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4 h1:tZh20RjgfMxKBxJiIS75iTVAKIUxrST5X2dVHMTptL4=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4/go.mod h1:vGYAk36rhMVCfTP7v+RVruCR0zmPe6S+36KRpDCLySw=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 h1:sxgSqOB9CDToiaVFpxuvb5wGgGqWa3lCShcm5o0n3bE=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3/go.mod h1:XdED8i399lEVblYHTZM8eXaP07gv4Z58IL6ueMlVlrg=
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=
@@ -581,8 +581,8 @@ github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDP
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=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Codefor/geohash v0.0.0-20140723084247-1b41c28e3a9d h1:iG9B49Q218F/XxXNRM7k/vWf7MKmLIS8AcJV9cGN4nA=
@@ -593,10 +593,10 @@ github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3
github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e h1:rd4bOvKmDIx0WeTv9Qz+hghsgyjikFiPrseXHlKepO0=
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e/go.mod h1:blbwPQh4DTlCZEfk1BLU4oMIhLda2U+A840Uag9DsZw=
github.com/FilenCloudDienste/filen-sdk-go v0.0.39 h1:tgV5jYL6dsXop9TpDTIQU6UwJjws122HrwskaEE/igY=
github.com/FilenCloudDienste/filen-sdk-go v0.0.39/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
github.com/Files-com/files-sdk-go/v3 v3.3.82 h1:2RfP0d2QgkFH64BjZSWd59aMsc28IyWsUuHqU0txBtY=
github.com/Files-com/files-sdk-go/v3 v3.3.82/go.mod h1:IPk80dOmc7VFC0DJ85xMTPmre+8xoXX6kGHAkf5jRRw=
github.com/FilenCloudDienste/filen-sdk-go v0.0.38 h1:YCyHs3wUXEe2BEWn40vcoyaQ2ruHNmNwkasfo3Th16A=
github.com/FilenCloudDienste/filen-sdk-go v0.0.38/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
github.com/Files-com/files-sdk-go/v3 v3.2.264 h1:lMHTplAYI9FtmCo/QOcpRxmPA5REVAct1r2riQmDQKw=
github.com/Files-com/files-sdk-go/v3 v3.2.264/go.mod h1:wGqkOzRu/ClJibvDgcfuJNAqI2nLhe8g91tPlDKRCdE=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
@@ -605,8 +605,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/IBM/go-sdk-core/v5 v5.21.2 h1:mJ5QbLPOm4g5qhZiVB6wbSllfpeUExftGoyPek2hk4M=
github.com/IBM/go-sdk-core/v5 v5.21.2/go.mod h1:ngpMgwkjur1VNUjqn11LPk3o5eCyOCRbcfg/0YAY7Hc=
github.com/IBM/go-sdk-core/v5 v5.21.0 h1:DUnYhvC4SoC8T84rx5omnhY3+xcQg/Whyoa3mDPIMkk=
github.com/IBM/go-sdk-core/v5 v5.21.0/go.mod h1:Q3BYO6iDA2zweQPDGbNTtqft5tDcEpm6RTuqMlPcvbw=
github.com/Jille/raft-grpc-transport v1.6.1 h1:gN3sjapb+fVbiebS7AfQQgbV2ecTOI7ur7NPPC7Mhoc=
github.com/Jille/raft-grpc-transport v1.6.1/go.mod h1:HbOjEdu/yzCJ/mjTF6wEOJNbAUpHfU2UOA2hVD4CNFg=
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
@@ -633,16 +633,16 @@ github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e h1:lCsqUUACrcMC83lg5rTo9Y0PnPItE61JSfvMyIcANwk=
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/ProtonMail/gopenpgp/v2 v2.9.0 h1:ruLzBmwe4dR1hdnrsEJ/S7psSBmV15gFttFUPP/+/kE=
github.com/ProtonMail/gopenpgp/v2 v2.9.0/go.mod h1:IldDyh9Hv1ZCCYatTuuEt1XZJ0OPjxLpTarDfglih7s=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/Sereal/Sereal/Go/sereal v0.0.0-20231009093132-b9187f1a92c6/go.mod h1:JwrycNnC8+sZPDyzM3MQ86LvaGzSpfxg885KOOwFRW4=
github.com/Shopify/sarama v1.38.1 h1:lqqPUPQZ7zPqYlWpTh+LQ9bhYNu2xJL6k1SJN4WVe2A=
github.com/Shopify/sarama v1.38.1/go.mod h1:iwv9a67Ha8VNa+TifujYoWGxWnu2kNVAQdSdZ4X2o5g=
@@ -656,14 +656,12 @@ github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 h1:iLDOF0rdGTrol/q8OfPIIs5kLD8XvA2q75o6Uq/tgak=
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:DrEWcQJjz7t5iF2duaiyhg4jyoF0kxOD6LtECNGkZ/Q=
github.com/aalpar/deheap v1.1.2 h1:MABHLcnjqsffb8GLkUFDigqpBBxOMz0DoKM9QfELeTw=
github.com/aalpar/deheap v1.1.2/go.mod h1:A+nfkD4JbS05sewV0he/MYgR/90vfqyMoNNROgs+rmA=
github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3 h1:hhdWprfSpFbN7lz3W1gM40vOgvSh1WCSMxYD6gGB4Hs=
github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3/go.mod h1:XaUnRxSCYgL3kkgX0QHIV0D+znljPIDImxlv2kbGv0Y=
github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0=
github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
@@ -698,8 +696,9 @@ github.com/apache/cassandra-gocql-driver/v2 v2.1.0 h1:VEbbeJ2ift4deKMZ6Fs55Vs3fq
github.com/apache/cassandra-gocql-driver/v2 v2.1.0/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=
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
github.com/apache/thrift v0.23.0 h1:wKR6YnefQSEnxpEfmgTPuJibNG4bF0p2TK34tHLWi3s=
github.com/apache/thrift v0.23.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3 h1:WZaTKNHCfcw7fWSR6/RKnCldVzvYZC+Y20Su4lffEIg=
@@ -715,36 +714,36 @@ 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 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
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/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/feature/s3/manager v1.22.1 h1:IbWiN670htmBioc+Zj32vSpJgQ2+OYSlvTvfQ1nCORQ=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1/go.mod h1:tw/B596EUhBWDFGdDGuLC21fVU4A3s4/5Efy8S39W18=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
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/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
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=
@@ -828,13 +827,15 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudinary/cloudinary-go/v2 v2.15.0 h1:iLoIwb7BJECHTbNcmIhYDsQhoZiACWGNvEpyqQy97Dk=
github.com/cloudinary/cloudinary-go/v2 v2.15.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo=
github.com/cloudinary/cloudinary-go/v2 v2.13.0 h1:ugiQwb7DwpWQnete2AZkTh94MonZKmxD7hDGy1qTzDs=
github.com/cloudinary/cloudinary-go/v2 v2.13.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo=
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc h1:t8YjNUCt1DimB4HCIXBztwWMhgxr5yG5/YaRl9Afdfg=
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc/go.mod h1:CgWpFCFWzzEA5hVkhAc6DZZzGd3czx+BblvOzjmg6KA=
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqLeM7jFBSw/Or81UEElFqO8MY+GDs=
@@ -866,8 +867,8 @@ github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwP
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/version v0.0.0-20250314144055-3860cd14adf2 h1:8Vfw2iNEpYIV6aLtMwT5UOGuPmp9MKlEKWKFTuB+MPU=
github.com/cockroachdb/version v0.0.0-20250314144055-3860cd14adf2/go.mod h1:P9WiZOdQ1R/ZZDL0WzF5wlyRvrjtfhNOwMZymFpBwjE=
github.com/cognusion/imaging v1.0.3 h1:nHyIeEVDV8JkBbuhgx8iSBW72W8rHbDEyruA0Jh7Lnk=
github.com/cognusion/imaging v1.0.3/go.mod h1:38tFLFhGK81ORThZG8dVXPtp1uhd+3xM1WXuMn+unOA=
github.com/cognusion/imaging v1.0.2 h1:BQwBV8V8eF3+dwffp8Udl9xF1JKh5Z0z5JkJwAi98Mc=
github.com/cognusion/imaging v1.0.2/go.mod h1:mj7FvH7cT2dlFogQOSUQRtotBxJ4gFQ2ySMSmBm5dSk=
github.com/colinmarc/hdfs/v2 v2.4.0 h1:v6R8oBx/Wu9fHpdPoJJjpGSUxo8NhHIwrwsfhFvU9W0=
github.com/colinmarc/hdfs/v2 v2.4.0/go.mod h1:0NAO+/3knbMx6+5pCv+Hcbaz4xn/Zzbn9+WIib2rKVI=
github.com/compose-spec/compose-go/v2 v2.9.0 h1:UHSv/QHlo6QJtrT4igF1rdORgIUhDo1gWuyJUoiNNIM=
@@ -904,9 +905,8 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 h1:tLWBZgPg6TV67oe76W4p+aUQEWIa52wbcuiz8GFd3vo=
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 h1:iwZdTE0PVqJCos1vaoKsclOGD3ADKpshg3SRtYBbwso=
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U=
@@ -1047,8 +1047,8 @@ github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQ
github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI=
@@ -1071,8 +1071,8 @@ github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3
github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
github.com/go-fonts/liberation v0.2.0/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -1096,8 +1096,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo=
github.com/go-openapi/errors v0.22.6/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
github.com/go-openapi/errors v0.22.4 h1:oi2K9mHTOb5DPW2Zjdzs/NIvwi2N3fARKaTJLdNabaM=
github.com/go-openapi/errors v0.22.4/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
@@ -1116,8 +1116,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI=
@@ -1126,8 +1126,8 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-redsync/redsync/v4 v4.16.0 h1:bNcOzeHH9d3s6pghU9NJFMPrQa41f5Nx3L4YKr3BdEU=
github.com/go-redsync/redsync/v4 v4.16.0/go.mod h1:V4gagqgyASWBZuwx4xGzu72aZNb/6Mo05byUa3mVmKQ=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -1136,8 +1136,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
github.com/go-zookeeper/zk v1.0.4 h1:DPzxraQx7OrPyXq2phlGlNSIyWEsAox0RJmjTseMV6I=
github.com/go-zookeeper/zk v1.0.4/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
@@ -1284,8 +1284,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY
github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@@ -1296,8 +1296,8 @@ github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK
github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo=
github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY=
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
@@ -1397,8 +1397,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s=
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4=
github.com/internxt/rclone-adapter v0.0.0-20260331173834-036f908d0160 h1:PV6ipOJN0JIek4dqPqsTtenQmjI1SZntCUgPzhfk9gM=
github.com/internxt/rclone-adapter v0.0.0-20260331173834-036f908d0160/go.mod h1:yRuPDnHgGmsbvopF0amMqXxr4n32GynzX5GTwFYDHaw=
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd h1:dSIuz2mpJAPQfhHYtG57D0qwSkgC/vQ69gHfeyQ4kxA=
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd/go.mod h1:vdPya4AIcDjvng4ViaAzqjegJf0VHYpYHQguFx5xBp0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -1429,8 +1429,8 @@ github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb h1:6vkM8gO+zFV2m21QzGYyUSq5TP0VQgP2Xz3UQyCN2kI=
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb/go.mod h1:H1+whwD0Qe3YOunlXIWhh3rlvzW5cZfkMDYGQPg+KAM=
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 h1:ZxO6Qr2GOXPdcW80Mcn3nemvilMPvpWqxrNfK2ZnNNs=
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3/go.mod h1:dvLUr/8Fs9a2OBrEnCC5duphbkz/k/mSy5OkXg3PAgI=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
@@ -1475,8 +1475,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
@@ -1523,8 +1523,8 @@ github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJV
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
github.com/lpar/date v1.0.0 h1:bq/zVqFTUmsxvd/CylidY4Udqpr9BOFrParoP6p0x/I=
github.com/lpar/date v1.0.0/go.mod h1:KjYe0dDyMQTgpqcUz4LEIeM5VZwhggjVx/V2dtc8NSo=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o=
@@ -1544,8 +1544,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk=
github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
@@ -1657,18 +1657,18 @@ github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
github.com/oracle/oci-go-sdk/v65 v65.111.0 h1:eDkWg6ZN0uKwWzSekoFcQJhR+C+F/aVdTwr+lGHU9Qk=
github.com/oracle/oci-go-sdk/v65 v65.111.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
github.com/oracle/oci-go-sdk/v65 v65.104.0 h1:l9awEvzWvxmYhy/97A0hZ87pa7BncYXmcO/S8+rvgK0=
github.com/oracle/oci-go-sdk/v65 v65.104.0/go.mod h1:oB8jFGVc/7/zJ+DbleE8MzGHjhs2ioCz5stRTdZdIcY=
github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c=
github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM=
github.com/panjf2000/ants/v2 v2.11.5 h1:a7LMnMEeux/ebqTux140tRiaqcFTV0q2bEHF03nl6Rg=
github.com/panjf2000/ants/v2 v2.11.5/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
github.com/parquet-go/parquet-go v0.30.1 h1:Oy6ganNrAdFiVwy7wNmWagfPTWA2X9Z3tVHBc7JtuX8=
github.com/parquet-go/parquet-go v0.30.1/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=
github.com/parquet-go/parquet-go v0.28.0 h1:ECyksyv8T2pOrlLsN7aWJIoQakyk/HtxQ2lchgS4els=
github.com/parquet-go/parquet-go v0.28.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
@@ -1755,8 +1755,8 @@ github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
@@ -1779,24 +1779,24 @@ github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
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/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.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=
github.com/rclone/go-proton-api v1.0.2/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc=
github.com/rclone/rclone v1.74.1 h1:QQI//tl4O5uooTowdKwNKcv5qCQvJ3LIBG00+TKeXw0=
github.com/rclone/rclone v1.74.1/go.mod h1:44epaKf5w/1ZQounefr76wx4+y9NZmgfMKle9T3pzj0=
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 h1:4MI2alxM/Ye2gIRBlYf28JGWTipZ4Zz7yAziPKrttjs=
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11/go.mod h1:3HLX7dwZgvB7nt+Yl/xdzVPcargQ1yBmJEUg3n+jMKM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc=
github.com/rclone/rclone v1.73.5 h1:r8a9JHYIWUqk7hNRJuMJ3cROkKfB2zmfkADg8ZLYh6I=
github.com/rclone/rclone v1.73.5/go.mod h1:WVv8gvA/lEl/Y37e8I8yosm7ZY+Szq7ujXbJS8Ol63o=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM=
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.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
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=
@@ -1809,8 +1809,8 @@ github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rfjakob/eme v1.2.0 h1:8dAHL+WVAw06+7DkRKnRiFp1JL3QjcJEZFqDnndUaSI=
github.com/rfjakob/eme v1.2.0/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4=
github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -1856,15 +1856,15 @@ github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b h1:h+3JX2VoWTFuyQ
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b/go.mod h1:/yeG0My1xr/u+HZrFQ1tOQQQQrOawfyMUH13ai5brBc=
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
@@ -1887,8 +1887,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
@@ -1930,8 +1930,8 @@ github.com/substrait-io/substrait-protobuf/go v0.81.0 h1:/qC1XYKuO4oPdTwLYySuVZ6
github.com/substrait-io/substrait-protobuf/go v0.81.0/go.mod h1:hn+Szm1NmZZc91FwWK9EXD/lmuGBSRTJ5IvHhlG1YnQ=
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965 h1:1oFLiOyVl+W7bnBzGhf7BbIv9loSFQcieWWYIjLqcAw=
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965/go.mod h1:9OrXJhf154huy1nPWmuSrkgjPUtUNhA+Zmy+6AESzuA=
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c h1:dtcOwRimeiBFrlutmF6K94l0rxYFARNFMA+lSQ41C+M=
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c/go.mod h1:BF/l2jNyK+2h/BJZ7VLMAz6m/IWjA2F67gTjV1C/+Bo=
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 h1:rrGZv6xYk37hx0tW2sYfgbO0PqStbHqz6Bq6oc9Hurg=
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491/go.mod h1:ykucQyiE9Q2qx1wLlEtZkkNn1IURib/2O+Mvd25i1Fo=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
github.com/tarantool/go-iproto v1.1.0 h1:HULVOIHsiehI+FnHfM7wMDntuzUddO09DKqu2WnFQ5A=
github.com/tarantool/go-iproto v1.1.0/go.mod h1:LNCtdyZxojUed8SbOiYHoc3v9NvaZTB7p96hUySMlIo=
@@ -1961,8 +1961,8 @@ github.com/tikv/pd/client v0.0.0-20230329114254-1948c247c2b1/go.mod h1:3cTcfo8GR
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 h1:QB54BJwA6x8QU9nHY3xJSZR2kX9bgpZekRKGkLTmEXA=
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375/go.mod h1:xRroudyp5iVtxKqZCrA6n2TLFRBf8bmnjr1UD4x+z7g=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
@@ -2028,8 +2028,8 @@ 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 v0.0.3 h1:Z5fHVxMsppgEucdkKBN26Vou19MtEM875NmRwj156RE=
github.com/willscott/go-nfs v0.0.3/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=
@@ -2098,8 +2098,6 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zeebo/mwc v0.0.7 h1:0NerGhCww6ZQx+/xCx5iwznftveokvto1KILpYfENZk=
github.com/zeebo/mwc v0.0.7/go.mod h1:0B32or6moOig1YGuqMoimBpU9QK9uYaGG2bBOuddqtE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
@@ -2130,12 +2128,12 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
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/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/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 h1:0tY123n7CdWMem7MOVdKOt0YfshufLCwfE5Bob+hQuM=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0/go.mod h1:CosX/aS4eHnG9D7nESYpV753l4j9q5j3SL/PUYd2lR8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
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=
@@ -2216,8 +2214,8 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -2233,8 +2231,8 @@ golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -2248,8 +2246,8 @@ golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeap
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20220302094943-723b81ca9867/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -2283,8 +2281,8 @@ golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -2352,8 +2350,8 @@ golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -2511,11 +2509,11 @@ golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -2531,8 +2529,8 @@ golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -2553,8 +2551,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -2632,8 +2630,8 @@ golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools/godoc v0.1.0-deprecated h1:o+aZ1BOj6Hsx/GBdJO/s815sqftjSnrZZwyYTHODvtk=
golang.org/x/tools/godoc v0.1.0-deprecated/go.mod h1:qM63CriJ961IHWmnWa9CjZnBndniPt4a3CK0PVB9bIg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -2711,8 +2709,8 @@ google.golang.org/api v0.106.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/
google.golang.org/api v0.107.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY=
google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY=
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E=
google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA=
google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -2850,8 +2848,8 @@ google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgn
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -3054,8 +3052,8 @@ sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aN
sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
storj.io/common v0.0.0-20260225132117-99155641c30a h1:7gSBQY3vhQMIqi3vfaEXR7mreRjcBLfVsYY0rHIN7P0=
storj.io/common v0.0.0-20260225132117-99155641c30a/go.mod h1:kyxwKwlfH4paBZCZt/szvRB770ieAIJF73iDy2DpEHw=
storj.io/common v0.0.0-20251107171817-6221ae45072c h1:UDXSrdeLJe3QFouavSW10fYdpclK0YNu3KvQHzqq2+k=
storj.io/common v0.0.0-20251107171817-6221ae45072c/go.mod h1:XNX7uykja6aco92y2y8RuqaXIDRPpt1YA2OQDKlKEUk=
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 h1:8OE12DvUnB9lfZcHe7IDGsuhjrY9GBAr964PVHmhsro=
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55/go.mod h1:Y9LZaa8esL1PW2IDMqJE7CFSNq7d5bQ3RI7mGPtmKMg=
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 h1:5MZ0CyMbG6Pi0rRzUWVG6dvpXjbBYEX2oyXuj+tT+sk=
@@ -3064,7 +3062,7 @@ storj.io/infectious v0.0.2 h1:rGIdDC/6gNYAStsxsZU79D/MqFjNyJc1tsyyj9sTl7Q=
storj.io/infectious v0.0.2/go.mod h1:QEjKKww28Sjl1x8iDsjBpOM4r1Yp8RsowNcItsZJ1Vs=
storj.io/picobuf v0.0.4 h1:qswHDla+YZ2TovGtMnU4astjvrADSIz84FXRn0qgP6o=
storj.io/picobuf v0.0.4/go.mod h1:hSMxmZc58MS/2qSLy1I0idovlO7+6K47wIGUyRZa6mg=
storj.io/uplink v1.14.0 h1:J1yXlt0aRr6kgLTHWXOWosNCFVfbamlcyd+CSxyIczo=
storj.io/uplink v1.14.0/go.mod h1:2ysmjzd/1Xtz4VKoErNcSqBQz3UC9WKTVuLMV1cNu6E=
storj.io/uplink v1.13.1 h1:C8RdW/upALoCyuF16Lod9XGCXEdbJAS+ABQy9JO/0pA=
storj.io/uplink v1.13.1/go.mod h1:x0MQr4UfFsQBwgVWZAtEsLpuwAn6dg7G0Mpne1r516E=
tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc=
tags.cncf.io/container-device-interface v1.0.1/go.mod h1:JojJIOeW3hNbcnOH2q0NrWNha/JuHoDZcmYxAZwb2i0=
+2 -2
View File
@@ -1,6 +1,6 @@
apiVersion: v1
description: SeaweedFS
name: seaweedfs
appVersion: "4.27"
appVersion: "4.23"
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
version: 4.27.0
version: 4.23.0
@@ -135,14 +135,6 @@ spec:
{{ toYaml $value | nindent 16 | trim }}
{{- end -}}
{{- end }}
{{- $secretExtraEnvironmentVars := .Values.admin.secretExtraEnvironmentVars }}
{{- if $secretExtraEnvironmentVars }}
{{- range $key := keys $secretExtraEnvironmentVars | sortAlpha }}
{{- $value := index $secretExtraEnvironmentVars $key }}
- name: {{ $key }}
valueFrom: {{ toYaml $value | nindent 16 }}
{{- end }}
{{- end }}
command:
- "/bin/sh"
- "-ec"
@@ -188,13 +180,11 @@ spec:
- name: admin-logs
mountPath: /logs
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
readOnly: true
mountPath: /etc/seaweedfs/security.toml
subPath: security.toml
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/ca/
@@ -285,12 +275,10 @@ spec:
persistentVolumeClaim:
claimName: {{ .Values.admin.logs.claimName }}
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
configMap:
name: {{ include "seaweedfs.fullname" . }}-security-config
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
secret:
secretName: {{ include "seaweedfs.fullname" . }}-ca-cert
@@ -330,13 +330,11 @@ spec:
mountPath: /etc/seaweedfs/master.toml
subPath: master.toml
readOnly: true
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
mountPath: /etc/seaweedfs/security.toml
subPath: security.toml
readOnly: true
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
mountPath: /usr/local/share/ca-certificates/ca/
readOnly: true
@@ -463,12 +461,10 @@ spec:
- name: master-config
configMap:
name: {{ include "seaweedfs.fullname" . }}-master-config
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
configMap:
name: {{ include "seaweedfs.fullname" . }}-security-config
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
secret:
secretName: {{ include "seaweedfs.fullname" . }}-ca-cert
@@ -117,13 +117,11 @@ spec:
name: config-users
readOnly: true
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
readOnly: true
mountPath: /etc/seaweedfs/security.toml
subPath: security.toml
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/ca/
@@ -181,12 +179,10 @@ spec:
secretName: {{ include "seaweedfs.fullname" . }}-s3-secret
{{- end }}
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
configMap:
name: {{ include "seaweedfs.fullname" . }}-security-config
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
secret:
secretName: {{ include "seaweedfs.fullname" . }}-ca-cert
@@ -234,13 +234,11 @@ spec:
mountPath: /etc/seaweedfs/notification.toml
subPath: notification.toml
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
readOnly: true
mountPath: /etc/seaweedfs/security.toml
subPath: security.toml
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/ca/
@@ -276,8 +274,7 @@ spec:
name: swfs-s3-tls
{{- end }}
{{- end }}
{{- $jwt := (.Values.global.seaweedfs.securityConfig).jwtSigning | default dict }}
{{- $isJwtEnabled := or $jwt.filerWrite $jwt.filerRead }}
{{- $isJwtEnabled := or .Values.global.seaweedfs.securityConfig.jwtSigning.filerWrite .Values.global.seaweedfs.securityConfig.jwtSigning.filerRead }}
{{- if .Values.filer.readinessProbe.enabled }}
readinessProbe:
{{- if or $isJwtEnabled .Values.filer.readinessProbe.tcpSocket }}
@@ -371,12 +368,10 @@ spec:
configMap:
name: {{ include "seaweedfs.fullname" . }}-notification-config
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
configMap:
name: {{ include "seaweedfs.fullname" . }}-security-config
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
secret:
secretName: {{ include "seaweedfs.fullname" . }}-ca-cert
@@ -188,13 +188,11 @@ spec:
readOnly: true
mountPath: /etc/seaweedfs/master.toml
subPath: master.toml
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
readOnly: true
mountPath: /etc/seaweedfs/security.toml
subPath: security.toml
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/ca/
@@ -289,12 +287,10 @@ spec:
- name: master-config
configMap:
name: {{ include "seaweedfs.fullname" . }}-master-config
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
configMap:
name: {{ include "seaweedfs.fullname" . }}-security-config
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
secret:
secretName: {{ include "seaweedfs.fullname" . }}-ca-cert
@@ -156,13 +156,11 @@ spec:
name: config-users
readOnly: true
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
readOnly: true
mountPath: /etc/seaweedfs/security.toml
subPath: security.toml
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/ca/
@@ -251,12 +249,10 @@ spec:
- name: logs
emptyDir: {}
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
configMap:
name: {{ include "seaweedfs.fullname" . }}-security-config
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
secret:
secretName: {{ include "seaweedfs.fullname" . }}-ca-cert
@@ -176,13 +176,11 @@ spec:
- mountPath: /etc/sw/ssh
name: config-ssh
readOnly: true
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
readOnly: true
mountPath: /etc/seaweedfs/security.toml
subPath: security.toml
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/ca/
@@ -266,12 +264,10 @@ spec:
- name: logs
emptyDir: {}
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
configMap:
name: {{ include "seaweedfs.fullname" . }}-security-config
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
secret:
secretName: {{ include "seaweedfs.fullname" . }}-ca-cert
@@ -332,16 +332,6 @@ Create the name of the service account to use
{{- .Values.global.seaweedfs.serviceAccountName | default "seaweedfs" -}}
{{- end -}}
{{/* True when security.toml should be rendered and mounted. volumeWrite is
excluded since it defaults to true. */}}
{{- define "seaweedfs.securityConfigEnabled" -}}
{{- $sec := (.Values.global.seaweedfs).securityConfig | default dict -}}
{{- $jwt := $sec.jwtSigning | default dict -}}
{{- if or .Values.global.seaweedfs.enableSecurity $jwt.volumeRead $jwt.filerWrite $jwt.filerRead -}}
true
{{- end -}}
{{- end -}}
{{/* S3 TLS cert/key arguments, using custom secret if s3.tlsSecret is set */}}
{{- define "seaweedfs.s3.tlsArgs" -}}
{{- $prefix := .prefix -}}
@@ -1,5 +1,5 @@
{{- include "seaweedfs.compat" . -}}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
apiVersion: v1
kind: ConfigMap
metadata:
@@ -55,7 +55,6 @@ data:
key = "{{ dig "jwt" "filer_signing" "read" "key" (randAlphaNum 10 | b64enc) $securityConfig }}"
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
# all grpc tls authentications are mutual
# the values for the following ca, cert, and key are paths to the PERM files.
[grpc]
@@ -95,5 +94,4 @@ data:
[https.volume]
cert = ""
key = ""
{{- end }}
{{- end }}
@@ -137,9 +137,6 @@ spec:
- "/bin/sh"
- "-ec"
- |
{{- if $volume.rust }}
exec /usr/bin/weed-volume \
{{- else }}
exec /usr/bin/weed \
{{- if $volume.logs }}
-logdir=/logs \
@@ -152,7 +149,6 @@ spec:
-v={{ $.Values.global.seaweedfs.loggingLevel }} \
{{- end }}
volume \
{{- end }}
-port={{ $volume.port }} \
{{- if $volume.metricsPort }}
-metricsPort={{ $volume.metricsPort }} \
@@ -184,7 +180,7 @@ spec:
{{- if $volume.imagesFixOrientation }}
-images.fix.orientation \
{{- end }}
{{- if and $volume.pulseSeconds (not $volume.rust) }}
{{- if $volume.pulseSeconds }}
-pulseSeconds={{ $volume.pulseSeconds }} \
{{- end }}
{{- if $volume.index }}
@@ -215,13 +211,11 @@ spec:
- name: idx
mountPath: "/idx/"
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" $ }}
{{- if $.Values.global.seaweedfs.enableSecurity }}
- name: security-config
readOnly: true
mountPath: /etc/seaweedfs/security.toml
subPath: security.toml
{{- end }}
{{- if $.Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/ca/
@@ -339,12 +333,10 @@ spec:
emptyDir: {}
{{- end }}
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" $ }}
{{- if $.Values.global.seaweedfs.enableSecurity }}
- name: security-config
configMap:
name: {{ include "seaweedfs.fullname" $ }}-security-config
{{- end }}
{{- if $.Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
secret:
secretName: {{ include "seaweedfs.fullname" $ }}-ca-cert
@@ -149,13 +149,11 @@ spec:
- name: worker-logs
mountPath: /logs
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
readOnly: true
mountPath: /etc/seaweedfs/security.toml
subPath: security.toml
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/ca/
@@ -254,12 +252,10 @@ spec:
persistentVolumeClaim:
claimName: {{ .Values.worker.logs.claimName }}
{{- end }}
{{- if include "seaweedfs.securityConfigEnabled" . }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: security-config
configMap:
name: {{ include "seaweedfs.fullname" . }}-security-config
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
secret:
secretName: {{ include "seaweedfs.fullname" . }}-ca-cert
-14
View File
@@ -18,8 +18,6 @@ global:
loggingLevel: 1
enableSecurity: false
masterServer: null
# filerWrite: true mounts security.toml on filer + admin without needing
# enableSecurity (mTLS); required for the Admin UI Users tab.
securityConfig:
jwtSigning:
volumeWrite: true
@@ -305,11 +303,6 @@ volume:
enabled: true
imageOverride: null
restartPolicy: null
# Run the Rust volume server (/usr/bin/weed-volume) instead of the Go one.
# Requires an image that ships the Rust binary (amd64/arm64). The Go-only
# log flags (-logtostderr/-logdir/-v) and -pulseSeconds are dropped; set log
# level via the RUST_LOG env var in extraEnvironmentVars if needed.
rust: false
port: 8080
grpcPort: 18080
metricsPort: 9327
@@ -1293,13 +1286,6 @@ admin:
extraEnvironmentVars: {}
# secret env variables (e.g. for injecting OIDC client secret from a Kubernetes Secret)
secretExtraEnvironmentVars: {}
# WEED_ADMIN_OIDC_CLIENT_SECRET:
# secretKeyRef:
# name: seaweedfs-admin-oidc
# key: client_secret
# Health checks
livenessProbe:
enabled: true
@@ -22,9 +22,6 @@ service SeaweedFiler {
rpc UpdateEntry (UpdateEntryRequest) returns (UpdateEntryResponse) {
}
rpc TouchAccessTime (TouchAccessTimeRequest) returns (TouchAccessTimeResponse) {
}
rpc AppendToEntry (AppendToEntryRequest) returns (AppendToEntryResponse) {
}
@@ -211,8 +208,6 @@ message FuseAttributes {
int32 mtime_ns = 19; // nanosecond component of mtime (0-999999999)
int32 ctime_ns = 20; // nanosecond component of ctime (0-999999999)
int32 crtime_ns = 21; // nanosecond component of crtime (0-999999999)
int64 atime = 22; // unix time in seconds, last access time
int32 atime_ns = 23; // nanosecond component of atime (0-999999999)
}
message CreateEntryRequest {
@@ -252,16 +247,6 @@ message UpdateEntryResponse {
SubscribeMetadataResponse metadata_event = 1;
}
message TouchAccessTimeRequest {
string directory = 1;
string name = 2;
int64 client_atime_ns = 3; // nanoseconds since epoch; filer may override with relatime
}
message TouchAccessTimeResponse {
int64 persisted_atime_ns = 1; // nanoseconds since epoch; 0 if no update was performed
bool updated = 2;
}
message AppendToEntryRequest {
string directory = 1;
string entry_name = 2;
-1
View File
@@ -458,7 +458,6 @@ message VolumeEcShardsMountRequest {
uint32 volume_id = 1;
string collection = 2;
repeated uint32 shard_ids = 3;
string source_disk_type = 4; // disk type of the source volume, applied to the in-memory EC volume so heartbeats report under it (#9423)
}
message VolumeEcShardsMountResponse {
}
+1 -7
View File
@@ -288,10 +288,6 @@ async fn run(
config.jwt_read_signing_expires_seconds,
);
let master_url = config.masters.first().cloned().unwrap_or_default();
// Defensive-copy the configured seed masters before freezing the lookup
// set, so any later mutation of config.masters cannot desync them.
let master_urls: Vec<String> = config.masters.clone();
let seed_master_set = VolumeServerState::build_seed_master_set(&master_urls);
let self_url = format!("{}:{}", config.ip, config.port);
let (http_client, outgoing_http_scheme) = build_outgoing_http_client(&config)?;
let outgoing_grpc_tls = load_outgoing_grpc_tls(&config)?;
@@ -328,9 +324,7 @@ async fn run(
),
read_mode: config.read_mode,
master_url,
master_urls,
seed_master_set,
current_master_url: tokio::sync::RwLock::new(String::new()),
master_urls: config.masters.clone(),
self_url,
http_client,
outgoing_http_scheme,
-97
View File
@@ -107,57 +107,6 @@ pub fn build_grpc_endpoint(
Ok(endpoint)
}
/// Parse a SeaweedFS server address (`"ip:port.grpcPort"` or
/// `"ip:port"`) into the `host:grpcPort` form `build_grpc_endpoint`
/// expects. With the trailing `.grpcPort` segment, that segment IS
/// the gRPC port; without it, the gRPC port is `port + 10000`
/// (SeaweedFS's HTTP↔gRPC port-offset convention).
///
/// Shared between `grpc_server.rs` and the distributed-EC-read path
/// in `store_ec.rs` — keep this as the single source of truth so the
/// HTTP↔gRPC port translation can't drift between callers.
pub fn parse_grpc_address(source: &str) -> Result<String, String> {
let colon_idx = source
.rfind(':')
.ok_or_else(|| format!("cannot parse address: {}", source))?;
let host = &source[..colon_idx];
let port_part = &source[colon_idx + 1..];
if let Some(dot_idx) = port_part.rfind('.') {
// Format: "ip:port.grpcPort". Validate BOTH ports as u16
// so a malformed HTTP port (e.g. `host:abc.18080`) is
// rejected here rather than tripping a downstream
// `build_grpc_endpoint` URI parse failure with a less
// useful error.
let http_port = &port_part[..dot_idx];
let grpc_port = &port_part[dot_idx + 1..];
http_port
.parse::<u16>()
.map_err(|e| format!("invalid http port {:?}: {}", http_port, e))?;
grpc_port
.parse::<u16>()
.map_err(|e| format!("invalid grpc port {:?}: {}", grpc_port, e))?;
return Ok(format!("{}:{}", host, grpc_port));
}
// Format: "ip:port" → grpc = port + 10000. Reject inputs whose
// implicit grpc port would overflow the TCP port range (e.g.
// `host:60000` produces 70000 — invalid). Without this check
// the cast silently wraps and the endpoint call later fails
// with an opaque connection error.
let port: u16 = port_part
.parse()
.map_err(|e| format!("invalid port {:?}: {}", port_part, e))?;
let grpc_port = port as u32 + 10000;
if grpc_port > u16::MAX as u32 {
return Err(format!(
"implicit grpc port out of range: {} + 10000 = {}",
port, grpc_port
));
}
Ok(format!("{}:{}", host, grpc_port))
}
#[cfg(test)]
mod tests {
use super::{build_grpc_endpoint, grpc_endpoint_uri, load_outgoing_grpc_tls};
@@ -254,50 +203,4 @@ mod tests {
let endpoint = build_grpc_endpoint("127.0.0.1:19333", None).unwrap();
assert_eq!(endpoint.uri().scheme_str(), Some("http"));
}
#[test]
fn test_parse_grpc_address_dotted_form() {
use super::parse_grpc_address;
assert_eq!(
parse_grpc_address("127.0.0.1:8080.18080").unwrap(),
"127.0.0.1:18080"
);
}
#[test]
fn test_parse_grpc_address_implicit_form_adds_10000() {
use super::parse_grpc_address;
assert_eq!(
parse_grpc_address("127.0.0.1:8080").unwrap(),
"127.0.0.1:18080"
);
}
#[test]
fn test_parse_grpc_address_rejects_non_numeric_http_port_in_dotted_form() {
use super::parse_grpc_address;
let err = parse_grpc_address("host:abc.18080").unwrap_err();
assert!(err.contains("invalid http port"), "{}", err);
}
#[test]
fn test_parse_grpc_address_rejects_non_numeric_grpc_port_in_dotted_form() {
use super::parse_grpc_address;
let err = parse_grpc_address("host:8080.xyz").unwrap_err();
assert!(err.contains("invalid grpc port"), "{}", err);
}
#[test]
fn test_parse_grpc_address_rejects_implicit_port_that_overflows() {
use super::parse_grpc_address;
let err = parse_grpc_address("127.0.0.1:60000").unwrap_err();
assert!(err.contains("out of range"), "{}", err);
}
#[test]
fn test_parse_grpc_address_rejects_input_without_colon() {
use super::parse_grpc_address;
let err = parse_grpc_address("hostname").unwrap_err();
assert!(err.contains("cannot parse"), "{}", err);
}
}
+26 -233
View File
@@ -1511,11 +1511,9 @@ impl VolumeServer for VolumeGrpcService {
// EcVolume holds fds on the same inodes, so overwriting
// corrupts live readers.
if store.has_ec_volume(VolumeId(info.volume_id)) {
let mounted_disks =
store.find_ec_volume_disk_ids(VolumeId(info.volume_id));
resp_error = Some(format!(
"ec volume {} is mounted on disk_ids:{:?}; unmount before ReceiveFile",
info.volume_id, mounted_disks
"ec volume {} is mounted; unmount before ReceiveFile",
info.volume_id
));
break;
}
@@ -2614,7 +2612,7 @@ impl VolumeServer for VolumeGrpcService {
let mut store = self.state.store.write().unwrap();
for &shard_id in &req.shard_ids {
store
.mount_ec_shard(vid, &req.collection, shard_id, &req.source_disk_type)
.mount_ec_shard(vid, &req.collection, shard_id)
.map_err(|e| {
Status::internal(format!("mount {}.{}: {}", req.volume_id, shard_id, e))
})?;
@@ -3900,24 +3898,6 @@ impl VolumeServer for VolumeGrpcService {
let start = now_ns();
// Empty target is a self-liveness probe and stays unauthenticated.
// Otherwise gate the dial on cluster membership: volume servers only
// know masters, so any other target type is refused. Mirrors Go's
// volume_grpc_admin.go Ping admission check. tonic forbids returning
// a body alongside an error, so we surface the InvalidArgument status
// alone — behaviour-identical to Go's status.Errorf return.
if !req.target.is_empty()
&& !self
.state
.is_known_ping_target(&req.target, &req.target_type)
.await
{
return Err(Status::invalid_argument(format!(
"unknown ping target {} of type {}",
req.target, req.target_type
)));
}
// Route ping based on target type (matches Go's volume_grpc_admin.go Ping)
let remote_time_ns = if req.target_type == "volumeServer" {
match ping_volume_server_target(&req.target, self.state.outgoing_grpc_tls.as_ref())
@@ -4053,12 +4033,29 @@ async fn ping_filer_target(
Ok(resp.into_inner().start_time_ns)
}
// parse_grpc_address moved to super::grpc_client::parse_grpc_address
// for sharing with the distributed-EC-read path in server/store_ec.rs.
// In-file callers below still write `parse_grpc_address(...)`; this
// `use` makes them resolve to the new home without churning every
// call site.
use super::grpc_client::parse_grpc_address;
/// Parse a SeaweedFS server address ("ip:port.grpcPort" or "ip:port") into a gRPC address.
fn parse_grpc_address(source: &str) -> Result<String, String> {
if let Some(colon_idx) = source.rfind(':') {
let port_part = &source[colon_idx + 1..];
if let Some(dot_idx) = port_part.rfind('.') {
// Format: "ip:port.grpcPort"
let host = &source[..colon_idx];
let grpc_port = &port_part[dot_idx + 1..];
grpc_port
.parse::<u16>()
.map_err(|e| format!("invalid grpc port: {}", e))?;
return Ok(format!("{}:{}", host, grpc_port));
}
// Format: "ip:port" → grpc = port + 10000
let port: u16 = port_part
.parse()
.map_err(|e| format!("invalid port: {}", e))?;
let grpc_port = port as u32 + 10000;
let host = &source[..colon_idx];
return Ok(format!("{}:{}", host, grpc_port));
}
Err(format!("cannot parse address: {}", source))
}
/// Set the modification time of a file from nanoseconds since Unix epoch.
fn set_file_mtime(path: &str, modified_ts_ns: i64) {
@@ -4563,8 +4560,6 @@ mod tests {
read_mode: crate::config::ReadMode::Local,
master_url: String::new(),
master_urls: Vec::new(),
seed_master_set: std::collections::HashSet::new(),
current_master_url: tokio::sync::RwLock::new(String::new()),
self_url: String::new(),
http_client: reqwest::Client::new(),
outgoing_http_scheme: "http".to_string(),
@@ -4667,8 +4662,6 @@ mod tests {
read_mode: crate::config::ReadMode::Local,
master_url: String::new(),
master_urls: Vec::new(),
seed_master_set: std::collections::HashSet::new(),
current_master_url: tokio::sync::RwLock::new(String::new()),
self_url: String::new(),
http_client: reqwest::Client::new(),
outgoing_http_scheme: "http".to_string(),
@@ -4717,206 +4710,6 @@ mod tests {
.remove("s3.incr_copy_test");
}
/// Build a bare-bones service with no on-disk store but a configurable
/// seed master set, for Ping admission tests. Matches the structure of
/// `make_local_service_with_volume` minus the volume bits.
fn make_service_with_seed_masters(seeds: &[&str]) -> (VolumeGrpcService, TempDir) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut store = Store::new(NeedleMapKind::InMemory);
store
.add_location(
dir,
dir,
10,
DiskType::HardDrive,
MinFreeSpace::Percent(1.0),
Vec::new(),
)
.unwrap();
let master_urls: Vec<String> = seeds.iter().map(|s| (*s).to_string()).collect();
let seed_master_set =
crate::server::volume_server::VolumeServerState::build_seed_master_set(&master_urls);
let state = Arc::new(VolumeServerState {
store: RwLock::new(store),
guard: RwLock::new(Guard::new(
&[],
SigningKey(vec![]),
0,
SigningKey(vec![]),
0,
)),
is_stopping: RwLock::new(false),
maintenance: std::sync::atomic::AtomicBool::new(false),
state_version: std::sync::atomic::AtomicU32::new(0),
concurrent_upload_limit: 0,
concurrent_download_limit: 0,
inflight_upload_data_timeout: std::time::Duration::from_secs(60),
inflight_download_data_timeout: std::time::Duration::from_secs(60),
inflight_upload_bytes: std::sync::atomic::AtomicI64::new(0),
inflight_download_bytes: std::sync::atomic::AtomicI64::new(0),
upload_notify: tokio::sync::Notify::new(),
download_notify: tokio::sync::Notify::new(),
data_center: String::new(),
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
has_master: false,
pre_stop_seconds: 0,
volume_state_notify: tokio::sync::Notify::new(),
write_queue: std::sync::OnceLock::new(),
s3_tier_registry: std::sync::RwLock::new(
crate::remote_storage::s3_tier::S3TierRegistry::new(),
),
read_mode: crate::config::ReadMode::Local,
master_url: master_urls.first().cloned().unwrap_or_default(),
master_urls,
seed_master_set,
current_master_url: tokio::sync::RwLock::new(String::new()),
self_url: String::new(),
http_client: reqwest::Client::new(),
outgoing_http_scheme: "http".to_string(),
outgoing_grpc_tls: None,
metrics_runtime: std::sync::RwLock::new(
crate::server::volume_server::RuntimeMetricsConfig::default(),
),
metrics_notify: tokio::sync::Notify::new(),
fix_jpg_orientation: false,
has_slow_read: false,
read_buffer_size_bytes: 1024 * 1024,
security_file: String::new(),
cli_white_list: vec![],
state_file_path: String::new(),
});
(VolumeGrpcService { state }, tmp)
}
#[tokio::test]
async fn test_ping_empty_target_is_self_probe() {
// Empty target stays unauthenticated and returns Ok with timing fields
// populated — it is the local liveness probe path.
let (service, _tmp) = make_service_with_seed_masters(&[]);
let response = service
.ping(Request::new(volume_server_pb::PingRequest {
target: String::new(),
target_type: String::new(),
}))
.await
.expect("empty target ping must succeed");
let inner = response.into_inner();
assert!(inner.start_time_ns > 0);
assert!(inner.stop_time_ns >= inner.start_time_ns);
}
#[tokio::test]
async fn test_ping_seed_master_target_passes_admission() {
// A target that matches a configured seed master clears admission.
// The dial itself may or may not succeed depending on what's listening
// on the loopback; either way, the response must not be the
// InvalidArgument the gate would surface.
let (service, _tmp) = make_service_with_seed_masters(&["localhost:9333"]);
let result = service
.ping(Request::new(volume_server_pb::PingRequest {
target: "localhost:9333".to_string(),
target_type: "master".to_string(),
}))
.await;
if let Err(err) = result {
assert_ne!(err.code(), tonic::Code::InvalidArgument, "got {err:?}");
assert!(
!err.message().contains("unknown ping target"),
"admission gate should have allowed this target: {}",
err.message()
);
}
}
#[tokio::test]
async fn test_ping_unknown_master_target_rejected() {
// A master-type target not in the seed list and not the current
// master is refused with InvalidArgument.
let (service, _tmp) = make_service_with_seed_masters(&["localhost:9333"]);
let err = service
.ping(Request::new(volume_server_pb::PingRequest {
target: "localhost:9999".to_string(),
target_type: "master".to_string(),
}))
.await
.expect_err("unknown master target must be rejected");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert_eq!(
err.message(),
"unknown ping target localhost:9999 of type master"
);
}
#[tokio::test]
async fn test_ping_volume_server_target_always_rejected() {
// Volume servers do not maintain a peer-volume list, so volumeServer
// pings are refused regardless of address.
let (service, _tmp) = make_service_with_seed_masters(&["localhost:9333"]);
let err = service
.ping(Request::new(volume_server_pb::PingRequest {
target: "localhost:8080".to_string(),
target_type: "volumeServer".to_string(),
}))
.await
.expect_err("volumeServer target must be rejected");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert_eq!(
err.message(),
"unknown ping target localhost:8080 of type volumeServer"
);
}
#[tokio::test]
async fn test_ping_current_master_target_passes_admission() {
// A target that matches the current (post-leader-change) master also
// clears admission, even if it is not in the seed list. Pick a port
// that is extremely unlikely to be live so the test does not flake on
// a developer machine that happens to be running a real master.
let (service, _tmp) = make_service_with_seed_masters(&["localhost:9333"]);
// Simulate the heartbeat goroutine having moved to a new leader.
*service.state.current_master_url.write().await = "127.0.0.1:1".to_string();
let result = service
.ping(Request::new(volume_server_pb::PingRequest {
target: "127.0.0.1:1".to_string(),
target_type: "master".to_string(),
}))
.await;
if let Err(err) = result {
assert_ne!(err.code(), tonic::Code::InvalidArgument, "got {err:?}");
assert!(
!err.message().contains("unknown ping target"),
"leader-change master should be admitted: {}",
err.message()
);
}
}
#[tokio::test]
async fn test_ping_target_with_grpc_port_suffix_is_normalised() {
// Seed masters in pb.ServerAddress form (`host:port.grpcPort`) must
// match a Ping target sent in plain `host:port` form, since the gate
// normalises both sides through to_http_address.
let (service, _tmp) = make_service_with_seed_masters(&["localhost:9333.19333"]);
let result = service
.ping(Request::new(volume_server_pb::PingRequest {
target: "localhost:9333".to_string(),
target_type: "master".to_string(),
}))
.await;
if let Err(err) = result {
assert_ne!(err.code(), tonic::Code::InvalidArgument, "got {err:?}");
}
}
#[tokio::test]
async fn test_volume_ec_shards_generate_persists_expire_at_sec() {
let ttl = crate::storage::needle::ttl::TTL::read("3m").unwrap();
+31 -32
View File
@@ -1019,44 +1019,43 @@ async fn get_or_head_handler_inner(
if has_ec_volume && !has_volume {
// ---- EC volume read path (always full read, no streaming) ----
//
// The distributed read path already does a local-first pass
// in its Snapshot phase under the same store read lock the
// legacy code would have taken — so calling it directly
// serves both the "all shards local" fast case and the
// "some intervals need peer fetch + reconstruct" general
// case without paying for the local interval reads twice.
match crate::server::store_ec::read_ec_shard_needle_distributed(
&state, vid, needle_id,
)
.await
{
Ok(Some(ec_needle)) => {
n = ec_needle;
}
Ok(None) => {
let store = state.store.read().unwrap();
match store.find_ec_volume(vid) {
Some(ecv) => match ecv.read_ec_shard_needle(needle_id) {
Ok(Some(ec_needle)) => {
n = ec_needle;
}
Ok(None) => {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_GET_NOT_FOUND])
.inc();
return StatusCode::NOT_FOUND.into_response();
}
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_GET_NOT_FOUND])
.inc();
return StatusCode::NOT_FOUND.into_response();
}
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_GET_INTERNAL])
.inc();
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("read ec error: {}", e),
)
.into_response();
}
},
None => {
metrics::HANDLER_COUNTER
.with_label_values(&[metrics::ERROR_GET_NOT_FOUND])
.inc();
return StatusCode::NOT_FOUND.into_response();
}
Err(e) => {
let kind = if e.kind() == std::io::ErrorKind::NotFound {
metrics::ERROR_GET_NOT_FOUND
} else {
metrics::ERROR_GET_INTERNAL
};
metrics::HANDLER_COUNTER.with_label_values(&[kind]).inc();
if e.kind() == std::io::ErrorKind::NotFound {
return StatusCode::NOT_FOUND.into_response();
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("ec read: {}", e),
)
.into_response();
}
}
drop(store);
// Validate cookie (matches Go behavior after ReadEcShardNeedle)
if n.cookie != cookie {
+3 -20
View File
@@ -344,12 +344,6 @@ fn diff_ec_shard_delta_messages(
if !current.contains_key(key) {
let mut deleted = message.clone();
deleted.shard_sizes = vec![0];
tracing::info!(
volume_id = deleted.id,
disk_id = deleted.disk_id,
ec_index_bits = deleted.ec_index_bits,
"deletes ec shards"
);
deleted_ec_shards.push(deleted);
}
}
@@ -403,15 +397,6 @@ async fn do_heartbeat(
let mut response_stream = client.send_heartbeat(stream).await?.into_inner();
info!("Heartbeat stream established with {}", grpc_addr);
// Publish the master we're now talking to in canonical http host:port
// form so Ping admission can recognise it once a leader change moves us
// off the seed list. Mirrors Go's vs.setCurrentMaster(masterAddress).
{
let normalised =
super::volume_server::to_http_address(current_master).into_owned();
let mut guard = state.current_master_url.write().await;
*guard = normalised;
}
if is_stopping(state) {
state.is_heartbeating.store(false, Ordering::Relaxed);
send_deregister_heartbeat(config, state, &tx).await;
@@ -1048,8 +1033,6 @@ mod tests {
read_mode: ReadMode::Local,
master_url: String::new(),
master_urls: Vec::new(),
seed_master_set: std::collections::HashSet::new(),
current_master_url: tokio::sync::RwLock::new(String::new()),
self_url: String::new(),
http_client: reqwest::Client::new(),
outgoing_http_scheme: "http".to_string(),
@@ -1258,7 +1241,7 @@ mod tests {
let shard_path = format!("{}/ec_metrics_case_27.ec00", dir);
std::fs::write(&shard_path, b"ec-shard").unwrap();
store.locations[0]
.mount_ec_shards(VolumeId(27), "ec_metrics_case", &[0], "")
.mount_ec_shards(VolumeId(27), "ec_metrics_case", &[0])
.unwrap();
let state = test_state_with_store(store);
@@ -1300,7 +1283,7 @@ mod tests {
std::fs::write(format!("{}/expired_heartbeat_ec_31.ec00", dir), b"expired").unwrap();
store.locations[0]
.mount_ec_shards(VolumeId(31), "expired_heartbeat_ec", &[0], "")
.mount_ec_shards(VolumeId(31), "expired_heartbeat_ec", &[0])
.unwrap();
store
.find_ec_volume_mut(VolumeId(31))
@@ -1603,7 +1586,7 @@ mod tests {
std::fs::write(format!("{}/ec_delta_case_81.ec00", dir), b"delta").unwrap();
store.locations[0]
.mount_ec_shards(VolumeId(81), "ec_delta_case", &[0], "")
.mount_ec_shards(VolumeId(81), "ec_delta_case", &[0])
.unwrap();
let current = collect_ec_shard_delta_messages(&store);
let (new_ec_shards, deleted_ec_shards) =
-1
View File
@@ -9,7 +9,6 @@ pub mod memory_status;
pub mod profiling;
pub mod request_id;
pub mod server_stats;
pub mod store_ec;
pub mod ui;
pub mod volume_server;
pub mod write_queue;
-678
View File
@@ -1,678 +0,0 @@
//! Distributed EC read path. Mirror of `weed/storage/store_ec.go`'s
//! `readEcShardIntervals` → `readOneEcShardInterval` →
//! `readRemoteEcShardInterval` → `recoverOneRemoteEcShardInterval`
//! chain.
//!
//! The existing `EcVolume::read_ec_shard_needle` reads only locally-
//! mounted shards and returns `NotFound` if any interval requires a
//! shard held on a peer server. In a standard RS(10,4)-across-14
//! deployment each server holds one shard, so every read needs >=9
//! peer fetches. This module fills the gap by:
//!
//! 1. Locating the needle in `.ecx` (under the Store read lock) and
//! computing the per-interval (shard_id, shard_offset, size).
//! 2. Reading the local-resident intervals while still holding the
//! lock — same path the local-only helper uses.
//! 3. Dropping the lock and, for any remaining intervals, fetching
//! from peer volume servers via `VolumeEcShardRead`. If the
//! direct peer read fails, fan-out reads to other shards at the
//! same (shard_offset, size) and rebuild the missing shard via
//! Reed-Solomon — exactly Go's flow.
//! 4. Refreshing the per-EcVolume `shard_locations` cache from the
//! master's `LookupEcVolume` RPC when the cached map is stale.
//!
//! All gRPC IO is async; the file IO portion runs under the sync
//! Store read lock, matching Go's `readLocalEcShardInterval`. The
//! cache write-back briefly reacquires the EcVolume's internal
//! `RwLock` so we do not contend with the Store-level lock at all.
use std::collections::HashMap;
use std::io;
use std::sync::Arc;
use std::time::{Duration, Instant};
use futures::future::join_all;
use reed_solomon_erasure::galois_8::ReedSolomon;
use tonic::Request;
use crate::pb::master_pb::{self, seaweed_client::SeaweedClient, LookupEcVolumeRequest};
use crate::pb::volume_server_pb::{
volume_server_client::VolumeServerClient, VolumeEcShardReadRequest,
};
use crate::server::grpc_client::{build_grpc_endpoint, parse_grpc_address, GRPC_MAX_MESSAGE_SIZE};
use crate::server::request_id::outgoing_request_id_interceptor;
use crate::server::volume_server::VolumeServerState;
use crate::storage::erasure_coding::ec_shard::ShardId;
use crate::storage::needle::needle::{get_actual_size, Needle};
use crate::storage::types::*;
/// One interval's data after Phase A.
enum IntervalResult {
/// Already read from a locally-mounted shard.
Local(Vec<u8>),
/// Shard not local (or local read failed). Must be fetched.
NeedRemote {
shard_id: ShardId,
shard_offset: i64,
size: usize,
},
}
/// Snapshot extracted under the Store read lock so Phases B/C can run
/// without holding any sync lock across `.await`.
struct Snapshot {
data_shards: u32,
parity_shards: u32,
version: Version,
actual_size: usize,
offset: Offset,
size_for_parse: Size,
intervals: Vec<IntervalResult>,
cached_locations: HashMap<ShardId, Vec<String>>,
cache_refreshed_at: Option<Instant>,
}
/// Top-level entry point. Returns `Ok(None)` for "not found" (matches
/// Go's `ReadEcShardNeedle`); errors propagate as `io::Error`.
pub async fn read_ec_shard_needle_distributed(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
) -> io::Result<Option<Needle>> {
// Phase A — under the Store read lock, locate the needle, compute
// intervals, and read any locally-mounted shard intervals. We must
// not `.await` while holding this guard (std::sync::RwLockReadGuard
// is !Send).
let snapshot = match snapshot_under_lock(state, vid, needle_id)? {
Some(s) => s,
None => return Ok(None),
};
// Phase B — refresh the shard_locations cache from the master if
// it is stale. Do this lazily: if every needed interval was read
// locally we can skip the master RPC entirely.
let any_remote = snapshot
.intervals
.iter()
.any(|r| matches!(r, IntervalResult::NeedRemote { .. }));
let total_shards = (snapshot.data_shards + snapshot.parity_shards) as usize;
let mut shard_locations = snapshot.cached_locations.clone();
if any_remote
&& needs_refresh(
&shard_locations,
snapshot.cache_refreshed_at,
snapshot.data_shards as usize,
total_shards,
)
{
match cached_lookup_ec_shard_locations(state, vid).await {
Ok(fresh) => {
shard_locations = fresh.clone();
write_back_shard_locations(state, vid, fresh);
}
Err(e) => {
// Lookup failed — proceed with cached values. If cache
// is empty, the remote fetch below will fail and we
// surface a NotFound (matching Go's behavior when no
// locations are known).
tracing::warn!(
"ec lookup failed for volume {}: {} — using cached locations ({} entries)",
vid.0,
e,
shard_locations.len(),
);
}
}
}
// Phase C — fetch missing intervals, reconstructing when the
// direct peer read fails.
let mut assembled: Vec<Vec<u8>> = Vec::with_capacity(snapshot.intervals.len());
for res in snapshot.intervals {
match res {
IntervalResult::Local(buf) => assembled.push(buf),
IntervalResult::NeedRemote {
shard_id,
shard_offset,
size,
} => {
let buf = fetch_one_interval(
state,
vid,
needle_id,
shard_id,
shard_offset,
size,
&shard_locations,
snapshot.data_shards as usize,
snapshot.parity_shards as usize,
)
.await?;
assembled.push(buf);
}
}
}
// Phase D — assemble and parse the Needle. Mirrors the tail of
// `EcVolume::read_ec_shard_needle`.
let mut bytes = Vec::with_capacity(snapshot.actual_size);
for chunk in assembled {
bytes.extend_from_slice(&chunk);
}
bytes.truncate(snapshot.actual_size);
if bytes.len() < snapshot.actual_size {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"read {} bytes but need {} for needle {}",
bytes.len(),
snapshot.actual_size,
needle_id
),
));
}
let mut n = Needle::default();
n.id = needle_id;
n.read_bytes(
&bytes,
snapshot.offset.to_actual_offset(),
snapshot.size_for_parse,
snapshot.version,
)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("{}", e)))?;
Ok(Some(n))
}
fn snapshot_under_lock(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
) -> io::Result<Option<Snapshot>> {
let store = state.store.read().unwrap();
let ecv = match store.find_ec_volume(vid) {
Some(v) => v,
None => return Ok(None),
};
// Reuse EcVolume::locate_needle for offset/size resolution AND
// the per-needle shard-interval math — it's the same routine the
// local-only read path uses, so we stay byte-identical on the
// shard-size + interval boundaries.
let (offset, size, intervals) = match ecv.locate_needle(needle_id)? {
Some(v) => v,
None => return Ok(None),
};
if intervals.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"no intervals for needle",
));
}
let actual = get_actual_size(size, ecv.version);
// Phase A.local: for each interval read the local shard if we
// hold it; otherwise fall through to remote. We accumulate the
// results in interval order so the assembly step is just a
// concat.
let mut interval_results = Vec::with_capacity(intervals.len());
for interval in &intervals {
let (shard_id, shard_offset) = interval.to_shard_id_and_offset(ecv.data_shards);
let buf_size = interval.size as usize;
let local = ecv
.shards
.get(shard_id as usize)
.and_then(|s| s.as_ref());
match local {
Some(shard) => {
let mut buf = vec![0u8; buf_size];
match shard.read_at(&mut buf, shard_offset as u64) {
Ok(n) if n == buf_size => {
interval_results.push(IntervalResult::Local(buf));
}
_ => interval_results.push(IntervalResult::NeedRemote {
shard_id,
shard_offset,
size: buf_size,
}),
}
}
None => interval_results.push(IntervalResult::NeedRemote {
shard_id,
shard_offset,
size: buf_size,
}),
}
}
let cached_locations = ecv.shard_locations.read().unwrap().clone();
let cache_refreshed_at = *ecv.shard_locations_refresh_time.lock().unwrap();
Ok(Some(Snapshot {
data_shards: ecv.data_shards,
parity_shards: ecv.parity_shards,
version: ecv.version,
actual_size: actual as usize,
offset,
size_for_parse: size,
intervals: interval_results,
cached_locations,
cache_refreshed_at,
}))
}
/// Master `LookupEcVolume` freshness rules — match Go's
/// `cachedLookupEcShardLocations` thresholds in store_ec.go.
fn needs_refresh(
locations: &HashMap<ShardId, Vec<String>>,
refreshed_at: Option<Instant>,
data_shards: usize,
total_shards: usize,
) -> bool {
let now = Instant::now();
let age = match refreshed_at {
Some(t) => now.saturating_duration_since(t),
None => return true,
};
let shard_count = locations.len();
if shard_count < data_shards && age < Duration::from_secs(11) {
return false;
}
if shard_count == total_shards && age < Duration::from_secs(37 * 60) {
return false;
}
if shard_count >= data_shards && age < Duration::from_secs(7 * 60) {
return false;
}
true
}
async fn cached_lookup_ec_shard_locations(
state: &Arc<VolumeServerState>,
vid: VolumeId,
) -> io::Result<HashMap<ShardId, Vec<String>>> {
let master = {
let live = state.current_master_url.read().await.clone();
if !live.is_empty() {
live
} else {
state.master_url.clone()
}
};
if master.is_empty() {
return Err(io::Error::new(
io::ErrorKind::Other,
"no master configured for ec shard lookup",
));
}
let grpc_addr = parse_grpc_address(&master)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let endpoint = build_grpc_endpoint(&grpc_addr, state.outgoing_grpc_tls.as_ref())
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
let channel = endpoint
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(10))
.connect()
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("master connect: {}", e)))?;
let mut client = SeaweedClient::with_interceptor(channel, outgoing_request_id_interceptor)
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE);
let resp = client
.lookup_ec_volume(Request::new(LookupEcVolumeRequest { volume_id: vid.0 }))
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("lookup_ec_volume: {}", e)))?;
let resp = resp.into_inner();
let mut out = HashMap::new();
for entry in resp.shard_id_locations {
let addrs: Vec<String> = entry
.locations
.iter()
.map(format_location_as_server_address)
.collect();
out.insert(entry.shard_id as ShardId, addrs);
}
Ok(out)
}
fn write_back_shard_locations(
state: &Arc<VolumeServerState>,
vid: VolumeId,
locations: HashMap<ShardId, Vec<String>>,
) {
let store = state.store.read().unwrap();
if let Some(ecv) = store.find_ec_volume(vid) {
// Atomic swap + freshness stamp so a concurrent reader sees
// either the prior cache or the fresh one — never an
// intermediate half-replaced map with the freshness flag
// already flipped.
ecv.replace_shard_locations(locations);
}
}
/// Build a SeaweedFS-style `host:httpPort.grpcPort` address from a
/// master `Location` so the result is what `parse_grpc_address` (and
/// the heartbeat path) already understand.
fn format_location_as_server_address(loc: &master_pb::Location) -> String {
let raw = loc
.url
.trim_start_matches("http://")
.trim_start_matches("https://");
if loc.grpc_port > 0 {
if let Some((host, http_port)) = raw.rsplit_once(':') {
return format!("{}:{}.{}", host, http_port, loc.grpc_port);
}
}
raw.to_string()
}
/// Try direct peer read; on failure, reconstruct via Reed-Solomon
/// from the other shards. Mirrors `readOneEcShardInterval`'s tail.
async fn fetch_one_interval(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
shard_id: ShardId,
shard_offset: i64,
size: usize,
shard_locations: &HashMap<ShardId, Vec<String>>,
data_shards: usize,
parity_shards: usize,
) -> io::Result<Vec<u8>> {
// Direct peer read against the cached locations for this shard.
if let Some(sources) = shard_locations.get(&shard_id) {
if !sources.is_empty() {
match read_remote_ec_shard_interval(
state,
sources,
vid,
needle_id,
shard_id,
shard_offset,
size,
)
.await
{
Ok(buf) => return Ok(buf),
Err(e) => {
tracing::debug!(
"direct read ec shard {}.{} from {:?} failed: {} — will reconstruct",
vid.0,
shard_id,
sources,
e
);
}
}
}
}
// Reconstruct: fan-out reads to every other shard at the same
// (shard_offset, size). Mirrors `recoverOneRemoteEcShardInterval`.
recover_one_remote_ec_shard_interval(
state,
vid,
needle_id,
shard_id,
shard_offset,
size,
shard_locations,
data_shards,
parity_shards,
)
.await
}
async fn read_remote_ec_shard_interval(
state: &Arc<VolumeServerState>,
sources: &[String],
vid: VolumeId,
needle_id: NeedleId,
shard_id: ShardId,
shard_offset: i64,
size: usize,
) -> io::Result<Vec<u8>> {
let mut last_err: Option<io::Error> = None;
for src in sources {
match do_read_remote_ec_shard_interval(
state,
src,
vid,
needle_id,
shard_id,
shard_offset,
size,
)
.await
{
Ok(buf) => return Ok(buf),
Err(e) => last_err = Some(e),
}
}
Err(last_err.unwrap_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("no source for ec shard {}.{}", vid.0, shard_id),
)
}))
}
async fn do_read_remote_ec_shard_interval(
state: &Arc<VolumeServerState>,
source: &str,
vid: VolumeId,
needle_id: NeedleId,
shard_id: ShardId,
shard_offset: i64,
size: usize,
) -> io::Result<Vec<u8>> {
let grpc_addr =
parse_grpc_address(source).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let endpoint = build_grpc_endpoint(&grpc_addr, state.outgoing_grpc_tls.as_ref())
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
let channel = endpoint
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.connect()
.await
.map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("connect to {}: {}", source, e),
)
})?;
// TODO(grpc-jwt): clusters with `jwt.signing.key` configured will
// reject peer-to-peer VolumeEcShardRead calls until the Rust
// crate grows an outgoing-JWT interceptor. The gap is shared
// with every other peer gRPC call from this binary
// (`copy_file_from_source`, `batch_delete`, …) — handling it
// here in isolation would split the credential plumbing across
// call sites. Re-visit when outgoing JWT signing lands as a
// server-wide helper.
let mut client = VolumeServerClient::with_interceptor(channel, outgoing_request_id_interceptor)
.max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE)
.max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE);
let req = VolumeEcShardReadRequest {
volume_id: vid.0,
shard_id: shard_id as u32,
offset: shard_offset,
size: size as i64,
file_key: needle_id.0,
};
let resp = client
.volume_ec_shard_read(Request::new(req))
.await
.map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("volume_ec_shard_read {}.{} from {}: {}", vid.0, shard_id, source, e),
)
})?;
let mut stream = resp.into_inner();
let mut out = Vec::with_capacity(size);
while let Some(msg) = stream
.message()
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("recv: {}", e)))?
{
if !msg.data.is_empty() {
out.extend_from_slice(&msg.data);
}
}
if out.len() < size {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"short read from {} for ec shard {}.{}: got {} want {}",
source,
vid.0,
shard_id,
out.len(),
size
),
));
}
out.truncate(size);
Ok(out)
}
async fn recover_one_remote_ec_shard_interval(
state: &Arc<VolumeServerState>,
vid: VolumeId,
needle_id: NeedleId,
shard_id_to_recover: ShardId,
shard_offset: i64,
size: usize,
shard_locations: &HashMap<ShardId, Vec<String>>,
data_shards: usize,
parity_shards: usize,
) -> io::Result<Vec<u8>> {
let total_shards = data_shards + parity_shards;
let rs = ReedSolomon::new(data_shards, parity_shards).map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("reed-solomon init: {:?}", e),
)
})?;
let mut bufs: Vec<Option<Vec<u8>>> = vec![None; total_shards];
// Phase 0: seed bufs from LOCALLY mounted shards. If this node
// already holds enough sibling shards, reconstruction completes
// without any peer fan-out — and even with a cold/incomplete
// shard_locations cache or a failed master lookup, local
// survivors still contribute. Mirrors Go's
// recoverOneRemoteEcShardInterval behaviour, which is implicitly
// local-aware because the Store fan-out targets ALL known
// locations (including the caller's own server address); the
// Rust port had been remote-only, so reconstructing with a cold
// cache failed even when enough siblings were on disk.
{
let store = state.store.read().unwrap();
if let Some(ecv) = store.find_ec_volume(vid) {
for sid in 0..total_shards {
if sid as ShardId == shard_id_to_recover {
continue;
}
if let Some(Some(shard)) = ecv.shards.get(sid) {
let mut buf = vec![0u8; size];
if shard.read_at(&mut buf, shard_offset as u64).map(|n| n == size).unwrap_or(false) {
bufs[sid] = Some(buf);
}
}
}
}
}
// Phase 1: remote fan-out — one task per known shard location
// we DON'T already have locally and DON'T need to recover.
let mut tasks = Vec::new();
for (sid, locs) in shard_locations {
if *sid == shard_id_to_recover || locs.is_empty() {
continue;
}
if bufs[*sid as usize].is_some() {
continue;
}
let sid = *sid;
let locs = locs.clone();
let state = state.clone();
tasks.push(async move {
let res = read_remote_ec_shard_interval(
&state,
&locs,
vid,
needle_id,
sid,
shard_offset,
size,
)
.await;
(sid, res)
});
}
let results = join_all(tasks).await;
for (sid, res) in results {
match res {
Ok(buf) => {
if (sid as usize) < total_shards {
bufs[sid as usize] = Some(buf);
}
}
Err(e) => {
tracing::debug!(
"recover: read {}.{} for needle {} failed: {}",
vid.0,
sid,
needle_id,
e
);
}
}
}
let available = bufs.iter().filter(|b| b.is_some()).count();
if available < data_shards {
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"cannot recover ec shard {}.{}: only {} shards available, need at least {}",
vid.0, shard_id_to_recover, available, data_shards
),
));
}
rs.reconstruct(&mut bufs).map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!(
"reed-solomon reconstruct ec shard {}.{}: {:?}",
vid.0, shard_id_to_recover, e
),
)
})?;
match bufs.into_iter().nth(shard_id_to_recover as usize).flatten() {
Some(buf) => Ok(buf),
None => Err(io::Error::new(
io::ErrorKind::Other,
format!(
"reconstructed buffer for shard {}.{} missing after RS reconstruct",
vid.0, shard_id_to_recover
),
)),
}
}
// parse_grpc_address lives in `grpc_client.rs` and is re-exported
// here via the use above so this module shares a single
// HTTP↔gRPC port-translation routine with grpc_server.rs.
@@ -81,16 +81,6 @@ pub struct VolumeServerState {
pub master_url: String,
/// Seed master addresses for UI rendering.
pub master_urls: Vec<String>,
/// Canonical http `host:port` form of every configured seed master.
/// Built once at construction so Ping admission stays O(1). Mirrors
/// Go's `seedMasterSet` on `VolumeServer`.
pub seed_master_set: std::collections::HashSet<String>,
/// Current master this server is heartbeating with, in canonical http
/// `host:port` form. Empty when no heartbeat connection is active. The
/// heartbeat goroutine writes; admission reads — the lock keeps them
/// from racing on a leader change. Mirrors Go's `currentMaster` plus
/// `currentMasterLock`.
pub current_master_url: tokio::sync::RwLock<String>,
/// This server's own address (ip:port) for filtering self from lookup results.
pub self_url: String,
/// HTTP client for proxy requests and master lookups.
@@ -127,35 +117,6 @@ impl VolumeServerState {
}
Ok(())
}
/// Build the seed master set from a list of raw `host:port[.grpcPort]`
/// addresses, normalised the same way Go's `pb.ServerAddress.ToHttpAddress`
/// does (drop the `.grpcPort` suffix, preserve everything else).
pub fn build_seed_master_set(master_urls: &[String]) -> std::collections::HashSet<String> {
master_urls
.iter()
.map(|m| to_http_address(m).into_owned())
.collect()
}
/// Returns true iff `target` (normalised to canonical http `host:port`)
/// is a master this server already knows about. Volume servers do not
/// keep a peer-volume or peer-filer list, so Ping is scoped to masters.
/// Mirrors Go's `VolumeServer.isKnownPingTarget`.
pub async fn is_known_ping_target(&self, target: &str, target_type: &str) -> bool {
if target_type != "master" {
return false;
}
let key = to_http_address(target).into_owned();
if key.is_empty() {
return false;
}
let current = self.current_master_url.read().await.clone();
if !current.is_empty() && current == key {
return true;
}
self.seed_master_set.contains(&key)
}
}
pub fn build_metrics_router() -> Router {
-2
View File
@@ -207,8 +207,6 @@ mod tests {
read_mode: crate::config::ReadMode::Local,
master_url: String::new(),
master_urls: Vec::new(),
seed_master_set: std::collections::HashSet::new(),
current_master_url: tokio::sync::RwLock::new(String::new()),
self_url: String::new(),
http_client: reqwest::Client::new(),
outgoing_http_scheme: "http".to_string(),
+6 -81
View File
@@ -304,11 +304,7 @@ impl DiskLocation {
}
/// Remove all EC-related files for a volume.
/// `pub(crate)` so the Store-level cross-disk passes in
/// `store_ec_reconcile.rs` can call it to scrub partial EC artefacts
/// when a healthy `.dat` for the same vid lives on a sibling disk
/// (seaweedfs/seaweedfs#9478).
pub(crate) fn remove_ec_volume_files(&self, collection: &str, vid: VolumeId) {
fn remove_ec_volume_files(&self, collection: &str, vid: VolumeId) {
let base = volume_file_name(&self.directory, collection, vid);
let idx_base = volume_file_name(&self.idx_directory, collection, vid);
const MAX_SHARD_COUNT: usize = 32;
@@ -604,22 +600,14 @@ impl DiskLocation {
}
/// Mount EC shards for a volume on this location.
///
/// `source_disk_type` is the source volume's disk type carried on the
/// `VolumeEcShardsMount` RPC. When non-empty it overrides the in-memory
/// EC volume's reported disk type so heartbeats keep reporting under
/// the source's disk type after EC encoding (#9423). Empty means "use
/// this location's disk type" — used by disk-scan reload paths that
/// have no orchestrator context.
pub fn mount_ec_shards(
&mut self,
vid: VolumeId,
collection: &str,
shard_ids: &[u32],
source_disk_type: &str,
) -> Result<(), VolumeError> {
let idx_dir = self.idx_directory.clone();
self.mount_ec_shards_with_idx_dir(vid, collection, shard_ids, &idx_dir, source_disk_type)
self.mount_ec_shards_with_idx_dir(vid, collection, shard_ids, &idx_dir)
}
/// Mount EC shards but explicitly specify the idx directory the
@@ -639,7 +627,6 @@ impl DiskLocation {
collection: &str,
shard_ids: &[u32],
idx_dir: &str,
source_disk_type: &str,
) -> Result<(), VolumeError> {
let dir = self.directory.clone();
// Avoid the entry().or_insert_with() pattern here: that closure
@@ -656,22 +643,10 @@ impl DiskLocation {
.ec_volumes
.get_mut(&vid)
.expect("just inserted above");
// When the orchestrator supplied a source disk type on the Mount
// RPC, override the EC volume's disk type so heartbeats report
// under the source volume's disk type (#9423). When the caller
// passed "" (disk-scan reload, orphan-shard reconcile, restart)
// we only default to this location's disk type for a fresh EC
// volume; otherwise we leave whatever a prior RPC mount set in
// place, so empty-source reloads don't reintroduce the drift.
if !source_disk_type.is_empty() {
ec_vol.set_disk_type(DiskType::from_string(source_disk_type));
} else if ec_vol.shard_count() == 0 {
ec_vol.set_disk_type(self.disk_type.clone());
}
ec_vol.disk_type = self.disk_type.clone();
for &shard_id in shard_ids {
let mut shard = EcVolumeShard::new(&dir, collection, vid, shard_id as u8);
shard.disk_type = ec_vol.disk_type.clone();
let shard = EcVolumeShard::new(&dir, collection, vid, shard_id as u8);
ec_vol.add_shard(shard).map_err(VolumeError::Io)?;
crate::metrics::VOLUME_GAUGE
.with_label_values(&[collection, "ec_shards"])
@@ -829,7 +804,7 @@ impl DiskLocation {
}
let shard_ids: Vec<u32> = shards.iter().map(|(_, sid)| *sid).collect();
if let Err(e) = self.mount_ec_shards(vid, collection, &shard_ids, "") {
if let Err(e) = self.mount_ec_shards(vid, collection, &shard_ids) {
// mount_ec_shards adds shards one at a time and increments
// the per-shard metric for each. If it fails halfway, plain
// ec_volumes.remove(vid) would leak metric increments for
@@ -1262,7 +1237,7 @@ mod tests {
let shard_path = format!("{}/pics_7.ec00", dir);
std::fs::write(&shard_path, b"ec-shard").unwrap();
loc.mount_ec_shards(VolumeId(7), "pics", &[0], "").unwrap();
loc.mount_ec_shards(VolumeId(7), "pics", &[0]).unwrap();
assert!(loc.has_ec_volume(VolumeId(7)));
assert!(std::path::Path::new(&shard_path).exists());
assert!(std::path::Path::new(&format!("{}/pics_7.ecj", dir)).exists());
@@ -1274,56 +1249,6 @@ mod tests {
assert!(!std::path::Path::new(&format!("{}/pics_7.ecj", dir)).exists());
}
/// #9423: after an RPC `VolumeEcShardsMount` records the source
/// volume's disk type on the EcVolume, a later empty-source mount
/// call (disk-scan reload, orphan-shard reconcile, restart) must
/// not clobber that override back to the physical location's disk
/// type — otherwise heartbeat reporting drifts back to "hdd" on
/// every reload.
#[test]
fn test_mount_ec_shards_empty_source_preserves_existing_disk_type() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut loc = DiskLocation::new(
dir,
dir,
10,
DiskType::HardDrive,
MinFreeSpace::Percent(1.0),
Vec::new(),
)
.unwrap();
// Plant the first shard so the EC volume can mount, then call
// mount_ec_shards with source_disk_type="ssd" — simulating the
// VolumeEcShardsMount RPC path.
std::fs::write(format!("{}/pics_7.ec00", dir), b"ec-shard").unwrap();
loc.mount_ec_shards(VolumeId(7), "pics", &[0], "ssd").unwrap();
{
let ec_vol = loc.find_ec_volume(VolumeId(7)).expect("ec volume mounted");
assert_eq!(
ec_vol.disk_type,
DiskType::Ssd,
"first RPC mount should have set disk type to source's value",
);
}
// Plant another shard and re-mount with an empty source —
// matching what a disk-scan reload or orphan-shard reconcile
// would pass. The previously-recorded "ssd" override must
// survive.
std::fs::write(format!("{}/pics_7.ec01", dir), b"ec-shard").unwrap();
loc.mount_ec_shards(VolumeId(7), "pics", &[1], "").unwrap();
{
let ec_vol = loc.find_ec_volume(VolumeId(7)).expect("ec volume still mounted");
assert_eq!(
ec_vol.disk_type,
DiskType::Ssd,
"empty-source remount must not reset disk type to the physical location's",
);
}
}
#[test]
fn test_disk_location_persists_directory_uuid_and_tags() {
let tmp = TempDir::new().unwrap();
@@ -44,17 +44,8 @@ pub struct EcVolume {
/// Directory where .ecx/.ecj were actually found (may differ from dir_idx after fallback).
ecx_actual_dir: String,
/// Maps shard ID -> list of server addresses where that shard exists.
/// Used for distributed EC reads across the cluster. Wrapped in
/// `RwLock` so the read path can refresh the map (under master
/// lookup) without holding the Store write lock — mirrors Go's
/// `ShardLocationsLock sync.RWMutex` in `weed/storage/erasure_coding/ec_volume.go`.
pub shard_locations: std::sync::RwLock<HashMap<ShardId, Vec<String>>>,
/// Wall-clock timestamp of the most recent successful
/// `LookupEcVolume` refresh of `shard_locations`. `None` until the
/// first refresh. Drives the staleness heuristic in
/// `cached_lookup_ec_shard_locations` (mirrors Go's
/// `ShardLocationsRefreshTime`).
pub shard_locations_refresh_time: std::sync::Mutex<Option<std::time::Instant>>,
/// Used for distributed EC reads across the cluster.
pub shard_locations: HashMap<ShardId, Vec<String>>,
/// EC volume expiration time (unix epoch seconds), set during EC encode from TTL.
pub expire_at_sec: u64,
}
@@ -129,15 +120,10 @@ impl EcVolume {
shards.push(None);
}
// Read expire_at_sec, version, and dat_file_size from .vif if
// present (matches Go's MaybeLoadVolumeInfo). Prefer the data
// dir; fall back to the idx dir for the cross-disk reconcile
// case (#9212 / #9244). `dat_file_size` is the source .dat
// size at encode time, used both by `locate_ec_shard_needle`
// 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) = {
// Read expire_at_sec and version from .vif if present (matches Go's MaybeLoadVolumeInfo).
// Prefer the data dir; fall back to the idx dir for the
// cross-disk reconcile case (#9212 / #9244).
let (expire_at_sec, vif_version) = {
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 +134,12 @@ impl EcVolume {
} else {
Version::current()
};
(vif_info.expire_at_sec, ver, vif_info.dat_file_size)
(vif_info.expire_at_sec, ver)
} else {
(0, Version::current(), 0)
(0, Version::current())
}
} else {
(0, Version::current(), 0)
(0, Version::current())
}
};
@@ -164,7 +150,7 @@ impl EcVolume {
dir_idx: dir_idx.to_string(),
version: vif_version,
shards,
dat_file_size: vif_dat_file_size,
dat_file_size: 0,
data_shards,
parity_shards,
ecx_file: None,
@@ -174,8 +160,7 @@ impl EcVolume {
deleted_needles: RwLock::new(HashSet::new()),
disk_type: DiskType::default(),
ecx_actual_dir: dir_idx.to_string(),
shard_locations: std::sync::RwLock::new(HashMap::new()),
shard_locations_refresh_time: std::sync::Mutex::new(None),
shard_locations: HashMap::new(),
expire_at_sec,
};
@@ -342,20 +327,6 @@ impl EcVolume {
Ok(())
}
/// Override the disk type the EC volume (and its already-mounted
/// shards) reports under. Used by the `VolumeEcShardsMount` handler
/// so the source volume's disk type is preserved across encoding
/// (#9423). Not persisted across restarts — disk-scan reload paths
/// default to the physical location's disk type.
pub fn set_disk_type(&mut self, d: DiskType) {
self.disk_type = d.clone();
for slot in self.shards.iter_mut() {
if let Some(shard) = slot {
shard.disk_type = d.clone();
}
}
}
/// Remove and close a shard.
pub fn remove_shard(&mut self, shard_id: ShardId) {
if let Some(ref mut shard) = self.shards[shard_id as usize] {
@@ -440,45 +411,17 @@ impl EcVolume {
// ---- Shard locations (distributed tracking) ----
/// Set the list of server addresses for a single shard ID. Does
/// NOT touch `shard_locations_refresh_time` — a per-shard write
/// from inside a multi-shard population (e.g. iterating the
/// `LookupEcVolume` response shard-by-shard) would otherwise
/// flip the staleness flag while the map is still incomplete,
/// letting a concurrent reader observe `needs_refresh == false`
/// against a half-populated cache and return NotFound for the
/// not-yet-inserted shards.
///
/// Callers populating the whole cache atomically should use
/// [`Self::replace_shard_locations`] instead — it swaps the
/// entire map under the write lock and advances the refresh
/// timestamp in one step.
pub fn set_shard_locations(&self, shard_id: ShardId, locations: Vec<String>) {
self.shard_locations
.write()
.unwrap()
.insert(shard_id, locations);
/// Set the list of server addresses for a given shard ID.
pub fn set_shard_locations(&mut self, shard_id: ShardId, locations: Vec<String>) {
self.shard_locations.insert(shard_id, locations);
}
/// Atomically replace the entire shard-locations map and stamp
/// the refresh time. Used by the distributed-read path's
/// post-`LookupEcVolume` write-back so the cache transitions
/// from old → fresh in a single observable step — concurrent
/// readers either see the full prior map or the full new map,
/// never an intermediate state with the freshness flag flipped.
pub fn replace_shard_locations(&self, locations: HashMap<ShardId, Vec<String>>) {
*self.shard_locations.write().unwrap() = locations;
*self.shard_locations_refresh_time.lock().unwrap() = Some(std::time::Instant::now());
}
/// Get a cloned list of server addresses for a given shard ID.
pub fn get_shard_locations(&self, shard_id: ShardId) -> Vec<String> {
/// Get the list of server addresses for a given shard ID.
pub fn get_shard_locations(&self, shard_id: ShardId) -> &[String] {
self.shard_locations
.read()
.unwrap()
.get(&shard_id)
.cloned()
.unwrap_or_default()
.map(|v| v.as_slice())
.unwrap_or(&[])
}
// ---- Index operations ----
-1
View File
@@ -4,7 +4,6 @@ pub mod idx;
pub mod needle;
pub mod needle_map;
pub mod store;
pub mod store_ec_mirror;
pub mod store_ec_reconcile;
pub mod super_block;
pub mod types;
-20
View File
@@ -212,13 +212,6 @@ impl CompactNeedleMap {
self.idx_file_offset = offset;
}
/// True when an .idx file writer is attached. A read-only load leaves
/// this `false` — set_writable() must reattach a writer or subsequent
/// puts silently skip the disk append.
pub fn has_idx_writer(&self) -> bool {
self.idx_file.is_some()
}
// ---- Map operations ----
/// Insert or update an entry. Appends to .idx file if present.
@@ -712,11 +705,6 @@ impl RedbNeedleMap {
self.idx_file_offset = offset;
}
/// True when an .idx file writer is attached. See CompactNeedleMap.
pub fn has_idx_writer(&self) -> bool {
self.idx_file.is_some()
}
// ---- Map operations ----
/// Insert or update an entry. Writes to idx file first, then redb.
@@ -1012,14 +1000,6 @@ impl NeedleMap {
}
}
/// True when an .idx file writer is attached.
pub fn has_idx_writer(&self) -> bool {
match self {
NeedleMap::InMemory(nm) => nm.has_idx_writer(),
NeedleMap::Redb(nm) => nm.has_idx_writer(),
}
}
/// Content byte count.
pub fn content_size(&self) -> u64 {
match self {
+16 -59
View File
@@ -85,27 +85,14 @@ impl Store {
self.locations.push(loc);
// First scrub partial EC artefacts left on one disk by an
// interrupted encode while the source .dat still lives on a
// sibling disk of the same store. The per-disk loader cannot
// see the sibling .dat and so loads the partial shards as if
// they were a distributed-EC layout, which makes the volume
// server heartbeat both a regular replica and an EC shard set
// for the same vid (seaweedfs/seaweedfs#9478). Running before
// the cross-disk reconcile keeps that pass from later
// re-loading shards we just cleaned up.
self.prune_incomplete_ec_with_sibling_dat();
// Physically mirror EC sidecars onto every shard-bearing disk
// so each disk mounts self-contained. Must run before the
// cross-disk reconciler so the orphan pass can prefer the
// local idx_directory.
self.mirror_ec_metadata_to_shard_disks();
// Cross-disk fallback for orphan shards — ec.balance can land
// shards on one disk while leaving the index on another. Still
// needed after the mirror pass for volumes whose mirror failed
// (read-only target, partial copy).
// After every disk has finished its per-disk EC scan, sweep
// the store for shards that live on a disk without local index
// files and load them by reaching across to a sibling disk's
// .ecx / .ecj / .vif (seaweedfs/seaweedfs#9212 / #9244).
// ec.balance / ec.rebuild can move shards onto a destination
// node's second disk while leaving the index on the disk that
// already held the volume; without this pass those orphan
// shards stay invisible to the master.
self.reconcile_ec_shards_across_disks();
Ok(())
@@ -119,8 +106,6 @@ impl Store {
tracing::error!("load_new_volumes error in {}: {}", loc.directory, e);
}
}
self.prune_incomplete_ec_with_sibling_dat();
self.mirror_ec_metadata_to_shard_disks();
self.reconcile_ec_shards_across_disks();
}
@@ -651,25 +636,22 @@ impl Store {
))
})?;
self.locations[loc_idx].mount_ec_shards(vid, collection, shard_ids, "")
self.locations[loc_idx].mount_ec_shards(vid, collection, shard_ids)
}
/// Mount a single EC shard, searching all locations for the shard file.
/// Matches Go's Store.MountEcShards which mounts one shard at a time.
/// `source_disk_type` is the source volume's disk type from the
/// `VolumeEcShardsMount` RPC (#9423); pass `""` for non-RPC paths.
pub fn mount_ec_shard(
&mut self,
vid: VolumeId,
collection: &str,
shard_id: u32,
source_disk_type: &str,
) -> Result<(), VolumeError> {
for loc in &mut self.locations {
// Check if the shard file exists on this location
let shard = EcVolumeShard::new(&loc.directory, collection, vid, shard_id as u8);
if std::path::Path::new(&shard.file_name()).exists() {
loc.mount_ec_shards(vid, collection, &[shard_id], source_disk_type)?;
loc.mount_ec_shards(vid, collection, &[shard_id])?;
return Ok(());
}
}
@@ -702,20 +684,10 @@ impl Store {
// Walk all locations rather than stopping at the first with the
// vid — split-disk reconciled volumes can have the same vid on
// multiple disks, with the target shard on any of them.
for disk_id in 0..self.locations.len() {
let has_shard = self.locations[disk_id]
.find_ec_volume(vid)
.is_some_and(|ec_vol| ec_vol.has_shard(shard_id as u8));
if !has_shard {
continue;
for loc in &mut self.locations {
if loc.has_ec_volume(vid) {
loc.unmount_ec_shards(vid, &[shard_id]);
}
tracing::info!(
volume_id = vid.0,
shard_id,
disk_id,
"UnmountEcShards"
);
self.locations[disk_id].unmount_ec_shards(vid, &[shard_id]);
}
// Go returns nil if shard not found (no error)
Ok(())
@@ -746,21 +718,6 @@ impl Store {
self.locations.iter().any(|loc| loc.has_ec_volume(vid))
}
/// Returns every disk_id on this store that has an EcVolume entry
/// for `vid`. Useful for diagnostic logging when a single
/// `has_ec_volume` hit hides which disk is actually holding the
/// mount (e.g., the ReceiveFile mounted-volume guard).
/// Mirrors Go's `Store.FindEcVolumeDiskIds`.
pub fn find_ec_volume_disk_ids(&self, vid: VolumeId) -> Vec<u32> {
let mut ids = Vec::new();
for (idx, loc) in self.locations.iter().enumerate() {
if loc.has_ec_volume(vid) {
ids.push(idx as u32);
}
}
ids
}
/// Returns the index of the disk location that has `(vid, shard_id)`
/// mounted, if any. Mirrors Go's `Store.findEcShard` and is the
/// right primitive for read/unmount/delete operations on a single
@@ -1524,7 +1481,7 @@ mod tests {
std::fs::write(format!("{}/expired_ec_case_9.ec00", dir), b"expired").unwrap();
store.locations[0]
.mount_ec_shards(VolumeId(9), "expired_ec_case", &[0], "")
.mount_ec_shards(VolumeId(9), "expired_ec_case", &[0])
.unwrap();
store.find_ec_volume_mut(VolumeId(9)).unwrap().expire_at_sec = 1;
@@ -1619,7 +1576,7 @@ mod tests {
)
.unwrap();
store.locations[1]
.mount_ec_shards(vid, collection, &[0], "")
.mount_ec_shards(vid, collection, &[0])
.unwrap();
// Stray .ecx on disk 2 must not win.
@@ -1686,7 +1643,7 @@ mod tests {
)
.unwrap();
store.locations[1]
.mount_ec_shards(vid, collection, &[0], "")
.mount_ec_shards(vid, collection, &[0])
.unwrap();
let got = store.find_ec_shard_target_location(collection, vid, 10);
@@ -1,421 +0,0 @@
//! Physical EC sidecar mirroring across disks of the same volume
//! server. Mirrors `weed/storage/store_ec_mirror.go`.
use std::collections::HashMap;
use std::fs;
use std::io::{self, Read, Write};
use std::path::Path;
use tracing::{info, warn};
use crate::storage::disk_location::{parse_collection_volume_id_pub, DiskLocation};
use crate::storage::store::Store;
use crate::storage::types::VolumeId;
// Listed in `EcVolume::new`'s open order.
const EC_MIRRORED_SIDECARS: &[&str] = &[".ecx", ".ecj", ".vif"];
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct EcKey {
collection: String,
vid: VolumeId,
}
#[derive(Clone, Debug)]
struct EcxOwner {
location: usize,
idx_dir: String,
data_dir: String,
}
impl Store {
/// Mirror EC sidecars onto every shard-bearing disk that lacks
/// them, so each disk mounts self-contained. Runs before
/// `reconcile_ec_shards_across_disks` so the orphan pass can
/// prefer the local idx_directory.
pub fn mirror_ec_metadata_to_shard_disks(&mut self) {
if self.locations.len() < 2 {
return;
}
let owners = self.index_ecx_owners_for_mirror();
if owners.is_empty() {
return;
}
// Two-pass: gather work under an immutable borrow, then apply
// copies under independent mutable borrows.
struct Mirror<'a> {
target_idx: usize,
owner: &'a EcxOwner,
collection: String,
vid: VolumeId,
}
let mut mirrors: Vec<Mirror> = Vec::new();
for (loc_idx, loc) in self.locations.iter().enumerate() {
let orphans = collect_shard_disk_volumes(loc);
for (key, _shards) in orphans {
let Some(owner) = owners.get(&key) else {
continue;
};
if owner.location == loc_idx {
continue;
}
if disk_has_all_sidecars(loc, &key.collection, key.vid) {
continue;
}
mirrors.push(Mirror {
target_idx: loc_idx,
owner,
collection: key.collection,
vid: key.vid,
});
}
}
for m in mirrors {
let loc_dir = self.locations[m.target_idx].directory.clone();
let loc_idx_dir = self.locations[m.target_idx].idx_directory.clone();
match mirror_sidecars_for_volume(
&m.owner.idx_dir,
&m.owner.data_dir,
&loc_dir,
&loc_idx_dir,
&m.collection,
m.vid,
) {
Ok(0) => {}
Ok(copied) => {
info!(
volume_id = m.vid.0,
collection = %m.collection,
from = %m.owner.data_dir,
to = %loc_dir,
copied,
"mirrored EC sidecar(s) for same-disk invariant",
);
}
Err(e) => {
warn!(
volume_id = m.vid.0,
collection = %m.collection,
from = %m.owner.data_dir,
to = %loc_dir,
error = %e,
"mirror EC sidecars failed; cross-disk fallback will handle this volume",
);
}
}
}
}
// Records both idx_dir (where .ecx was found) and data_dir, so
// the mirror can resolve .vif from data_dir even when .ecx lives
// in idx_directory.
fn index_ecx_owners_for_mirror(&self) -> HashMap<EcKey, EcxOwner> {
let mut owners: HashMap<EcKey, EcxOwner> = HashMap::new();
for (loc_idx, loc) in self.locations.iter().enumerate() {
let mut seen: Vec<&str> = Vec::with_capacity(2);
for scan in [loc.idx_directory.as_str(), loc.directory.as_str()] {
if scan.is_empty() || seen.contains(&scan) {
continue;
}
seen.push(scan);
let Ok(read) = fs::read_dir(scan) else {
continue;
};
for ent in read.flatten() {
if ent.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
continue;
}
let name = ent.file_name().to_string_lossy().into_owned();
let Some(base) = name.strip_suffix(".ecx") else {
continue;
};
let Some((collection, vid)) = parse_collection_volume_id_pub(base) else {
continue;
};
owners
.entry(EcKey { collection, vid })
.or_insert_with(|| EcxOwner {
location: loc_idx,
idx_dir: scan.to_string(),
data_dir: loc.directory.clone(),
});
}
}
}
owners
}
}
// Checks the modern routing and the opposite directory — without
// that fallback, a destination with a legacy pre-`-dir.idx` .ecx in
// its data dir would be re-mirrored into idx_directory.
fn disk_has_all_sidecars(loc: &DiskLocation, collection: &str, vid: VolumeId) -> bool {
for ext in EC_MIRRORED_SIDECARS {
let primary = sidecar_dest_path(&loc.directory, &loc.idx_directory, collection, vid, ext);
if path_is_regular_file(&primary) {
continue;
}
if loc.idx_directory != loc.directory {
let (fallback_data, fallback_idx) = if *ext == ".vif" {
(loc.idx_directory.as_str(), loc.directory.as_str())
} else {
(loc.directory.as_str(), loc.directory.as_str())
};
let fallback = sidecar_dest_path(fallback_data, fallback_idx, collection, vid, ext);
if path_is_regular_file(&fallback) {
continue;
}
}
return false;
}
true
}
fn path_is_regular_file(path: &str) -> bool {
fs::metadata(path).map(|m| !m.is_dir()).unwrap_or(false)
}
// `.ecx`/`.ecj` route to idx_directory, `.vif` to directory.
fn sidecar_dest_path(
data_dir: &str,
idx_dir: &str,
collection: &str,
vid: VolumeId,
ext: &str,
) -> String {
let dir = if ext == ".vif" { data_dir } else { idx_dir };
if collection.is_empty() {
format!("{}/{}{}", dir, vid.0, ext)
} else {
format!("{}/{}_{}{}", dir, collection, vid.0, ext)
}
}
fn mirror_sidecars_for_volume(
src_idx_dir: &str,
src_data_dir: &str,
dst_data_dir: &str,
dst_idx_dir: &str,
collection: &str,
vid: VolumeId,
) -> io::Result<usize> {
let mut copied = 0usize;
for ext in EC_MIRRORED_SIDECARS {
let dst = sidecar_dest_path(dst_data_dir, dst_idx_dir, collection, vid, ext);
// An existing local copy is authoritative — it may be newer
// than the owner's after a delete journal append.
if fs::metadata(&dst).is_ok() {
continue;
}
let candidates = [
sidecar_dest_path(src_data_dir, src_idx_dir, collection, vid, ext),
sidecar_dest_path(src_idx_dir, src_data_dir, collection, vid, ext),
];
let mut src_path: Option<String> = None;
for c in candidates.iter() {
if fs::metadata(c).map(|m| !m.is_dir()).unwrap_or(false) {
src_path = Some(c.clone());
break;
}
}
let Some(src) = src_path else {
continue;
};
copy_sidecar_atomic(Path::new(&src), Path::new(&dst))?;
copied += 1;
}
Ok(copied)
}
fn copy_sidecar_atomic(src: &Path, dst: &Path) -> io::Result<()> {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
let mut src_file = fs::File::open(src)?;
let tmp = {
let mut s = dst.as_os_str().to_owned();
s.push(".mirror.tmp");
std::path::PathBuf::from(s)
};
let _ = fs::remove_file(&tmp);
let mut dst_file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
let mut buf = [0u8; 64 * 1024];
loop {
let n = src_file.read(&mut buf)?;
if n == 0 {
break;
}
dst_file.write_all(&buf[..n])?;
}
dst_file.sync_all()?;
drop(dst_file);
if let Err(e) = fs::rename(&tmp, dst) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
fn collect_shard_disk_volumes(loc: &DiskLocation) -> HashMap<EcKey, Vec<String>> {
let mut out: HashMap<EcKey, Vec<String>> = HashMap::new();
let Ok(read) = fs::read_dir(&loc.directory) else {
return out;
};
for ent in read.flatten() {
if ent.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
continue;
}
let name = ent.file_name().to_string_lossy().into_owned();
let Some(dot) = name.rfind('.') else {
continue;
};
let (base, ext) = name.split_at(dot);
if crate::storage::disk_location::is_ec_shard_extension(ext).is_none() {
continue;
}
match ent.metadata() {
Ok(meta) if meta.len() > 0 => {}
_ => continue,
}
let Some((collection, vid)) = parse_collection_volume_id_pub(base) else {
continue;
};
out.entry(EcKey { collection, vid })
.or_default()
.push(name);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MinFreeSpace;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::types::DiskType;
use crate::storage::volume::{VifEcShardConfig, VifVolumeInfo};
use tempfile::TempDir;
fn plant_shard(dir: &Path, collection: &str, vid: u32, shard_id: u8) {
let path = if collection.is_empty() {
dir.join(format!("{}.ec{:02}", vid, shard_id))
} else {
dir.join(format!("{}_{}.ec{:02}", collection, vid, shard_id))
};
fs::write(&path, b"shard data nonempty").unwrap();
}
fn plant_ecx(dir: &Path, collection: &str, vid: u32, bytes: &[u8]) {
let path = dir.join(format!("{}_{}.ecx", collection, vid));
fs::write(&path, bytes).unwrap();
}
fn plant_ecj(dir: &Path, collection: &str, vid: u32, bytes: &[u8]) {
let path = dir.join(format!("{}_{}.ecj", collection, vid));
fs::write(&path, bytes).unwrap();
}
fn plant_vif(dir: &Path, collection: &str, vid: u32, data_shards: u32, parity_shards: u32) {
let vif = VifVolumeInfo {
version: 3,
ec_shard_config: Some(VifEcShardConfig {
data_shards,
parity_shards,
}),
..Default::default()
};
let path = dir.join(format!("{}_{}.vif", collection, vid));
fs::write(&path, serde_json::to_string(&vif).unwrap()).unwrap();
}
fn add_loc(store: &mut Store, dir: &Path) {
store
.add_location(
dir.to_str().unwrap(),
dir.to_str().unwrap(),
100,
DiskType::HardDrive,
MinFreeSpace::Percent(0.0),
Vec::new(),
)
.unwrap();
}
#[test]
fn mirror_copies_sidecars_to_shard_only_disk() {
let tmp = TempDir::new().unwrap();
let dir0 = tmp.path().join("data0");
let dir1 = tmp.path().join("data1");
fs::create_dir_all(&dir0).unwrap();
fs::create_dir_all(&dir1).unwrap();
let collection = "video-recordings";
let vid = 4121u32;
plant_shard(&dir0, collection, vid, 0);
plant_shard(&dir0, collection, vid, 12);
plant_shard(&dir1, collection, vid, 1);
let ecx = vec![0xA1u8; 20];
let ecj = vec![0xB2u8; 16];
plant_ecx(&dir1, collection, vid, &ecx);
plant_ecj(&dir1, collection, vid, &ecj);
plant_vif(&dir1, collection, vid, 10, 4);
let mut store = Store::new(NeedleMapKind::InMemory);
add_loc(&mut store, &dir0);
add_loc(&mut store, &dir1);
for ext in [".ecx", ".ecj", ".vif"] {
let dst = dir0.join(format!("{}_{}{}", collection, vid, ext));
assert!(
dst.exists(),
"mirror did not install sidecar {} on dir0",
ext
);
}
let ecx_dst = fs::read(dir0.join(format!("{}_{}.ecx", collection, vid))).unwrap();
assert_eq!(ecx_dst, ecx, ".ecx mirrored bytes differ from source");
let ecj_dst = fs::read(dir0.join(format!("{}_{}.ecj", collection, vid))).unwrap();
assert_eq!(ecj_dst, ecj, ".ecj mirrored bytes differ from source");
}
#[test]
fn mirror_preserves_existing_destination_sidecars() {
let tmp = TempDir::new().unwrap();
let dir0 = tmp.path().join("data0");
let dir1 = tmp.path().join("data1");
fs::create_dir_all(&dir0).unwrap();
fs::create_dir_all(&dir1).unwrap();
let collection = "video-recordings";
let vid = 7777u32;
plant_shard(&dir0, collection, vid, 0);
plant_shard(&dir0, collection, vid, 12);
plant_shard(&dir1, collection, vid, 1);
let ecx_owner = vec![0xC3u8; 20];
let ecx_local = vec![0x5Au8; 20];
let ecj_bytes = vec![0xD4u8; 16];
plant_ecx(&dir1, collection, vid, &ecx_owner);
plant_ecj(&dir1, collection, vid, &ecj_bytes);
plant_vif(&dir1, collection, vid, 10, 4);
plant_ecx(&dir0, collection, vid, &ecx_local);
plant_ecj(&dir0, collection, vid, &ecj_bytes);
plant_vif(&dir0, collection, vid, 10, 4);
let mut store = Store::new(NeedleMapKind::InMemory);
add_loc(&mut store, &dir0);
add_loc(&mut store, &dir1);
let post = fs::read(dir0.join(format!("{}_{}.ecx", collection, vid))).unwrap();
assert_eq!(post, ecx_local, "mirror overwrote dir0's existing .ecx");
}
}
+20 -489
View File
@@ -21,30 +21,9 @@ use std::fs;
use tracing::{info, warn};
use crate::storage::disk_location::{is_ec_shard_extension, parse_collection_volume_id_pub};
use crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT;
use crate::storage::store::Store;
use crate::storage::super_block::SUPER_BLOCK_SIZE;
use crate::storage::types::VolumeId;
pub(crate) fn ec_local_ecx_path(dir: &str, collection: &str, vid: VolumeId) -> String {
if collection.is_empty() {
format!("{}/{}.ecx", dir, vid.0)
} else {
format!("{}/{}_{}.ecx", dir, collection, vid.0)
}
}
/// Sibling-disk `.dat` candidate for `prune_incomplete_ec_with_sibling_dat`.
/// We record both the disk index and the file's size: the size is
/// consulted before deleting any EC artefacts. A zero-byte or truncated
/// `.dat` is not a credible fallback, and we'd rather leave the partial
/// EC in place than wipe it based on garbage.
#[derive(Clone, Copy, Debug)]
struct DatOwnerInfo {
location: usize,
size: u64,
}
/// Key for orphan-shard reconciliation: collection + volume id. Two
/// collections can re-use the same volume id, and we must only pair
/// shards with their own `.ecx`.
@@ -84,10 +63,10 @@ impl Store {
return;
}
// `use_local_idx` is the post-mirror fast path: when the
// mirror already installed sidecars locally, mount against
// loc.idx_directory instead of the owner disk.
let mut to_load: Vec<(usize, EcKey, Vec<(String, u32)>, EcxOwnerInfo, bool)> = Vec::new();
// Snapshot of orphan shards, keyed by (loc_idx, ec_key) so we
// can release the immutable borrow on self.locations before
// calling mount_ec_shards_with_idx_dir (which needs &mut).
let mut to_load: Vec<(usize, EcKey, Vec<(String, u32)>, EcxOwnerInfo)> = Vec::new();
for (loc_idx, loc) in self.locations.iter().enumerate() {
let orphans = collect_orphan_ec_shards(loc, loc_idx);
for (key, shards) in orphans {
@@ -101,57 +80,34 @@ impl Store {
);
continue;
};
let local_ecx = ec_local_ecx_path(&loc.idx_directory, &key.collection, key.vid);
let local_ecx_in_data = ec_local_ecx_path(&loc.directory, &key.collection, key.vid);
let use_local_idx = std::path::Path::new(&local_ecx).exists()
|| std::path::Path::new(&local_ecx_in_data).exists();
if !use_local_idx
&& owner.location == loc_idx
&& owner.idx_dir == loc.idx_directory
{
// Same-disk no-op: load_all_ec_shards already
// tried and logged the failure.
if owner.location == loc_idx && owner.idx_dir == loc.idx_directory {
// Normal same-disk case: load_all_ec_shards already
// attempted the mount via `loc.idx_directory` and
// logged the underlying failure. No point retrying
// the same call.
continue;
}
to_load.push((loc_idx, key, shards, owner.clone(), use_local_idx));
// Either a cross-disk owner OR a same-disk owner whose
// `.ecx` actually lives in `loc.directory` (the legacy
// pre-`-dir.idx` layout). The latter wasn't tried by
// load_all_ec_shards, which only looked in
// `self.idx_directory`, so we still need to retry it
// here with the owner's discovered idx_dir.
to_load.push((loc_idx, key, shards, owner.clone()));
}
}
for (loc_idx, key, shards, owner, use_local_idx) in to_load {
for (loc_idx, key, shards, owner) in to_load {
let shard_names: Vec<&str> = shards.iter().map(|(n, _)| n.as_str()).collect();
let loc_dir = self.locations[loc_idx].directory.clone();
let shard_ids: Vec<u32> = shards.iter().map(|(_, sid)| *sid).collect();
if use_local_idx {
info!(
volume_id = key.vid.0,
collection = %key.collection,
directory = %loc_dir,
"loading orphan EC shards against locally-mirrored sidecars: {:?}",
shard_names,
);
let loc = &mut self.locations[loc_idx];
if let Err(e) = loc.mount_ec_shards(key.vid, &key.collection, &shard_ids, "") {
loc.unmount_ec_shards(key.vid, &shard_ids);
warn!(
volume_id = key.vid.0,
directory = %loc_dir,
"local-mirror shard load failed: {}",
e,
);
}
continue;
}
info!(
volume_id = key.vid.0,
collection = %key.collection,
from = %self.locations[owner.location].directory,
to = %loc_dir,
to = %self.locations[loc_idx].directory,
"loading orphan EC shards using index files from sibling disk (issue #9212): {:?}",
shard_names,
);
let shard_ids: Vec<u32> = shards.iter().map(|(_, sid)| *sid).collect();
let owner_idx_dir = owner.idx_dir.clone();
let loc = &mut self.locations[loc_idx];
if let Err(e) = loc.mount_ec_shards_with_idx_dir(
@@ -159,7 +115,6 @@ impl Store {
&key.collection,
&shard_ids,
&owner_idx_dir,
"",
) {
// mount_ec_shards_with_idx_dir adds shards one at a
// time and increments the `ec_shards` gauge per shard
@@ -172,7 +127,7 @@ impl Store {
loc.unmount_ec_shards(key.vid, &shard_ids);
warn!(
volume_id = key.vid.0,
directory = %loc_dir,
directory = %loc.directory,
"cross-disk shard load failed: {}",
e,
);
@@ -180,187 +135,6 @@ impl Store {
}
}
/// Remove leftover EC artefacts on one disk when a healthy `.dat`
/// for the same `(collection, vid)` lives on a sibling disk of the
/// same store. Mirrors `Store.pruneIncompleteEcWithSiblingDat` in
/// `weed/storage/store_ec_reconcile.go` (seaweedfs/seaweedfs#9478).
///
/// `DiskLocation::handle_found_ecx_file` only looks for `.dat` in
/// the same disk as the EC shards. In a multi-disk volume server an
/// interrupted EC encode can leave `.ec??` + `.ecx` on disk B while
/// the source `.dat` still lives on disk A. The per-disk loader
/// sees no local `.dat`, classifies the layout as distributed EC,
/// and mounts the partial shards. The volume server then heartbeats
/// both a regular replica and an EC shard for the same vid, and
/// master ends up with a contradictory view.
///
/// Cleanup is gated on `shard_count < DATA_SHARDS_COUNT` so that a
/// deliberate "full local EC, `.dat` retained" layout split across
/// two disks (`.dat` on disk A, all 10+ shards on disk B) is left
/// alone — the per-disk loader already keeps that configuration on
/// a single disk, and pruning it here would be a behaviour
/// regression. Distributed EC volumes (no `.dat` on any disk of
/// 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.
///
/// We don't have to push anything to a deleted-shards channel
/// here: the Rust heartbeat path in `server/heartbeat.rs` diffs
/// the current `ec_volumes` snapshot against the previous one each
/// tick, so the first heartbeat after the prune naturally emits a
/// delete delta for whatever the per-disk pass had already
/// reported.
pub fn prune_incomplete_ec_with_sibling_dat(&mut self) {
if self.locations.len() < 2 {
return;
}
let dat_owners = self.index_dat_owners();
if dat_owners.is_empty() {
return;
}
// Snapshot under an immutable borrow so we can release it
// before taking the mutable borrow needed for cleanup.
struct Victim {
loc_idx: usize,
collection: String,
vid: VolumeId,
dat_dir: String,
shard_count: usize,
}
let mut victims: Vec<Victim> = Vec::new();
for (loc_idx, loc) in self.locations.iter().enumerate() {
for (vid, ev) in loc.ec_volumes() {
let shard_count = ev.shard_count();
if shard_count >= DATA_SHARDS_COUNT {
continue;
}
let key = EcKey {
collection: ev.collection.clone(),
vid: *vid,
};
let Some(owner) = dat_owners.get(&key) else {
continue;
};
if owner.location == loc_idx {
// Same-disk .dat is the job of
// DiskLocation::validate_ec_volume during the
// 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.
let required = if ev.dat_file_size > 0 {
ev.dat_file_size as u64
} else {
SUPER_BLOCK_SIZE as u64
};
if owner.size < required {
warn!(
volume_id = vid.0,
collection = %ev.collection,
directory = %loc.directory,
shard_count,
sibling_dir = %self.locations[owner.location].directory,
sibling_dat_size = owner.size,
required,
"sibling .dat is smaller than the EC source size; leaving partial EC in place so distributed reconstruction is still possible (issue 9478)",
);
continue;
}
victims.push(Victim {
loc_idx,
collection: ev.collection.clone(),
vid: *vid,
dat_dir: self.locations[owner.location].directory.clone(),
shard_count,
});
}
}
for v in victims {
warn!(
volume_id = v.vid.0,
collection = %v.collection,
directory = %self.locations[v.loc_idx].directory,
shard_count = v.shard_count,
required = DATA_SHARDS_COUNT,
dat_dir = %v.dat_dir,
"partial EC shards on this disk while a healthy .dat exists on a sibling disk; cleaning up leftover EC files (issue 9478)",
);
let loc = &mut self.locations[v.loc_idx];
if let Some(mut ec_vol) = loc.remove_ec_volume(v.vid) {
for _ in 0..ec_vol.shard_count() {
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&ec_vol.collection, "ec_shards"])
.dec();
}
// destroy() closes shard file handles before unlinking
// (matters on Windows) and removes .ecx / .ecj / .vif.
ec_vol.destroy();
}
// Also sweep any unmounted shard files (.ec00 .. .ec31)
// that the per-disk loader skipped — destroy() only walks
// the in-memory shards, but the disk may still hold others.
loc.remove_ec_volume_files(&v.collection, v.vid);
}
}
/// Returns, for every `(collection, vid)`, the index of the first
/// disk on this store that holds a `.dat` for it plus the file's
/// size. Used by `prune_incomplete_ec_with_sibling_dat` so it can
/// decide whether partial EC artefacts on another disk are
/// leftovers of an interrupted encode AND whether the sibling
/// `.dat` is large enough to be a credible fallback.
///
/// Any `.dat` `read_dir` can see is recorded — including zero-byte
/// shells. Their mere presence means this volume was a regular
/// volume on this server at some point, which rules out the
/// "distributed EC, no .dat anywhere" reading. Whether the file is
/// actually usable is the caller's call, made by comparing this
/// size to the EC's recorded source size in `.vif`.
fn index_dat_owners(&self) -> HashMap<EcKey, DatOwnerInfo> {
let mut owners: HashMap<EcKey, DatOwnerInfo> = HashMap::new();
for (loc_idx, loc) in self.locations.iter().enumerate() {
let Ok(read) = fs::read_dir(&loc.directory) else {
continue;
};
for ent in read.flatten() {
if ent.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
continue;
}
let name = ent.file_name().to_string_lossy().into_owned();
let Some(base) = name.strip_suffix(".dat") else {
continue;
};
let Some((collection, vid)) = parse_collection_volume_id_pub(base) else {
continue;
};
let Ok(meta) = ent.metadata() else {
continue;
};
owners
.entry(EcKey { collection, vid })
.or_insert(DatOwnerInfo {
location: loc_idx,
size: meta.len(),
});
}
}
owners
}
/// Build a `(collection, vid) -> EcxOwnerInfo` map of which disk
/// owns the `.ecx` file. `.ecx` normally lives in `IdxDirectory`
/// but may have been written into the data directory before
@@ -950,249 +724,6 @@ mod tests {
assert!(ev.has_shard(0));
assert!(ev.has_shard(1));
}
/// Reproduces the issue 9478 layout for a single volume server:
/// disk A holds a healthy `.dat` for vid 122; disk B holds a single
/// `.ec01` plus `.ecx` / `.ecj` / `.vif` and no `.dat`. The per-disk
/// loader mounts the partial shard on disk B as if it were a
/// distributed-EC layout. The Store-level prune must unmount and
/// delete the leftover EC files on disk B while leaving disk A's
/// `.dat` untouched.
#[test]
fn test_prune_drops_partial_ec_when_sibling_disk_has_dat() {
let tmp = TempDir::new().unwrap();
let dat_dir = tmp.path().join("sdd");
let ec_dir = tmp.path().join("sdf");
std::fs::create_dir_all(&dat_dir).unwrap();
std::fs::create_dir_all(&ec_dir).unwrap();
let collection = "";
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('_'));
std::fs::write(&dat_path, vec![0u8; 1024]).unwrap();
// Disk B (sdf): partial EC — one shard, plus .ecx / .ecj / .vif.
write_shard(ec_dir.to_str().unwrap(), collection, vid, 1);
write_index_files(ec_dir.to_str().unwrap(), collection, vid, 10, 4);
let mut store = Store::new(NeedleMapKind::InMemory);
store
.add_location(
dat_dir.to_str().unwrap(),
dat_dir.to_str().unwrap(),
100,
DiskType::HardDrive,
MinFreeSpace::Percent(0.0),
Vec::new(),
)
.unwrap();
store
.add_location(
ec_dir.to_str().unwrap(),
ec_dir.to_str().unwrap(),
100,
DiskType::HardDrive,
MinFreeSpace::Percent(0.0),
Vec::new(),
)
.unwrap();
// After add_location for sdf, the per-disk loader has mounted
// the partial shard AND the prune has run as part of
// add_location's epilogue. Confirm the prune removed it.
let ec_loc = &store.locations[1];
assert!(
ec_loc.find_ec_volume(VolumeId(vid)).is_none(),
"partial EC volume should have been unmounted by the prune",
);
let ec_base = ec_dir
.join(format!("{}_{}", collection, vid).trim_start_matches('_'))
.to_string_lossy()
.into_owned();
assert!(
!std::path::Path::new(&format!("{}.ec01", ec_base)).exists(),
"partial shard file should have been removed",
);
assert!(
!std::path::Path::new(&format!("{}.ecx", ec_base)).exists(),
".ecx should have been removed",
);
assert!(
!std::path::Path::new(&format!("{}.ecj", ec_base)).exists(),
".ecj should have been removed",
);
// The .dat on the sibling disk must survive — pruning the
// partial EC must never touch the healthy replica.
assert!(
dat_path.exists(),
"healthy .dat on sibling disk should be left alone",
);
}
/// Safety guard for `prune_incomplete_ec_with_sibling_dat`: when
/// the sibling `.dat` is smaller than the source size recorded in
/// the EC's `.vif`, the `.dat` is clearly truncated and is not a
/// credible fallback. The partial shard may still combine with
/// shards on other servers in a recoverable distributed-EC layout,
/// so we leave the EC files in place.
#[test]
fn test_prune_keeps_partial_ec_when_sibling_dat_is_smaller_than_vif_source_size() {
let tmp = TempDir::new().unwrap();
let dat_dir = tmp.path().join("sdd");
let ec_dir = tmp.path().join("sdf");
std::fs::create_dir_all(&dat_dir).unwrap();
std::fs::create_dir_all(&ec_dir).unwrap();
let collection = "logs";
let vid = 122u32;
// Tiny but loadable .dat on sdd: a valid 8-byte version-3
// superblock so Volume::new succeeds, padded out to a few
// hundred bytes so we have something well below the .vif-
// recorded source size below. Anything smaller would let
// Volume::new's auto-write extend the file to SUPER_BLOCK_SIZE
// and obscure the truncation we're trying to model.
let dat_path = dat_dir.join(format!("{}_{}.dat", collection, vid));
let mut dat_bytes = crate::storage::super_block::SuperBlock::default().to_bytes();
dat_bytes.resize(256, 0u8);
std::fs::write(&dat_path, &dat_bytes).unwrap();
let sibling_dat_size = dat_bytes.len() as u64;
let source_size = 10 * 1024 * 1024u64; // 10 MiB recorded in .vif
assert!(sibling_dat_size < source_size);
// Partial EC: one shard plus .ecx / .ecj / .vif. The .vif
// records the source .dat size at encode time; the safety
// check compares the sibling .dat against this value.
write_shard(ec_dir.to_str().unwrap(), collection, vid, 1);
let vif = VifVolumeInfo {
version: 3,
dat_file_size: source_size as i64,
ec_shard_config: Some(VifEcShardConfig {
data_shards: 10,
parity_shards: 4,
}),
..Default::default()
};
std::fs::write(
ec_dir.join(format!("{}_{}.vif", collection, vid)),
serde_json::to_string(&vif).unwrap(),
)
.unwrap();
std::fs::write(
ec_dir.join(format!("{}_{}.ecx", collection, vid)),
vec![0u8; 20],
)
.unwrap();
std::fs::write(
ec_dir.join(format!("{}_{}.ecj", collection, vid)),
b"",
)
.unwrap();
let mut store = Store::new(NeedleMapKind::InMemory);
store
.add_location(
dat_dir.to_str().unwrap(),
dat_dir.to_str().unwrap(),
100,
DiskType::HardDrive,
MinFreeSpace::Percent(0.0),
Vec::new(),
)
.unwrap();
store
.add_location(
ec_dir.to_str().unwrap(),
ec_dir.to_str().unwrap(),
100,
DiskType::HardDrive,
MinFreeSpace::Percent(0.0),
Vec::new(),
)
.unwrap();
// Prune ran as part of add_location's epilogue. Confirm the
// partial EC was left alone because the sibling .dat is too
// small to be the encoding source.
let ec_loc = &store.locations[1];
let ev = ec_loc
.find_ec_volume(VolumeId(vid))
.expect("partial EC volume must remain mounted when the sibling .dat is smaller than the .vif source size");
assert_eq!(ev.shard_count(), 1);
let ec_base = ec_dir
.join(format!("{}_{}", collection, vid))
.to_string_lossy()
.into_owned();
assert!(
std::path::Path::new(&format!("{}.ec01", ec_base)).exists(),
"partial shard file must survive when the sibling .dat is truncated",
);
assert!(
std::path::Path::new(&format!("{}.ecx", ec_base)).exists(),
".ecx must survive when the sibling .dat is truncated",
);
}
/// A distributed EC volume — no `.dat` on any disk of this store —
/// must not be touched by the prune even when shard count is below
/// `DATA_SHARDS_COUNT`. Partial shard layouts are normal for
/// distributed EC.
#[test]
fn test_prune_leaves_distributed_ec_alone() {
let tmp = TempDir::new().unwrap();
let dir0 = tmp.path().join("data0");
let dir1 = tmp.path().join("data1");
std::fs::create_dir_all(&dir0).unwrap();
std::fs::create_dir_all(&dir1).unwrap();
let collection = "logs";
let vid = 7u32;
// Two shards on dir0 with .ecx, no .dat anywhere.
write_shard(dir0.to_str().unwrap(), collection, vid, 1);
write_shard(dir0.to_str().unwrap(), collection, vid, 2);
write_index_files(dir0.to_str().unwrap(), collection, vid, 10, 4);
let mut store = Store::new(NeedleMapKind::InMemory);
for dir in [&dir0, &dir1] {
store
.add_location(
dir.to_str().unwrap(),
dir.to_str().unwrap(),
100,
DiskType::HardDrive,
MinFreeSpace::Percent(0.0),
Vec::new(),
)
.unwrap();
}
let ev = store.locations[0]
.find_ec_volume(VolumeId(vid))
.expect("distributed EC volume should still be mounted on dir0");
assert_eq!(
ev.shard_count(),
2,
"no .dat anywhere on this store means the partial shards must be kept as distributed EC",
);
let ec_base = dir0
.join(format!("{}_{}", collection, vid))
.to_string_lossy()
.into_owned();
assert!(std::path::Path::new(&format!("{}.ec01", ec_base)).exists());
assert!(std::path::Path::new(&format!("{}.ec02", ec_base)).exists());
assert!(std::path::Path::new(&format!("{}.ecx", ec_base)).exists());
}
}
/// Walk a disk's data directory and return the `.ec??` shard files
+1 -231
View File
@@ -2122,34 +2122,7 @@ impl Volume {
}
/// Mark this volume as writable (allow writes and deletes).
///
/// If the volume booted with .vif ReadOnly=true, `load_index` built the
/// needle map without an .idx writer attached, so subsequent puts would
/// silently skip the on-disk append and only mutate in-memory state —
/// surviving until the next restart, then vanishing. Re-attach a writer
/// here so writes persist again.
pub fn set_writable(&mut self) -> Result<(), VolumeError> {
// Attach the writer (if missing) before flipping the flag — otherwise
// a transient open/metadata failure would leave the volume marked
// writable with no .idx writer, and subsequent puts would silently
// skip the on-disk append and vanish on the next restart.
let needs_idx_writer = self
.nm
.as_ref()
.map(|nm| !nm.has_idx_writer())
.unwrap_or(false);
if needs_idx_writer {
let idx_path = self.file_name(".idx");
let write_file = OpenOptions::new()
.write(true)
.append(true)
.create(true)
.open(&idx_path)?;
let idx_size = write_file.metadata()?.len();
if let Some(ref mut nm) = self.nm {
nm.set_idx_file(Box::new(write_file), idx_size);
}
}
self.no_write_or_delete = false;
self.save_vif()
}
@@ -3230,17 +3203,11 @@ 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) {
for ext in &[
".dat", ".idx", ".vif", ".sdx", ".cpd", ".cpx", ".note", ".rdb",
] {
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();
if existed && (*ext == ".dat" || *ext == ".idx") {
tracing::info!("removed volume file {} (size={})", path, size);
}
let _ = fs::remove_file(format!("{}{}", base, ext));
}
// leveldb uses a directory
let _ = fs::remove_dir_all(format!("{}.ldb", base));
@@ -3592,118 +3559,6 @@ mod tests {
assert!(matches!(err, VolumeError::Deleted));
}
// Guard the Rust integrity-check tombstone path against the Go regression
// where verifyDeletedNeedleIntegrity forwarded TombstoneFileSize into the
// needle-size check, mismatched against the on-disk Size=0 header, and
// sent every volume with a trailing deletion read-only on load. The Rust
// check guards its size comparison with !size.is_deleted(); this test
// keeps that guarantee from silently regressing.
#[test]
fn test_check_volume_data_integrity_with_deletion_tombstone() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
{
let mut v = make_test_volume(dir);
for i in 1..=3 {
let data = format!("data {}", i);
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(i as u32),
data: data.as_bytes().to_vec(),
data_size: data.len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true).unwrap();
}
v.delete_needle(&mut Needle {
id: NeedleId(2),
cookie: Cookie(2),
..Needle::default()
})
.unwrap();
v.sync_to_disk().unwrap();
}
let v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert!(
!v.is_no_write_or_delete(),
"volume should not be read-only after reload with trailing deletion tombstone"
);
}
#[test]
fn test_scrub_empty_volume() {
// Mirror of Go's TestScrubVolumeData "zero-size volume without index"
// case (weed/storage/volume_checking_test.go): a freshly created /
// pre-allocated volume has a superblock-only .dat and a zero-size .idx,
// and must scrub clean instead of being flagged as corrupt.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let v = make_test_volume(dir);
// .dat holds only the superblock; .idx is empty.
assert_eq!(v.dat_file_size().unwrap(), SUPER_BLOCK_SIZE as u64);
let (files_checked, broken) = v.scrub().unwrap();
assert_eq!(files_checked, 0);
assert!(
broken.is_empty(),
"empty volume should scrub clean, got {:?}",
broken
);
// The index-only mode must agree.
let (idx_checked, idx_broken) = v.scrub_index().unwrap();
assert_eq!(idx_checked, 0);
assert!(
idx_broken.is_empty(),
"empty volume should scrub_index clean, got {:?}",
idx_broken
);
}
#[test]
fn test_scrub_healthy_volume() {
// Mirror of Go's TestScrubVolumeData "healthy volume" case: a volume
// with live needles scrubs clean and the .dat size accounting matches.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut v = make_test_volume(dir);
for i in 1..=5 {
let data = format!("needle data {}", i);
let mut n = Needle {
id: NeedleId(i),
cookie: Cookie(i as u32),
data: data.as_bytes().to_vec(),
data_size: data.len() as u32,
..Needle::default()
};
v.write_needle(&mut n, true).unwrap();
}
v.sync_to_disk().unwrap();
let (files_checked, broken) = v.scrub().unwrap();
assert_eq!(files_checked, 5);
assert!(
broken.is_empty(),
"healthy volume should scrub clean, got {:?}",
broken
);
}
#[test]
fn test_volume_multiple_needles() {
let tmp = TempDir::new().unwrap();
@@ -4330,91 +4185,6 @@ mod tests {
assert!(v.no_write_can_delete);
}
// A volume booted with .vif ReadOnly=true used to come back stuck —
// load_index_inmemory built the CompactNeedleMap without an .idx writer
// attached, and set_writable only flipped the flag and rewrote .vif.
// The next put silently skipped the .idx append, so the write landed in
// memory only and was lost on the next restart.
#[test]
fn test_set_writable_reattaches_idx_writer_after_persisted_readonly() {
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"initial".to_vec(),
data_size: 7,
..Needle::default()
};
v.write_needle(&mut n, true).unwrap();
v.set_read_only_persist(true).unwrap();
v.sync_to_disk().unwrap();
}
let mut v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
assert!(
v.no_write_or_delete,
"reloaded volume should be read-only from .vif"
);
assert!(
!v.nm.as_ref().unwrap().has_idx_writer(),
"read-only load should not attach an .idx writer"
);
v.set_writable().unwrap();
assert!(!v.is_read_only());
assert!(
v.nm.as_ref().unwrap().has_idx_writer(),
"set_writable must reattach the .idx writer or post-restart writes vanish"
);
let mut n = Needle {
id: NeedleId(2),
cookie: Cookie(2),
data: b"after-mark-writable".to_vec(),
data_size: 19,
..Needle::default()
};
v.write_needle(&mut n, true).unwrap();
v.sync_to_disk().unwrap();
// Reload one more time — the .idx must contain the post-mark-writable
// entry, not just have it in memory.
drop(v);
let v = Volume::new(
dir,
dir,
"",
VolumeId(1),
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
let mut probe = Needle {
id: NeedleId(2),
..Needle::default()
};
v.read_needle(&mut probe).unwrap();
assert_eq!(std::str::from_utf8(&probe.data).unwrap(), "after-mark-writable");
}
#[test]
fn test_load_vif_defaults_local_version_and_bytes_offset() {
let tmp = TempDir::new().unwrap();
-2
View File
@@ -101,8 +101,6 @@ fn test_state_with_guard(
read_mode: seaweed_volume::config::ReadMode::Local,
master_url: String::new(),
master_urls: Vec::new(),
seed_master_set: std::collections::HashSet::new(),
current_master_url: tokio::sync::RwLock::new(String::new()),
self_url: String::new(),
http_client: reqwest::Client::new(),
outgoing_http_scheme: "http".to_string(),
@@ -247,7 +247,7 @@ func TestEcEndToEnd(t *testing.T) {
"job_type": "erasure_coding",
"admin_runtime": map[string]interface{}{
"enabled": true,
"detection_interval_minutes": 1,
"detection_interval_seconds": 1,
"global_execution_concurrency": 4,
"per_worker_execution_concurrency": 4,
"max_jobs_per_detection": 100,
@@ -1,265 +0,0 @@
package erasure_coding
import (
"context"
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/shell"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
// TestMultiDiskECBalanceNoShardLoss is the end-to-end regression for issue 9593.
// It runs a real cluster of multi-disk volume servers (3 servers x 4 disks),
// EC-encodes a volume, then runs ec.balance, asserting hard invariants the older
// integration tests only logged:
//
// - after encode the full set of 14 EC shards exists,
// - ec.balance never loses a shard (still 14 distinct shards afterwards),
// - shards end up spread across more than one disk per node, and
// - cluster.status counts physical disks (not one per node) and matches the
// real on-disk distribution.
func TestMultiDiskECBalanceNoShardLoss(t *testing.T) {
if testing.Short() {
t.Skip("Skipping multi-disk EC integration test in short mode")
}
testDir := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 240*time.Second)
defer cancel()
cluster, err := startMultiDiskCluster(ctx, testDir)
require.NoError(t, err)
defer cluster.Stop()
require.NoError(t, waitForServer("127.0.0.1:9334", 30*time.Second))
for i := 0; i < 3; i++ {
require.NoError(t, waitForServer(fmt.Sprintf("127.0.0.1:809%d", i), 30*time.Second))
}
t.Log("waiting for multi-disk volume servers to register...")
time.Sleep(10 * time.Second)
commandEnv := shell.NewCommandEnv(&shell.ShellOptions{
Masters: stringPtr("127.0.0.1:9334"),
GrpcDialOption: grpc.WithInsecure(),
FilerGroup: stringPtr("default"),
})
connectToMasterAndSync(ctx, t, commandEnv)
// Upload enough small files that the volume holds real data to encode.
var volumeId needle.VolumeId
for retry := 0; retry < 5; retry++ {
volumeId, err = uploadTestDataToMaster([]byte(strings.Repeat("multidisk-ec-9593 ", 64)), "127.0.0.1:9334")
if err == nil {
break
}
time.Sleep(3 * time.Second)
}
require.NoError(t, err, "failed to upload test data")
for i := 0; i < 40; i++ {
if _, e := uploadTestDataToMaster([]byte(strings.Repeat("filler ", 128)), "127.0.0.1:9334"); e != nil {
break
}
}
t.Logf("using volume %d", volumeId)
time.Sleep(3 * time.Second)
// Populate every server's disks with volumes so the encode can see and target
// each physical disk. The master only enumerates disks that already hold a
// volume or EC shard — an empty disk leaves no trace in the topology (heartbeats
// aggregate capacity per disk type, not per physical disk). ec.encode therefore
// spreads a volume's shards only across the disks the master already knows hold
// data on each node; if a node's data sits on a single disk, all its shards land
// there and ec.balance cannot redistribute them (it has no within-node
// cross-disk move). So spreading must be set up before encoding.
//
// volume.grow only tops up toward a writable target and stops on the first
// allocation error, so a single -count grow can create far fewer volumes than
// asked and leave a node on one disk. Grow repeatedly on the nodes that have not
// spread yet (the volume server places each new volume on its least-loaded disk)
// until the master's topology shows every node holding volumes on at least two
// physical disks. This makes the multi-disk layout — and thus the post-encode
// disk spread — deterministic instead of racing volume-growth and heartbeat.
require.Eventually(t, func() bool {
spread := nodeVolumeDiskCounts(t, commandEnv)
if len(spread) == 3 && allAtLeast(spread, 2) {
return true
}
for i := 0; i < 3; i++ {
server := fmt.Sprintf("127.0.0.1:809%d", i)
if spread[server] < 2 {
captureCommandOutput(t, shell.Commands[findCommandIndex("volume.grow")],
[]string{"-collection", "test", "-dataNode", server, "-count", "4"}, commandEnv)
}
}
return false
}, 60*time.Second, 2*time.Second,
"volumes never spread across >=2 disks on all 3 nodes")
locked, unlock := tryLockWithTimeout(t, commandEnv, 15*time.Second)
require.True(t, locked, "could not acquire shell lock")
defer unlock()
// EC-encode the volume.
out, err := captureCommandOutput(t, shell.Commands[findCommandIndex("ec.encode")],
[]string{"-volumeId", fmt.Sprintf("%d", volumeId), "-collection", "test", "-force"}, commandEnv)
t.Logf("ec.encode output:\n%s", out)
require.NoError(t, err, "ec.encode failed")
// All 14 shards must exist after encoding.
require.Eventually(t, func() bool {
return len(collectDistinctShardIDs(testDir, uint32(volumeId))) == erasureShardCount
}, 30*time.Second, time.Second, "expected all %d EC shards after encode, got %v",
erasureShardCount, collectDistinctShardIDs(testDir, uint32(volumeId)))
beforeBalance := collectDistinctShardIDs(testDir, uint32(volumeId))
t.Logf("after encode: %d distinct shards on %d disks", len(beforeBalance), disksWithShards(testDir, uint32(volumeId)))
// Run ec.balance.
out, err = captureCommandOutput(t, shell.Commands[findCommandIndex("ec.balance")],
[]string{"-collection", "test", "-force"}, commandEnv)
t.Logf("ec.balance output:\n%s", out)
require.NoError(t, err, "ec.balance failed")
time.Sleep(3 * time.Second)
// The core regression: ec.balance must not lose any shard.
afterBalance := collectDistinctShardIDs(testDir, uint32(volumeId))
require.Equal(t, erasureShardCount, len(afterBalance),
"ec.balance lost shards on multi-disk nodes: had %v, now %v", sortedKeysOf(beforeBalance), sortedKeysOf(afterBalance))
// Shards must be spread across more than one physical disk per node overall.
usedDisks := disksWithShards(testDir, uint32(volumeId))
assert.Greater(t, usedDisks, 3, "EC shards should span more than one disk per node (got %d disks across 3 nodes)", usedDisks)
// cluster.status must count physical disks, not collapse to one per node: it
// must report at least the disks actually holding this volume's shards (which
// is already >3 across the 3 nodes). Before the fix it reported 3 (node count).
require.Eventually(t, func() bool {
n, ok := clusterStatusDiskCount(t, commandEnv)
return ok && n >= usedDisks
}, 30*time.Second, 2*time.Second, "cluster.status never reported the >=%d physical disks holding shards (multi-disk count)", usedDisks)
n, _ := clusterStatusDiskCount(t, commandEnv)
t.Logf("cluster.status reports %d physical disks (>= %d holding this volume's shards)", n, usedDisks)
}
const erasureShardCount = 14 // 10 data + 4 parity
// collectDistinctShardIDs returns the set of EC shard ids present for a volume
// across every disk of every server in the multi-disk test layout.
func collectDistinctShardIDs(testDir string, volumeId uint32) map[int]bool {
ids := map[int]bool{}
for server := 0; server < 3; server++ {
for disk := 0; disk < 4; disk++ {
diskDir := filepath.Join(testDir, fmt.Sprintf("server%d_disk%d", server, disk))
files, err := listECShardFiles(diskDir, volumeId)
if err != nil {
continue
}
for _, f := range files {
i := strings.LastIndex(f, ".ec")
if i < 0 {
continue
}
if n, err := strconv.Atoi(f[i+3:]); err == nil && n >= 0 && n < erasureShardCount {
ids[n] = true
}
}
}
}
return ids
}
// disksWithShards counts how many physical disks hold at least one shard.
func disksWithShards(testDir string, volumeId uint32) int {
n := 0
for _, disks := range countShardsPerDisk(testDir, volumeId) {
for _, c := range disks {
if c > 0 {
n++
}
}
}
return n
}
// nodeVolumeDiskCounts returns, per volume server id, how many distinct physical
// disks hold at least one volume according to the master's topology. The master
// only enumerates disks that already hold a volume or EC shard (heartbeats
// aggregate capacity per disk type, not per physical disk), so this reports the
// disks ec.encode can actually spread a volume's shards across on each node.
func nodeVolumeDiskCounts(t *testing.T, commandEnv *shell.CommandEnv) map[string]int {
t.Helper()
var resp *master_pb.VolumeListResponse
err := commandEnv.MasterClient.WithClient(false, func(client master_pb.SeaweedClient) error {
var e error
resp, e = client.VolumeList(context.Background(), &master_pb.VolumeListRequest{})
return e
})
counts := map[string]int{}
if err != nil || resp.GetTopologyInfo() == nil {
return counts
}
for _, dc := range resp.GetTopologyInfo().GetDataCenterInfos() {
for _, r := range dc.GetRackInfos() {
for _, dn := range r.GetDataNodeInfos() {
disks := map[uint32]bool{}
for _, di := range dn.GetDiskInfos() {
for _, vi := range di.GetVolumeInfos() {
disks[vi.GetDiskId()] = true
}
}
counts[dn.Id] = len(disks)
}
}
}
return counts
}
func allAtLeast(counts map[string]int, min int) bool {
for _, c := range counts {
if c < min {
return false
}
}
return true
}
var diskCountRe = regexp.MustCompile(`(\d+)\s+disks?`)
// clusterStatusDiskCount runs cluster.status and parses the reported disk count.
func clusterStatusDiskCount(t *testing.T, commandEnv *shell.CommandEnv) (int, bool) {
t.Helper()
out, err := captureCommandOutput(t, shell.Commands[findCommandIndex("cluster.status")], []string{}, commandEnv)
if err != nil {
return 0, false
}
m := diskCountRe.FindStringSubmatch(out)
if m == nil {
return 0, false
}
n, err := strconv.Atoi(m[1])
return n, err == nil
}
func sortedKeysOf(m map[int]bool) []int {
out := make([]int, 0, len(m))
for k := range m {
out = append(out, k)
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j-1] > out[j]; j-- {
out[j-1], out[j] = out[j], out[j-1]
}
}
return out
}
+46 -45
View File
@@ -8,37 +8,36 @@ require (
github.com/seaweedfs/seaweedfs v0.0.0-00010101000000-000000000000
github.com/segmentio/kafka-go v0.4.49
github.com/stretchr/testify v1.11.1
google.golang.org/grpc v1.80.0
google.golang.org/grpc v1.79.3
)
replace github.com/seaweedfs/seaweedfs => ../../
require (
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.39 // indirect
github.com/Files-com/files-sdk-go/v3 v3.3.82 // indirect
github.com/IBM/go-sdk-core/v5 v5.21.2 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.38 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
github.com/IBM/go-sdk-core/v5 v5.21.0 // indirect
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect
github.com/abbot/go-http-auth v0.4.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
@@ -49,7 +48,7 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
@@ -63,7 +62,7 @@ require (
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 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/aws/smithy-go v1.25.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
@@ -75,9 +74,10 @@ require (
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.15.0 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.13.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/cognusion/imaging v1.0.2 // indirect
@@ -85,7 +85,7 @@ require (
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/creasty/defaults v1.8.0 // indirect
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/dromara/dongle v1.0.1 // indirect
@@ -100,22 +100,22 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/errors v0.22.6 // indirect
github.com/go-openapi/errors v0.22.4 // indirect
github.com/go-openapi/strfmt v0.25.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
@@ -125,8 +125,8 @@ require (
github.com/google/btree v1.1.3 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -134,7 +134,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260331173834-036f908d0160 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
@@ -143,14 +143,14 @@ require (
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jhump/protoreflect v1.18.0 // indirect
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 // indirect
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb // indirect
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
github.com/karlseguin/ccache/v2 v2.0.8 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/reedsolomon v1.14.0 // indirect
github.com/klauspost/reedsolomon v1.13.3 // indirect
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
github.com/kr/fs v0.1.0 // indirect
@@ -158,18 +158,18 @@ require (
github.com/lanrat/extsort v1.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lpar/date v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncw/swift/v2 v2.0.5 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/oracle/oci-go-sdk/v65 v65.111.0 // indirect
github.com/oracle/oci-go-sdk/v65 v65.104.0 // indirect
github.com/orcaman/concurrent-map/v2 v2.0.1 // indirect
github.com/panjf2000/ants/v2 v2.11.5 // indirect
github.com/panjf2000/ants/v2 v2.11.3 // indirect
github.com/parquet-go/bitpack v1.0.0 // indirect
github.com/parquet-go/jsonlite v1.0.0 // indirect
github.com/parquet-go/parquet-go v0.28.0 // indirect
@@ -188,22 +188,23 @@ require (
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
github.com/rclone/Proton-API-Bridge v1.0.3 // indirect
github.com/rclone/go-proton-api v1.0.2 // indirect
github.com/rclone/rclone v1.74.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect
github.com/rclone/rclone v1.73.5 // indirect
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
github.com/rdleal/intervalst v1.5.0 // indirect
github.com/relvacode/iso8601 v1.7.0 // indirect
github.com/rfjakob/eme v1.2.0 // indirect
github.com/rfjakob/eme v1.1.2 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/seaweedfs/goexif v1.0.3 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/shirou/gopsutil/v4 v4.26.2 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/smarty/assertions v1.16.0 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
@@ -216,7 +217,7 @@ require (
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twpayne/go-geom v1.6.1 // indirect
@@ -240,15 +241,15 @@ require (
go.etcd.io/bbolt v1.4.3 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/image v0.39.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
@@ -256,8 +257,8 @@ require (
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.278.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
google.golang.org/api v0.274.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc/security/advancedtls v1.0.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
@@ -266,10 +267,10 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
storj.io/common v0.0.0-20260225132117-99155641c30a // indirect
storj.io/common v0.0.0-20251107171817-6221ae45072c // indirect
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
storj.io/infectious v0.0.2 // indirect
storj.io/picobuf v0.0.4 // indirect
storj.io/uplink v1.14.0 // indirect
storj.io/uplink v1.13.1 // indirect
)
+101 -104
View File
@@ -13,8 +13,8 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
@@ -49,8 +49,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4 h1:tZh20RjgfMxKBxJiIS75iTVAKIUxrST5X2dVHMTptL4=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4/go.mod h1:vGYAk36rhMVCfTP7v+RVruCR0zmPe6S+36KRpDCLySw=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 h1:sxgSqOB9CDToiaVFpxuvb5wGgGqWa3lCShcm5o0n3bE=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3/go.mod h1:XdED8i399lEVblYHTZM8eXaP07gv4Z58IL6ueMlVlrg=
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=
@@ -61,12 +61,12 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/FilenCloudDienste/filen-sdk-go v0.0.39 h1:tgV5jYL6dsXop9TpDTIQU6UwJjws122HrwskaEE/igY=
github.com/FilenCloudDienste/filen-sdk-go v0.0.39/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
github.com/Files-com/files-sdk-go/v3 v3.3.82 h1:2RfP0d2QgkFH64BjZSWd59aMsc28IyWsUuHqU0txBtY=
github.com/Files-com/files-sdk-go/v3 v3.3.82/go.mod h1:IPk80dOmc7VFC0DJ85xMTPmre+8xoXX6kGHAkf5jRRw=
github.com/IBM/go-sdk-core/v5 v5.21.2 h1:mJ5QbLPOm4g5qhZiVB6wbSllfpeUExftGoyPek2hk4M=
github.com/IBM/go-sdk-core/v5 v5.21.2/go.mod h1:ngpMgwkjur1VNUjqn11LPk3o5eCyOCRbcfg/0YAY7Hc=
github.com/FilenCloudDienste/filen-sdk-go v0.0.38 h1:YCyHs3wUXEe2BEWn40vcoyaQ2ruHNmNwkasfo3Th16A=
github.com/FilenCloudDienste/filen-sdk-go v0.0.38/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
github.com/Files-com/files-sdk-go/v3 v3.2.264 h1:lMHTplAYI9FtmCo/QOcpRxmPA5REVAct1r2riQmDQKw=
github.com/Files-com/files-sdk-go/v3 v3.2.264/go.mod h1:wGqkOzRu/ClJibvDgcfuJNAqI2nLhe8g91tPlDKRCdE=
github.com/IBM/go-sdk-core/v5 v5.21.0 h1:DUnYhvC4SoC8T84rx5omnhY3+xcQg/Whyoa3mDPIMkk=
github.com/IBM/go-sdk-core/v5 v5.21.0/go.mod h1:Q3BYO6iDA2zweQPDGbNTtqft5tDcEpm6RTuqMlPcvbw=
github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s=
github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQVVvo=
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
@@ -82,24 +82,22 @@ github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e h1:lCsqUUACrcMC83lg5rTo9Y0PnPItE61JSfvMyIcANwk=
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/ProtonMail/gopenpgp/v2 v2.9.0 h1:ruLzBmwe4dR1hdnrsEJ/S7psSBmV15gFttFUPP/+/kE=
github.com/ProtonMail/gopenpgp/v2 v2.9.0/go.mod h1:IldDyh9Hv1ZCCYatTuuEt1XZJ0OPjxLpTarDfglih7s=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 h1:iLDOF0rdGTrol/q8OfPIIs5kLD8XvA2q75o6Uq/tgak=
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:DrEWcQJjz7t5iF2duaiyhg4jyoF0kxOD6LtECNGkZ/Q=
github.com/aalpar/deheap v1.1.2 h1:MABHLcnjqsffb8GLkUFDigqpBBxOMz0DoKM9QfELeTw=
github.com/aalpar/deheap v1.1.2/go.mod h1:A+nfkD4JbS05sewV0he/MYgR/90vfqyMoNNROgs+rmA=
github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3 h1:hhdWprfSpFbN7lz3W1gM40vOgvSh1WCSMxYD6gGB4Hs=
github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3/go.mod h1:XaUnRxSCYgL3kkgX0QHIV0D+znljPIDImxlv2kbGv0Y=
github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0=
github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
@@ -124,8 +122,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8TH
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/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/feature/s3/manager v1.22.1 h1:IbWiN670htmBioc+Zj32vSpJgQ2+OYSlvTvfQ1nCORQ=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1/go.mod h1:tw/B596EUhBWDFGdDGuLC21fVU4A3s4/5Efy8S39W18=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
@@ -152,8 +150,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6f
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/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
github.com/aws/smithy-go v1.25.0/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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -189,13 +187,15 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudinary/cloudinary-go/v2 v2.15.0 h1:iLoIwb7BJECHTbNcmIhYDsQhoZiACWGNvEpyqQy97Dk=
github.com/cloudinary/cloudinary-go/v2 v2.15.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo=
github.com/cloudinary/cloudinary-go/v2 v2.13.0 h1:ugiQwb7DwpWQnete2AZkTh94MonZKmxD7hDGy1qTzDs=
github.com/cloudinary/cloudinary-go/v2 v2.13.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo=
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc h1:t8YjNUCt1DimB4HCIXBztwWMhgxr5yG5/YaRl9Afdfg=
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc/go.mod h1:CgWpFCFWzzEA5hVkhAc6DZZzGd3czx+BblvOzjmg6KA=
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqLeM7jFBSw/Or81UEElFqO8MY+GDs=
@@ -213,9 +213,8 @@ github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5z
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 h1:tLWBZgPg6TV67oe76W4p+aUQEWIa52wbcuiz8GFd3vo=
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U=
github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -268,8 +267,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
@@ -282,8 +281,8 @@ github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7N
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -297,8 +296,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo=
github.com/go-openapi/errors v0.22.6/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
github.com/go-openapi/errors v0.22.4 h1:oi2K9mHTOb5DPW2Zjdzs/NIvwi2N3fARKaTJLdNabaM=
github.com/go-openapi/errors v0.22.4/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=
github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
@@ -309,14 +308,14 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
@@ -388,12 +387,12 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -428,8 +427,8 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/internxt/rclone-adapter v0.0.0-20260331173834-036f908d0160 h1:PV6ipOJN0JIek4dqPqsTtenQmjI1SZntCUgPzhfk9gM=
github.com/internxt/rclone-adapter v0.0.0-20260331173834-036f908d0160/go.mod h1:yRuPDnHgGmsbvopF0amMqXxr4n32GynzX5GTwFYDHaw=
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd h1:dSIuz2mpJAPQfhHYtG57D0qwSkgC/vQ69gHfeyQ4kxA=
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd/go.mod h1:vdPya4AIcDjvng4ViaAzqjegJf0VHYpYHQguFx5xBp0=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -446,8 +445,8 @@ github.com/jhump/protoreflect v1.18.0 h1:TOz0MSR/0JOZ5kECB/0ufGnC2jdsgZ123Rd/k4Z
github.com/jhump/protoreflect v1.18.0/go.mod h1:ezWcltJIVF4zYdIFM+D/sHV4Oh5LNU08ORzCGfwvTz8=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 h1:Dw1rslK/VotaUGYsv53XVWITr+5RCPXfvvlGrM/+B6w=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1/go.mod h1:D9LBEowZyv8/iSu97FU2zmXG3JxVTmNw21mu63niFzU=
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb h1:6vkM8gO+zFV2m21QzGYyUSq5TP0VQgP2Xz3UQyCN2kI=
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb/go.mod h1:H1+whwD0Qe3YOunlXIWhh3rlvzW5cZfkMDYGQPg+KAM=
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 h1:ZxO6Qr2GOXPdcW80Mcn3nemvilMPvpWqxrNfK2ZnNNs=
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3/go.mod h1:dvLUr/8Fs9a2OBrEnCC5duphbkz/k/mSy5OkXg3PAgI=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
@@ -470,12 +469,12 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/reedsolomon v1.14.0 h1:5YSZeclzSYg5nl349+GDG/agDtQ6MZiwUYXvVKN1Jx0=
github.com/klauspost/reedsolomon v1.14.0/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/klauspost/reedsolomon v1.13.3 h1:01GwnO2xoCSaM0ShP4qwl+FsHg3csFShC6Tu/RS1ji0=
github.com/klauspost/reedsolomon v1.13.3/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 h1:CjEMN21Xkr9+zwPmZPaJJw+apzVbjGL5uK/6g9Q2jGU=
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988/go.mod h1:/agobYum3uo/8V6yPVnq+R82pyVGCeuWW5arT4Txn8A=
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 h1:FHVoZMOVRA+6/y4yRlbiR3WvsrOcKBd/f64H7YiWR2U=
@@ -500,8 +499,8 @@ github.com/linkedin/goavro/v2 v2.15.0 h1:pDj1UrjUOO62iXhgBiE7jQkpNIc5/tA5eZsgolM
github.com/linkedin/goavro/v2 v2.15.0/go.mod h1:KXx+erlq+RPlGSPmLF7xGo6SAbh8sCQ53x064+ioxhk=
github.com/lpar/date v1.0.0 h1:bq/zVqFTUmsxvd/CylidY4Udqpr9BOFrParoP6p0x/I=
github.com/lpar/date v1.0.0/go.mod h1:KjYe0dDyMQTgpqcUz4LEIeM5VZwhggjVx/V2dtc8NSo=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -509,8 +508,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk=
github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
@@ -536,12 +535,12 @@ github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGm
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/oracle/oci-go-sdk/v65 v65.111.0 h1:eDkWg6ZN0uKwWzSekoFcQJhR+C+F/aVdTwr+lGHU9Qk=
github.com/oracle/oci-go-sdk/v65 v65.111.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
github.com/oracle/oci-go-sdk/v65 v65.104.0 h1:l9awEvzWvxmYhy/97A0hZ87pa7BncYXmcO/S8+rvgK0=
github.com/oracle/oci-go-sdk/v65 v65.104.0/go.mod h1:oB8jFGVc/7/zJ+DbleE8MzGHjhs2ioCz5stRTdZdIcY=
github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c=
github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM=
github.com/panjf2000/ants/v2 v2.11.5 h1:a7LMnMEeux/ebqTux140tRiaqcFTV0q2bEHF03nl6Rg=
github.com/panjf2000/ants/v2 v2.11.5/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
@@ -581,30 +580,30 @@ github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UH
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 h1:Y258uzXU/potCYnQd1r6wlAnoMB68BiCkCcCnKx1SH8=
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8/go.mod h1:bSJjRokAHHOhA+XFxplld8w2R/dXLH7Z3BZ532vhFwU=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
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=
github.com/rclone/go-proton-api v1.0.2/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc=
github.com/rclone/rclone v1.74.1 h1:QQI//tl4O5uooTowdKwNKcv5qCQvJ3LIBG00+TKeXw0=
github.com/rclone/rclone v1.74.1/go.mod h1:44epaKf5w/1ZQounefr76wx4+y9NZmgfMKle9T3pzj0=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 h1:4MI2alxM/Ye2gIRBlYf28JGWTipZ4Zz7yAziPKrttjs=
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11/go.mod h1:3HLX7dwZgvB7nt+Yl/xdzVPcargQ1yBmJEUg3n+jMKM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc=
github.com/rclone/rclone v1.73.5 h1:r8a9JHYIWUqk7hNRJuMJ3cROkKfB2zmfkADg8ZLYh6I=
github.com/rclone/rclone v1.73.5/go.mod h1:WVv8gvA/lEl/Y37e8I8yosm7ZY+Szq7ujXbJS8Ol63o=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/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/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo=
github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
github.com/rfjakob/eme v1.2.0 h1:8dAHL+WVAw06+7DkRKnRiFp1JL3QjcJEZFqDnndUaSI=
github.com/rfjakob/eme v1.2.0/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4=
github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
@@ -619,11 +618,11 @@ github.com/seaweedfs/goexif v1.0.3/go.mod h1:Oni780Z236sXpIQzk1XoJlTwqrJ02smEin9
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY=
@@ -642,8 +641,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
@@ -671,8 +670,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965 h1:1oFLiOyVl+W7bnBzGhf7BbIv9loSFQcieWWYIjLqcAw=
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965/go.mod h1:9OrXJhf154huy1nPWmuSrkgjPUtUNhA+Zmy+6AESzuA=
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c h1:dtcOwRimeiBFrlutmF6K94l0rxYFARNFMA+lSQ41C+M=
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c/go.mod h1:BF/l2jNyK+2h/BJZ7VLMAz6m/IWjA2F67gTjV1C/+Bo=
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 h1:rrGZv6xYk37hx0tW2sYfgbO0PqStbHqz6Bq6oc9Hurg=
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491/go.mod h1:ykucQyiE9Q2qx1wLlEtZkkNn1IURib/2O+Mvd25i1Fo=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
@@ -738,8 +737,6 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zeebo/mwc v0.0.7 h1:0NerGhCww6ZQx+/xCx5iwznftveokvto1KILpYfENZk=
github.com/zeebo/mwc v0.0.7/go.mod h1:0B32or6moOig1YGuqMoimBpU9QK9uYaGG2bBOuddqtE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
@@ -755,8 +752,8 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
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/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
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/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
@@ -802,12 +799,12 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -1033,8 +1030,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1057,8 +1054,8 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E=
google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA=
google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -1094,12 +1091,12 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE=
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw=
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js=
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1112,8 +1109,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
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.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=
google.golang.org/grpc/security/advancedtls v1.0.0 h1:/KQ7VP/1bs53/aopk9QhuPyFAp9Dm9Ejix3lzYkCrDA=
@@ -1164,8 +1161,8 @@ rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
storj.io/common v0.0.0-20260225132117-99155641c30a h1:7gSBQY3vhQMIqi3vfaEXR7mreRjcBLfVsYY0rHIN7P0=
storj.io/common v0.0.0-20260225132117-99155641c30a/go.mod h1:kyxwKwlfH4paBZCZt/szvRB770ieAIJF73iDy2DpEHw=
storj.io/common v0.0.0-20251107171817-6221ae45072c h1:UDXSrdeLJe3QFouavSW10fYdpclK0YNu3KvQHzqq2+k=
storj.io/common v0.0.0-20251107171817-6221ae45072c/go.mod h1:XNX7uykja6aco92y2y8RuqaXIDRPpt1YA2OQDKlKEUk=
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 h1:8OE12DvUnB9lfZcHe7IDGsuhjrY9GBAr964PVHmhsro=
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55/go.mod h1:Y9LZaa8esL1PW2IDMqJE7CFSNq7d5bQ3RI7mGPtmKMg=
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 h1:5MZ0CyMbG6Pi0rRzUWVG6dvpXjbBYEX2oyXuj+tT+sk=
@@ -1174,5 +1171,5 @@ storj.io/infectious v0.0.2 h1:rGIdDC/6gNYAStsxsZU79D/MqFjNyJc1tsyyj9sTl7Q=
storj.io/infectious v0.0.2/go.mod h1:QEjKKww28Sjl1x8iDsjBpOM4r1Yp8RsowNcItsZJ1Vs=
storj.io/picobuf v0.0.4 h1:qswHDla+YZ2TovGtMnU4astjvrADSIz84FXRn0qgP6o=
storj.io/picobuf v0.0.4/go.mod h1:hSMxmZc58MS/2qSLy1I0idovlO7+6K47wIGUyRZa6mg=
storj.io/uplink v1.14.0 h1:J1yXlt0aRr6kgLTHWXOWosNCFVfbamlcyd+CSxyIczo=
storj.io/uplink v1.14.0/go.mod h1:2ysmjzd/1Xtz4VKoErNcSqBQz3UC9WKTVuLMV1cNu6E=
storj.io/uplink v1.13.1 h1:C8RdW/upALoCyuF16Lod9XGCXEdbJAS+ABQy9JO/0pA=
storj.io/uplink v1.13.1/go.mod h1:x0MQr4UfFsQBwgVWZAtEsLpuwAn6dg7G0Mpne1r516E=
@@ -207,10 +207,8 @@ func buildVolumeListResponse(t *testing.T, spec topologySpec, volumeID uint32) *
t.Helper()
volumeSizeLimitMB := uint64(100)
// Exceed the default fullness (0.95) and quiet (1h) thresholds so volumes are
// EC-eligible.
volumeSize := uint64(96) * 1024 * 1024
volumeModifiedAt := time.Now().Add(-2 * time.Hour).Unix()
volumeSize := uint64(90) * 1024 * 1024
volumeModifiedAt := time.Now().Add(-10 * time.Minute).Unix()
diskTypes := spec.diskTypes
if len(diskTypes) == 0 {
@@ -29,11 +29,9 @@ func TestErasureCodingDetectionLargeTopology(t *testing.T) {
}
nodesPerRack := serverCount / rackCount
// Eligible volumes must exceed the default fullness (0.95) and quiet (1h)
// thresholds; ineligible ones fall below the fullness threshold.
eligibleSize := uint64(96) * 1024 * 1024
eligibleSize := uint64(90) * 1024 * 1024
ineligibleSize := uint64(10) * 1024 * 1024
modifiedAt := time.Now().Add(-2 * time.Hour).Unix()
modifiedAt := time.Now().Add(-10 * time.Minute).Unix()
volumeID := uint32(1)
dataCenters := make([]*master_pb.DataCenterInfo, 0, 1)
-69
View File
@@ -7,7 +7,6 @@ import (
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -292,74 +291,6 @@ func (v *VolumeServer) VolumeEcShardsMount(ctx context.Context, req *volume_serv
return &volume_server_pb.VolumeEcShardsMountResponse{}, nil
}
// VolumeEcShardsUnmount is a no-op stub: the worker's pre-distribute
// cleanup calls it against every destination, and the fake server has no
// mounted state to clear.
func (v *VolumeServer) VolumeEcShardsUnmount(ctx context.Context, req *volume_server_pb.VolumeEcShardsUnmountRequest) (*volume_server_pb.VolumeEcShardsUnmountResponse, error) {
return &volume_server_pb.VolumeEcShardsUnmountResponse{}, nil
}
// 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.
func (v *VolumeServer) VolumeEcShardsDelete(ctx context.Context, req *volume_server_pb.VolumeEcShardsDeleteRequest) (*volume_server_pb.VolumeEcShardsDeleteResponse, error) {
return &volume_server_pb.VolumeEcShardsDeleteResponse{}, nil
}
func (v *VolumeServer) VolumeEcShardsInfo(ctx context.Context, req *volume_server_pb.VolumeEcShardsInfoRequest) (*volume_server_pb.VolumeEcShardsInfoResponse, error) {
if req == nil {
return nil, fmt.Errorf("VolumeEcShardsInfo request is nil")
}
v.mu.Lock()
defer v.mu.Unlock()
// Report whichever shards exist on disk: seeded or mounted. Collection
// comes from the matching mount request when one exists.
collectionByShard := make(map[uint32]string)
for _, mr := range v.mountRequests {
if mr == nil || mr.VolumeId != req.VolumeId {
continue
}
for _, shardId := range mr.ShardIds {
if _, ok := collectionByShard[shardId]; !ok {
collectionByShard[shardId] = mr.Collection
}
}
}
resp := &volume_server_pb.VolumeEcShardsInfoResponse{}
prefix := fmt.Sprintf("%d.ec", req.VolumeId)
entries, _ := os.ReadDir(v.baseDir)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, prefix) {
continue
}
suffix := strings.TrimPrefix(name, prefix)
if len(suffix) < 2 {
continue
}
var shardId uint32
if _, err := fmt.Sscanf(suffix[:2], "%d", &shardId); err != nil {
continue
}
var size int64
if info, err := entry.Info(); err == nil {
size = info.Size()
}
resp.EcShardInfos = append(resp.EcShardInfos, &volume_server_pb.EcShardInfo{
ShardId: shardId,
Size: size,
Collection: collectionByShard[shardId],
VolumeId: req.VolumeId,
})
}
return resp, nil
}
func (v *VolumeServer) VolumeDelete(ctx context.Context, req *volume_server_pb.VolumeDeleteRequest) (*volume_server_pb.VolumeDeleteResponse, error) {
v.mu.Lock()
v.deleteRequests = append(v.deleteRequests, req)
@@ -1,309 +0,0 @@
package iam
import (
"fmt"
mathrand "math/rand"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// uniqueResourceSuffix returns a lowercased per-test, per-invocation suffix
// safe for use in IAM resource and S3 bucket names. Avoids EntityAlreadyExists
// / BucketAlreadyExists collisions when integration jobs retry or run in
// parallel against a shared stack.
func uniqueResourceSuffix(t *testing.T) string {
name := strings.ToLower(t.Name())
name = strings.ReplaceAll(name, "/", "-")
name = strings.ReplaceAll(name, "_", "-")
return fmt.Sprintf("%s-%d", name, mathrand.Intn(10000))
}
// isAccessDenied returns true when err is an AWS error with code "AccessDenied".
// Used to gate deny-path polling so transient setup errors don't end the
// Eventually loop prematurely.
func isAccessDenied(err error) bool {
if err == nil {
return false
}
awsErr, ok := err.(awserr.Error)
return ok && awsErr.Code() == "AccessDenied"
}
// TestIAMUserInlinePolicySourceIpCondition verifies that an aws:SourceIp condition
// on a user inline policy is honored. Tests run from localhost (127.0.0.1), so a
// policy that only allows access from a non-loopback CIDR must deny the request,
// and a policy that allows access from 127.0.0.0/8 must allow it.
func TestIAMUserInlinePolicySourceIpCondition(t *testing.T) {
framework := NewS3IAMTestFramework(t)
defer framework.Cleanup()
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
require.NoError(t, err)
suffix := uniqueResourceSuffix(t)
userName := "user-" + suffix
policyName := "policy-" + suffix
bucketName := "bucket-" + suffix
_, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
require.NoError(t, err)
keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
accessKeyId := *keyResp.AccessKey.AccessKeyId
secretKey := *keyResp.AccessKey.SecretAccessKey
userS3 := createS3Client(t, accessKeyId, secretKey)
adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
require.NoError(t, err)
require.NoError(t, framework.CreateBucketWithCleanup(adminS3, bucketName))
t.Cleanup(func() {
if _, err := iamClient.DeleteUserPolicy(&iam.DeleteUserPolicyInput{
UserName: aws.String(userName),
PolicyName: aws.String(policyName),
}); err != nil {
t.Logf("cleanup: failed to delete user policy: %v", err)
}
if _, err := iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
UserName: aws.String(userName),
AccessKeyId: keyResp.AccessKey.AccessKeyId,
}); err != nil {
t.Logf("cleanup: failed to delete access key: %v", err)
}
if _, err := iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil {
t.Logf("cleanup: failed to delete user: %v", err)
}
})
policyDoc := func(cidrs ...string) string {
quoted := make([]string, len(cidrs))
for i, c := range cidrs {
quoted[i] = `"` + c + `"`
}
return `{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Action":"s3:*",
"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"],
"Condition":{"IpAddress":{"aws:SourceIp":[` + strings.Join(quoted, ",") + `]}}
}]
}`
}
t.Run("denies_when_source_ip_does_not_match", func(t *testing.T) {
// SourceIp 198.51.100.0/24 is RFC5737 TEST-NET-2; the test client is on
// loopback (127.0.0.1 or ::1 depending on resolver), so the condition
// must fail and the action must be denied.
_, err = iamClient.PutUserPolicy(&iam.PutUserPolicyInput{
UserName: aws.String(userName),
PolicyName: aws.String(policyName),
PolicyDocument: aws.String(policyDoc("198.51.100.0/24")),
})
require.NoError(t, err)
var lastErr error
require.Eventually(t, func() bool {
_, lastErr = userS3.PutObject(&s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String("denied.txt"),
Body: aws.ReadSeekCloser(strings.NewReader("nope")),
})
return isAccessDenied(lastErr)
}, 10*time.Second, 500*time.Millisecond,
"PutObject must be denied with AccessDenied when aws:SourceIp condition does not match (last error: %v)", lastErr)
})
t.Run("allows_when_source_ip_matches", func(t *testing.T) {
// Cover both IPv4 and IPv6 loopback: on CI runners `localhost` may
// resolve to ::1 first, in which case a 127.0.0.0/8-only allow would
// silently never match and the test would hang.
_, err = iamClient.PutUserPolicy(&iam.PutUserPolicyInput{
UserName: aws.String(userName),
PolicyName: aws.String(policyName),
PolicyDocument: aws.String(policyDoc("127.0.0.0/8", "::1/128")),
})
require.NoError(t, err)
require.Eventually(t, func() bool {
_, err := userS3.PutObject(&s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String("allowed.txt"),
Body: aws.ReadSeekCloser(strings.NewReader("ok")),
})
return err == nil
}, 10*time.Second, 500*time.Millisecond,
"PutObject must succeed when aws:SourceIp condition matches the loopback range")
})
}
// TestIAMGroupInlinePolicyEnforcement verifies that PutGroupPolicy is supported
// and that the resulting inline policy is enforced for members of the group,
// including its Condition block.
func TestIAMGroupInlinePolicyEnforcement(t *testing.T) {
framework := NewS3IAMTestFramework(t)
defer framework.Cleanup()
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
require.NoError(t, err)
suffix := uniqueResourceSuffix(t)
groupName := "group-" + suffix
userName := "user-" + suffix
policyName := "policy-" + suffix
bucketName := "bucket-" + suffix
_, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
require.NoError(t, err)
keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
UserName: aws.String(userName),
})
require.NoError(t, err)
_, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(groupName)})
require.NoError(t, err)
_, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
GroupName: aws.String(groupName),
UserName: aws.String(userName),
})
require.NoError(t, err)
userS3 := createS3Client(t, *keyResp.AccessKey.AccessKeyId, *keyResp.AccessKey.SecretAccessKey)
adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
require.NoError(t, err)
require.NoError(t, framework.CreateBucketWithCleanup(adminS3, bucketName))
t.Cleanup(func() {
if _, err := iamClient.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{
GroupName: aws.String(groupName),
PolicyName: aws.String(policyName),
}); err != nil {
t.Logf("cleanup: failed to delete group policy: %v", err)
}
if _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
GroupName: aws.String(groupName),
UserName: aws.String(userName),
}); err != nil {
t.Logf("cleanup: failed to remove user from group: %v", err)
}
if _, err := iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
UserName: aws.String(userName),
AccessKeyId: keyResp.AccessKey.AccessKeyId,
}); err != nil {
t.Logf("cleanup: failed to delete access key: %v", err)
}
if _, err := iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil {
t.Logf("cleanup: failed to delete user: %v", err)
}
if _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}); err != nil {
t.Logf("cleanup: failed to delete group: %v", err)
}
})
// Cover both IPv4 and IPv6 loopback in the allow CIDR list: on CI runners
// `localhost` may resolve to ::1 first, in which case a 127.0.0.0/8-only
// allow would silently never match and the test would hang.
allowDoc := `{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Action":"s3:*",
"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"],
"Condition":{"IpAddress":{"aws:SourceIp":["127.0.0.0/8","::1/128"]}}
}]
}`
denyDoc := `{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Action":"s3:*",
"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"],
"Condition":{"IpAddress":{"aws:SourceIp":"198.51.100.0/24"}}
}]
}`
t.Run("crud_round_trip", func(t *testing.T) {
_, err := iamClient.PutGroupPolicy(&iam.PutGroupPolicyInput{
GroupName: aws.String(groupName),
PolicyName: aws.String(policyName),
PolicyDocument: aws.String(allowDoc),
})
require.NoError(t, err, "PutGroupPolicy must succeed (no longer NotImplemented)")
listResp, err := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{
GroupName: aws.String(groupName),
})
require.NoError(t, err)
found := false
for _, name := range listResp.PolicyNames {
if name != nil && *name == policyName {
found = true
break
}
}
assert.True(t, found, "ListGroupPolicies must return the freshly added policy")
getResp, err := iamClient.GetGroupPolicy(&iam.GetGroupPolicyInput{
GroupName: aws.String(groupName),
PolicyName: aws.String(policyName),
})
require.NoError(t, err)
require.NotNil(t, getResp.PolicyDocument)
assert.Contains(t, *getResp.PolicyDocument, "aws:SourceIp",
"GetGroupPolicy must round-trip the Condition block")
})
t.Run("enforces_allow_when_condition_matches", func(t *testing.T) {
_, err := iamClient.PutGroupPolicy(&iam.PutGroupPolicyInput{
GroupName: aws.String(groupName),
PolicyName: aws.String(policyName),
PolicyDocument: aws.String(allowDoc),
})
require.NoError(t, err)
require.Eventually(t, func() bool {
_, err := userS3.PutObject(&s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String("group-allowed.txt"),
Body: aws.ReadSeekCloser(strings.NewReader("ok")),
})
return err == nil
}, 10*time.Second, 500*time.Millisecond,
"group member must be allowed when the group policy condition matches")
})
t.Run("enforces_deny_when_condition_does_not_match", func(t *testing.T) {
_, err := iamClient.PutGroupPolicy(&iam.PutGroupPolicyInput{
GroupName: aws.String(groupName),
PolicyName: aws.String(policyName),
PolicyDocument: aws.String(denyDoc),
})
require.NoError(t, err)
var lastErr error
require.Eventually(t, func() bool {
_, lastErr = userS3.PutObject(&s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String("group-denied.txt"),
Body: aws.ReadSeekCloser(strings.NewReader("nope")),
})
return isAccessDenied(lastErr)
}, 10*time.Second, 500*time.Millisecond,
"group member must be denied with AccessDenied when the group policy condition does not match (last error: %v)", lastErr)
})
}
+1 -7
View File
@@ -9,10 +9,6 @@ S3_PORT := 8333
MASTER_PORT := 9333
VOLUME_PORT := 8080
FILER_PORT := 8888
# Pin the filer gRPC port off the FILER_PORT+10000 convention so any
# code path that assumes the offset breaks here, not in production.
FILER_GRPC_PORT := 18890
ADMIN_PORT := 23646
ACCESS_KEY ?= some_access_key1
SECRET_KEY ?= some_secret_key1
TEST_TIMEOUT := 10m
@@ -43,7 +39,6 @@ start-server: build-weed
@AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) $(WEED_BINARY) mini \
-dir=$(SERVER_DIR) \
-s3.port=$(S3_PORT) \
-filer.port.grpc=$(FILER_GRPC_PORT) \
> weed-test.log 2>&1 & \
echo $$! > weed-server.pid
@for i in $$(seq 1 90); do \
@@ -73,8 +68,7 @@ test:
S3_ENDPOINT=http://localhost:$(S3_PORT) \
S3_GRPC_ENDPOINT=localhost:$$(($(S3_PORT) + 10000)) \
MASTER_ENDPOINT=http://localhost:$(MASTER_PORT) \
FILER_GRPC_ADDRESS=localhost:$(FILER_GRPC_PORT) \
ADMIN_ENDPOINT=http://localhost:$(ADMIN_PORT) \
FILER_GRPC_ADDRESS=localhost:$$(($(FILER_PORT) + 10000)) \
go test -v -timeout $(TEST_TIMEOUT) -run $(TEST_PATTERN)
test-with-server: start-server
@@ -1,113 +0,0 @@
package lifecycle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/require"
)
// Drives the real admin->worker dispatch path via the run endpoint and
// asserts the dispatch reaches the filer (no dial error) and deletes
// the backdated object. The Makefile pins filer.port.grpc off the
// FILER_PORT+10000 convention so a raw-address forwarding regression
// resurfaces here.
func TestLifecycleAdminDispatchSucceedsWithCustomFilerGrpcPort(t *testing.T) {
adminEndpoint := envOr("ADMIN_ENDPOINT", defaultAdminEndpoint)
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("admin-dispatch")
mustCreateBucket(t, c, bucket)
putExpirationLifecycle(t, c, bucket, "expire/", 1)
const oldKey = "expire/old.txt"
putObject(t, c, bucket, oldKey, "old")
backdateMtime(t, fc, bucket, oldKey, 30)
waitForLifecycleWorkerReady(t, adminEndpoint)
// Lifecycle is a long-running batch; the run endpoint cancels it at
// this timeout, which converts a healthy run into canceled_count=1.
const runTimeoutSeconds = 30
body, err := json.Marshal(map[string]any{
"timeout_seconds": runTimeoutSeconds,
})
require.NoError(t, err)
req, err := http.NewRequestWithContext(
context.Background(), http.MethodPost,
adminEndpoint+"/api/plugin/job-types/s3_lifecycle/run",
bytes.NewReader(body),
)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var payload map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
t.Logf("admin /api/plugin/job-types/s3_lifecycle/run response: %v", payload)
require.Equal(t, http.StatusOK, resp.StatusCode, "admin run endpoint failed: %v", payload)
require.GreaterOrEqual(t, jsonNumber(t, payload, "detected_count"), 1)
require.Equal(t, 0, jsonNumber(t, payload, "error_count"),
"dispatched job errored — likely filer_grpc_address was raw host:httpPort.grpcPort")
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(oldKey),
})
return err != nil
}, 30*time.Second, 500*time.Millisecond,
"expected %s/%s to be deleted after admin-dispatched lifecycle run", bucket, oldKey)
}
func jsonNumber(t *testing.T, payload map[string]any, key string) int {
t.Helper()
raw, ok := payload[key]
require.Truef(t, ok, "response missing key %q: %v", key, payload)
switch v := raw.(type) {
case float64:
return int(v)
case json.Number:
n, err := v.Int64()
require.NoErrorf(t, err, "key %q is not an int: %v", key, raw)
return int(n)
default:
t.Fatalf("key %q has unexpected type %T (%v)", key, raw, raw)
return 0
}
}
func waitForLifecycleWorkerReady(t *testing.T, adminEndpoint string) {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
req, err := http.NewRequest(http.MethodGet, adminEndpoint+"/api/plugin/scheduler-states", nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
if err != nil {
time.Sleep(250 * time.Millisecond)
continue
}
var payload any
_ = json.NewDecoder(resp.Body).Decode(&payload)
resp.Body.Close()
if resp.StatusCode == http.StatusOK && strings.Contains(fmt.Sprintf("%v", payload), "s3_lifecycle") {
return
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("admin never reported an s3_lifecycle-capable worker")
}
@@ -1,79 +0,0 @@
// Bootstrap-walk integration scenario: lifecycle config added AFTER the
// objects exist must still expire them on the first sweep.
package lifecycle
import (
"context"
"fmt"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/require"
)
// TestLifecycleBootstrapWalkOnExistingObjects: PUT a batch of objects
// FIRST, backdate them, THEN configure the lifecycle rule, THEN run
// the worker. The worker must discover the pre-existing objects via
// the bootstrap walk (BucketBootstrapper) — there were no meta-log
// events the reader could have observed because the objects predate
// the rule.
//
// Production scenario: an operator enables lifecycle on a bucket that
// already holds a million objects from before the policy. Without a
// bootstrap walk, only NEW writes would ever match; the existing
// content would never expire.
func TestLifecycleBootstrapWalkOnExistingObjects(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("bootstrap")
mustCreateBucket(t, c, bucket)
// 5 objects so the test exercises the walker iterating multiple
// entries in the same bucket directory. PUT FIRST, before any
// lifecycle config is set, so the meta-log doesn't carry events
// that match any rule.
const total = 5
keys := make([]string, total)
for i := 0; i < total; i++ {
keys[i] = fmt.Sprintf("preexisting/obj-%02d.txt", i)
putObject(t, c, bucket, keys[i], "content")
backdateMtime(t, fc, bucket, keys[i], 30)
}
// One out-of-prefix object that must NOT be expired. Same
// pre-existing PUT timing.
const survivor = "other/keep.txt"
putObject(t, c, bucket, survivor, "keep")
backdateMtime(t, fc, bucket, survivor, 30)
// NOW set the rule. Up to this point the worker (if it were
// running event-driven only) would have nothing matching to do.
putExpirationLifecycle(t, c, bucket, "preexisting/", 1)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// Every pre-existing in-prefix object must be expired by the
// bootstrap walk's discovery + dispatch.
for _, k := range keys {
k := k
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(k),
})
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond,
"pre-existing %s/%s must be discovered + expired by bootstrap walk", bucket, k)
}
// Out-of-prefix object stays — pin that the bootstrap walk
// doesn't ignore the rule's prefix filter.
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(survivor),
})
require.NoError(t, err, "out-of-prefix object must survive the bootstrap walk")
}
@@ -1,79 +0,0 @@
// Lifecycle config update across sweeps.
package lifecycle
import (
"context"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/require"
)
// TestLifecycleConfigUpdateBetweenSweeps: an operator changes the
// lifecycle rule between two shell-driven sweeps. The second sweep
// must respect the NEW rule, not a cached version of the old one.
//
// Each `runLifecycleShard` invocation spawns a fresh `weed shell`
// subprocess, so cached engine state from a previous sweep doesn't
// persist across runs. This test pins that the freshly-loaded config
// actually changes routing — under the new prefix only matching
// objects expire, even if there are still backdated objects sitting
// under the old prefix.
func TestLifecycleConfigUpdateBetweenSweeps(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("config-update")
mustCreateBucket(t, c, bucket)
// Sweep 1: rule expires anything under "first/".
putExpirationLifecycle(t, c, bucket, "first/", 1)
const firstKey = "first/initial.txt"
putObject(t, c, bucket, firstKey, "first")
backdateMtime(t, fc, bucket, firstKey, 30)
out := runLifecycleShard(t)
t.Logf("sweep 1 output:\n%s", out)
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(firstKey),
})
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond, "sweep 1 must expire %s", firstKey)
// Update the rule to a different prefix. The old "first/" prefix
// is no longer covered by any rule; objects under it must NOT be
// expired by sweep 2 even when backdated.
putExpirationLifecycle(t, c, bucket, "second/", 1)
const secondKey = "second/new.txt"
const oldPrefixKey = "first/post-update.txt"
putObject(t, c, bucket, secondKey, "second")
putObject(t, c, bucket, oldPrefixKey, "stale rule")
backdateMtime(t, fc, bucket, secondKey, 30)
backdateMtime(t, fc, bucket, oldPrefixKey, 30)
out = runLifecycleShard(t)
t.Logf("sweep 2 output:\n%s", out)
// Sweep 2 expires the new-prefix object.
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(secondKey),
})
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond, "sweep 2 must expire %s under the new rule", secondKey)
// Sweep 2 must NOT expire the old-prefix object — the rule was
// replaced, not merged. A regression that caches old rules across
// PutBucketLifecycleConfiguration calls would fail here.
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(oldPrefixKey),
})
require.NoError(t, err, "old-prefix object must survive after rule update — config replacement, not merge")
}
@@ -1,67 +0,0 @@
// Operator-removes-lifecycle-config integration scenario.
package lifecycle
import (
"context"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/require"
)
// TestLifecycleDeleteBucketLifecycleStopsDispatching: when an operator
// runs DeleteBucketLifecycle, subsequent sweeps must NOT continue
// expiring objects under the now-removed rule. The worker reads each
// bucket's XML config from filer state on every run; if it cached the
// previous config across PutBucketLifecycleConfiguration → DeleteBucket
// Lifecycle the bucket would keep losing objects.
//
// Setup: positive control (rule active, backdated obj expires), then
// DeleteBucketLifecycle, then PUT + backdate a fresh object, run the
// worker again. The fresh object must remain — no rule, no dispatch.
func TestLifecycleDeleteBucketLifecycleStopsDispatching(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("delete-config")
mustCreateBucket(t, c, bucket)
putExpirationLifecycle(t, c, bucket, "x/", 1)
const firstKey = "x/initial.txt"
putObject(t, c, bucket, firstKey, "first")
backdateMtime(t, fc, bucket, firstKey, 30)
out := runLifecycleShard(t)
t.Logf("sweep 1 output (rule active):\n%s", out)
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(firstKey),
})
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond, "positive control: %s should expire while rule is active", firstKey)
// Now remove the rule. The worker must observe the removal on the
// next sweep.
_, err := c.DeleteBucketLifecycle(context.Background(), &s3.DeleteBucketLifecycleInput{
Bucket: aws.String(bucket),
})
require.NoError(t, err)
const survivorKey = "x/post-delete.txt"
putObject(t, c, bucket, survivorKey, "should-stay")
backdateMtime(t, fc, bucket, survivorKey, 30)
out = runLifecycleShard(t)
t.Logf("sweep 2 output (rule deleted):\n%s", out)
// Wait the same window; survivor MUST still exist after the worker
// has had every opportunity to (incorrectly) act on it.
time.Sleep(2 * time.Second)
_, err = c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(survivorKey),
})
require.NoError(t, err, "after DeleteBucketLifecycle, sweeps must not expire %s", survivorKey)
}
@@ -1,46 +0,0 @@
// Empty bucket sweep — no-op safety integration scenario.
package lifecycle
import (
"context"
"strings"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/require"
)
// TestLifecycleEmptyBucketSweepIsNoOp: a bucket carrying lifecycle
// config but no objects must produce a successful sweep — no hangs,
// no errors, no dispatches. Pinned because the bootstrap walker
// iterates bucket directories, and an empty directory is a corner of
// that traversal that's easy to break (e.g. a slice-bounds bug on
// the first listing returning zero entries).
func TestLifecycleEmptyBucketSweepIsNoOp(t *testing.T) {
c := s3Client(t)
_, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("empty")
mustCreateBucket(t, c, bucket)
putExpirationLifecycle(t, c, bucket, "anywhere/", 1)
// No PUTs. The bucket is empty.
out := runLifecycleShard(t)
t.Logf("sweep output:\n%s", out)
// Sanity: the worker must report it loaded the bucket's config and
// completed without errors. The runLifecycleShard helper already
// fails on FATAL; pin the success markers here too so a regression
// that produces a half-shaped output (no completion) is caught.
require.Contains(t, out, "loaded lifecycle for", "worker must report config load")
require.Contains(t, out, "shards 0-15 complete", "worker must complete all shards")
require.False(t, strings.Contains(out, "FATAL"), "worker must not produce FATAL output")
// Bucket still exists after the sweep — the worker doesn't drop
// empty buckets as a side effect.
_, err := c.HeadBucket(context.Background(), &s3.HeadBucketInput{Bucket: aws.String(bucket)})
require.NoError(t, err, "empty bucket must still exist after sweep")
}
@@ -1,75 +0,0 @@
// ExpirationDate (date-based, not Days) integration scenario.
package lifecycle
import (
"context"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/require"
)
// TestLifecycleExpirationDateInThePast: an Expiration{Date: <past>} rule
// routes through the engine's ScanAtDate mode rather than the
// EventDriven delay-group path. The worker's dispatcher must still fire
// when the date has already passed at the time the worker runs. Pinning
// this path because most tests use Days-based rules; ScanAtDate is a
// separate compile + dispatch branch (engine.decideMode case
// ActionKindExpirationDate) that wouldn't be exercised otherwise.
func TestLifecycleExpirationDateInThePast(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("expdate")
mustCreateBucket(t, c, bucket)
// Date in the past: AWS rejects ExpirationDate in the future from
// being processed early but a past date is the natural integration
// test — every object hit by the rule is immediately eligible.
pastDate := time.Now().Add(-7 * 24 * time.Hour).UTC().Truncate(24 * time.Hour)
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
Bucket: aws.String(bucket),
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
Rules: []types.LifecycleRule{{
ID: aws.String("expire-by-date"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilter{Prefix: aws.String("d/")},
Expiration: &types.LifecycleExpiration{
Date: aws.Time(pastDate),
},
}},
},
})
require.NoError(t, err)
const oldKey = "d/in-prefix.txt"
const otherKey = "other/skip.txt"
putObject(t, c, bucket, oldKey, "old")
putObject(t, c, bucket, otherKey, "other")
// Backdate oldKey too — defense-in-depth so the test doesn't
// depend on whether the worker also considers MinTriggerAge for
// date kinds; a fresh object with a past date should still expire,
// but aging it locks the assertion either way.
backdateMtime(t, fc, bucket, oldKey, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(oldKey),
})
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond, "%s/%s must be expired by past-date rule", bucket, oldKey)
// Out-of-prefix object stays — the rule's Filter.Prefix gates this.
_, err = c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(otherKey),
})
require.NoError(t, err, "object outside the rule's prefix must remain")
}
-136
View File
@@ -1,136 +0,0 @@
// AbortIncompleteMultipartUpload integration scenario.
package lifecycle
import (
"context"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/stretchr/testify/require"
)
// putAbortMPULifecycle wires AbortIncompleteMultipartUpload on a prefix.
// AWS rejects DaysAfterInitiation < 1, so the test backdates the upload
// directory to age it past the threshold.
func putAbortMPULifecycle(t *testing.T, c *s3.Client, bucket, prefix string, days int32) {
t.Helper()
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
Bucket: aws.String(bucket),
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
Rules: []types.LifecycleRule{
{
ID: aws.String("abort-stale-mpu"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilter{Prefix: aws.String(prefix)},
AbortIncompleteMultipartUpload: &types.AbortIncompleteMultipartUpload{
DaysAfterInitiation: aws.Int32(days),
},
},
},
},
})
require.NoError(t, err)
}
// backdateUploadDir ages the .uploads/<uploadID>/ directory entry, which is
// what the lifecycle worker keys ABORT_MPU off of (the upload's init-time
// directory carries the destination key in Extended).
func backdateUploadDir(t *testing.T, fc filer_pb.SeaweedFilerClient, bucket, uploadID string, daysOld int) {
t.Helper()
dir := bucketsPath + "/" + bucket + "/.uploads"
resp, err := fc.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
Directory: dir, Name: uploadID,
})
require.NoError(t, err, "lookup .uploads/%s", uploadID)
require.NotNil(t, resp.Entry)
require.NotNil(t, resp.Entry.Attributes)
require.True(t, resp.Entry.IsDirectory, ".uploads/%s should be a directory", uploadID)
resp.Entry.Attributes.Mtime = time.Now().Add(-time.Duration(daysOld) * 24 * time.Hour).Unix()
resp.Entry.Attributes.MtimeNs = 0
_, err = fc.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
Directory: dir, Entry: resp.Entry,
})
require.NoError(t, err)
}
// TestLifecycleAbortIncompleteMultipartUpload: an MPU left incomplete past
// the AbortIncompleteMultipartUpload.DaysAfterInitiation threshold must be
// aborted by the lifecycle worker. Exercises the lifecycleAbortMPU handler
// path which is otherwise unreachable from the prefix-based expiration
// tests (the routing keys off of `.uploads/<id>/` directory events, not
// regular object events).
func TestLifecycleAbortIncompleteMultipartUpload(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("abort-mpu")
mustCreateBucket(t, c, bucket)
putAbortMPULifecycle(t, c, bucket, "uploads/", 1)
// Initiate an MPU, upload a single part, then leave it. CompleteMultipart
// would consume the upload; AbortMultipart would explicitly abort. We do
// neither so the lifecycle worker is the path that ends it.
const key = "uploads/big.bin"
createOut, err := c.CreateMultipartUpload(context.Background(), &s3.CreateMultipartUploadInput{
Bucket: aws.String(bucket), Key: aws.String(key),
})
require.NoError(t, err)
uploadID := aws.ToString(createOut.UploadId)
require.NotEmpty(t, uploadID)
// Upload one part so the directory carries the right shape.
_, err = c.UploadPart(context.Background(), &s3.UploadPartInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
UploadId: aws.String(uploadID),
PartNumber: aws.Int32(1),
Body: strings.NewReader("part 1 contents"),
})
require.NoError(t, err)
// Sanity: ListMultipartUploads sees our pending upload before the
// worker runs.
listOut, err := c.ListMultipartUploads(context.Background(), &s3.ListMultipartUploadsInput{
Bucket: aws.String(bucket),
})
require.NoError(t, err)
var foundBefore bool
for _, u := range listOut.Uploads {
if aws.ToString(u.UploadId) == uploadID {
foundBefore = true
break
}
}
require.True(t, foundBefore, "upload %s should be visible before the worker runs", uploadID)
// Backdate the upload directory entry so the rule's DaysAfterInitiation
// threshold is satisfied. The router walks .uploads/ and emits an
// MPU-init event for each entry.
backdateUploadDir(t, fc, bucket, uploadID, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// The upload must no longer appear in ListMultipartUploads.
require.Eventuallyf(t, func() bool {
listOut, err := c.ListMultipartUploads(context.Background(), &s3.ListMultipartUploadsInput{
Bucket: aws.String(bucket),
})
if err != nil {
return false
}
for _, u := range listOut.Uploads {
if aws.ToString(u.UploadId) == uploadID {
return false
}
}
return true
}, 30*time.Second, 500*time.Millisecond, "upload %s must be aborted by the lifecycle worker", uploadID)
}
@@ -1,90 +0,0 @@
// Multiple-bucket integration scenario.
package lifecycle
import (
"context"
"errors"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
smithyhttp "github.com/aws/smithy-go/transport/http"
"github.com/stretchr/testify/require"
)
// isS3NotFound recognizes a NotFound (NoSuchKey/404) response from the
// AWS SDK. Treating any HeadObject error as "deleted" lets a transport
// failure or dead endpoint mask a real bug, so callers that need to
// prove deletion specifically should use this.
func isS3NotFound(err error) bool {
if err == nil {
return false
}
var nsk *types.NoSuchKey
if errors.As(err, &nsk) {
return true
}
var notFound *types.NotFound
if errors.As(err, &notFound) {
return true
}
var apiErr *smithyhttp.ResponseError
if errors.As(err, &apiErr) {
return apiErr.HTTPStatusCode() == 404
}
return false
}
// TestLifecycleMultipleBucketsInOneSweep: a single shell-driven shard
// sweep must process every bucket carrying lifecycle config, not just
// the first one alphabetically. Pinned because the scheduler iterates
// the buckets directory and a regression that returns early after the
// first match would silently disable lifecycle for every later bucket.
//
// Two buckets, each with its own 1-day prefix-expiration rule and one
// backdated object. After the worker runs, both objects must be gone.
func TestLifecycleMultipleBucketsInOneSweep(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucketA := uniqueBucket("multi-a")
bucketB := uniqueBucket("multi-b")
mustCreateBucket(t, c, bucketA)
mustCreateBucket(t, c, bucketB)
putExpirationLifecycle(t, c, bucketA, "exp/", 1)
putExpirationLifecycle(t, c, bucketB, "exp/", 1)
const keyA = "exp/a.txt"
const keyB = "exp/b.txt"
putObject(t, c, bucketA, keyA, "a")
putObject(t, c, bucketB, keyB, "b")
backdateMtime(t, fc, bucketA, keyA, 30)
backdateMtime(t, fc, bucketB, keyB, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// Both buckets must have their objects expired in this single sweep.
for _, c2 := range []struct {
bucket, key string
}{
{bucketA, keyA},
{bucketB, keyB},
} {
c2 := c2
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(c2.bucket), Key: aws.String(c2.key),
})
// Pin: only count 404 / NoSuchKey as deletion. Any other
// error (transport failure, dead endpoint, auth) would
// otherwise mask a real bug as a "passed" test.
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond,
"%s/%s must be expired by the multi-bucket sweep", c2.bucket, c2.key)
}
}
@@ -1,94 +0,0 @@
// Multiple-rules-per-bucket integration scenario.
package lifecycle
import (
"context"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/require"
)
// TestLifecycleMultipleRulesInOneBucket: a single bucket carries two
// independent Expiration rules with disjoint prefix filters and
// different Days thresholds. Each rule must fire only on its prefix
// and ignore the other; objects outside both prefixes must survive.
//
// Pinned because compile.Compile builds one CompiledAction per rule
// per kind, all sharing the same bucket index. A bug that lets one
// rule's prefix or threshold leak into the other (e.g. last-write-
// wins on a shared map) would silently expire wrong objects.
func TestLifecycleMultipleRulesInOneBucket(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("multi-rule")
mustCreateBucket(t, c, bucket)
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
Bucket: aws.String(bucket),
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
Rules: []types.LifecycleRule{
{
ID: aws.String("logs-1d"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilter{Prefix: aws.String("logs/")},
Expiration: &types.LifecycleExpiration{Days: aws.Int32(1)},
},
{
ID: aws.String("tmp-7d"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilter{Prefix: aws.String("tmp/")},
Expiration: &types.LifecycleExpiration{Days: aws.Int32(7)},
},
},
},
})
require.NoError(t, err)
const logsKey = "logs/access.log"
const tmpKey = "tmp/scratch.bin"
const otherKey = "data/keep.bin"
putObject(t, c, bucket, logsKey, "log entry")
putObject(t, c, bucket, tmpKey, "scratch")
putObject(t, c, bucket, otherKey, "important")
// Backdate logs/ past the 1d rule, tmp/ past the 7d rule, data/
// past both thresholds — but data/ matches no rule, so it must
// survive regardless of age.
backdateMtime(t, fc, bucket, logsKey, 30)
backdateMtime(t, fc, bucket, tmpKey, 30)
backdateMtime(t, fc, bucket, otherKey, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// logs/ rule fires.
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(logsKey),
})
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond, "logs/ rule must expire %s", logsKey)
// tmp/ rule fires.
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(tmpKey),
})
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond, "tmp/ rule must expire %s", tmpKey)
// data/ matches NEITHER rule and must survive — pinning that the
// per-rule prefix gate is independent and additive, not a global
// "any rule's prefix" check.
_, err = c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(otherKey),
})
require.NoError(t, err, "object outside both rule prefixes must survive")
}
@@ -1,110 +0,0 @@
// NewerNoncurrentVersions integration scenario.
package lifecycle
import (
"context"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/require"
)
// putNewerNoncurrentLifecycle keeps the N most recent noncurrent versions
// and expires the rest. NoncurrentDays is required by the AWS XML schema
// alongside NewerNoncurrentVersions; keeping it at 1 means the rule fires
// as soon as a version moves past the keep-count and is older than 1 day.
func putNewerNoncurrentLifecycle(t *testing.T, c *s3.Client, bucket, prefix string, keep int32) {
t.Helper()
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
Bucket: aws.String(bucket),
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
Rules: []types.LifecycleRule{
{
ID: aws.String("keep-newest-noncurrent"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilter{Prefix: aws.String(prefix)},
NoncurrentVersionExpiration: &types.NoncurrentVersionExpiration{
NewerNoncurrentVersions: aws.Int32(keep),
NoncurrentDays: aws.Int32(1),
},
},
},
},
})
require.NoError(t, err)
}
// TestLifecycleNewerNoncurrentVersions: NewerNoncurrentVersions=1 keeps
// only the most recent noncurrent version; older noncurrent versions
// must be deleted. PUT v1, v2, v3, v4 (v4 current; v1-v3 noncurrent),
// backdate v1, v2, v3, run the worker, expect v3 (newest noncurrent)
// and v4 (current) to remain, v1 and v2 to be removed.
//
// This routes through routePointerTransition's "needs full expansion"
// path — distinct from the per-version NoncurrentDays path because
// the rule depends on per-version rank, not just per-version age.
func TestLifecycleNewerNoncurrentVersions(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("newer-nc")
mustCreateBucket(t, c, bucket)
putVersioningEnabled(t, c, bucket)
putNewerNoncurrentLifecycle(t, c, bucket, "n/", 1)
const key = "n/obj.txt"
puts := make([]string, 4) // versionIds in PUT order
for i := range puts {
out, err := c.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key),
Body: strings.NewReader(time.Now().Format(time.RFC3339Nano) + "-" + string(rune('a'+i))),
})
require.NoError(t, err)
puts[i] = aws.ToString(out.VersionId)
require.NotEmpty(t, puts[i])
}
v1, v2, v3, v4 := puts[0], puts[1], puts[2], puts[3]
require.NotEqual(t, v1, v2)
require.NotEqual(t, v3, v4)
// Backdate every noncurrent (v1, v2, v3) so all three satisfy the
// NoncurrentDays>=1 floor; the keep-newest-1 logic then chooses
// which to drop.
for _, vid := range []string{v1, v2, v3} {
backdateVersionedMtime(t, fc, bucket, key, vid, 30)
}
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// v1 and v2 (older noncurrent) must be expired.
for _, vid := range []string{v1, v2} {
vid := vid
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(vid),
})
return err != nil
}, 30*time.Second, 500*time.Millisecond, "older noncurrent version %s must be expired", vid)
}
// v3 (newest noncurrent within the keep=1 window) must remain.
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(v3),
})
require.NoError(t, err, "v3 (newest noncurrent within keep=1) must survive")
// v4 (current) must remain — current versions are immune to
// NoncurrentVersionExpiration regardless of how the keep-count
// arithmetic plays out.
currentHead, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key),
})
require.NoError(t, err, "current version v4 must remain")
require.Equal(t, v4, aws.ToString(currentHead.VersionId))
}
@@ -1,119 +0,0 @@
// Object Lock + lifecycle integration scenario.
package lifecycle
import (
"context"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/require"
)
// TestLifecycleSkipsObjectLockedObjects: an object under Object Lock
// retention must NOT be expired by the lifecycle worker. The handler's
// enforceObjectLockProtections check (s3api_internal_lifecycle.go:47)
// returns an error which the dispatcher classifies as
// SKIPPED_OBJECT_LOCK. Pinning the integration end-to-end so a
// regression in either the lock state lookup or the dispatcher's
// outcome handling is caught.
//
// Companion positive control: a second object in the same bucket
// without retention is expired by the same sweep — proves the rule
// is firing AND that the lock check is selective, not a blanket skip.
func TestLifecycleSkipsObjectLockedObjects(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("objlock")
// Object Lock requires the bucket to be created with the flag set;
// it can't be enabled retroactively on an existing bucket. Use the
// raw API rather than mustCreateBucket here so we can pass the
// ObjectLockEnabledForBucket option.
_, err := c.CreateBucket(context.Background(), &s3.CreateBucketInput{
Bucket: aws.String(bucket),
ObjectLockEnabledForBucket: aws.Bool(true),
})
require.NoError(t, err)
t.Cleanup(func() {
// Best-effort cleanup mirrors mustCreateBucket; locked objects
// may resist deletion but the test process continues regardless.
c.DeleteBucketLifecycle(context.Background(), &s3.DeleteBucketLifecycleInput{Bucket: aws.String(bucket)})
listOut, _ := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{Bucket: aws.String(bucket)})
if listOut != nil {
for _, v := range listOut.Versions {
// Bypass governance retention to free the bucket for delete.
c.DeleteObject(context.Background(), &s3.DeleteObjectInput{
Bucket: aws.String(bucket),
Key: v.Key,
VersionId: v.VersionId,
BypassGovernanceRetention: aws.Bool(true),
})
}
for _, m := range listOut.DeleteMarkers {
c.DeleteObject(context.Background(), &s3.DeleteObjectInput{
Bucket: aws.String(bucket), Key: m.Key, VersionId: m.VersionId,
})
}
}
c.DeleteBucket(context.Background(), &s3.DeleteBucketInput{Bucket: aws.String(bucket)})
})
putExpirationLifecycle(t, c, bucket, "lock/", 1)
const lockedKey = "lock/protected.txt"
const freeKey = "lock/free.txt"
// Locked object: PUT with GOVERNANCE retention until 1h from now.
lockedPut, err := c.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(lockedKey),
Body: strings.NewReader("locked"),
ObjectLockMode: types.ObjectLockModeGovernance,
ObjectLockRetainUntilDate: aws.Time(time.Now().Add(time.Hour)),
})
require.NoError(t, err, "PUT with retention must succeed on a lock-enabled bucket")
require.NotEmpty(t, aws.ToString(lockedPut.VersionId))
// Free object: PUT without retention. Object Lock requires
// versioning, so the bucket is implicitly versioned and every PUT
// produces a versionId. Capture both for the version-aware
// backdate path.
freePut, err := c.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(bucket), Key: aws.String(freeKey), Body: strings.NewReader("free"),
})
require.NoError(t, err)
freeVersionID := aws.ToString(freePut.VersionId)
require.NotEmpty(t, freeVersionID)
// Backdate both so they would otherwise both expire under the
// 1-day rule. The lock check is what distinguishes them.
// Versioning-enabled buckets store entries under .versions/v_<id>,
// not at the bare key path, so use backdateVersionedMtime.
backdateVersionedMtime(t, fc, bucket, lockedKey, aws.ToString(lockedPut.VersionId), 30)
backdateVersionedMtime(t, fc, bucket, freeKey, freeVersionID, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// Free object expires (positive control).
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(freeKey),
})
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond, "unlocked object must expire (control)")
// Locked object survives — Object Lock retention overrides the
// lifecycle rule. HEAD without versionId returns the still-current
// object data, not a 404.
_, err = c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(lockedKey),
})
require.NoError(t, err, "object under GOVERNANCE retention must survive the sweep")
}
@@ -1,71 +0,0 @@
// Size-filter integration scenario.
package lifecycle
import (
"context"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/require"
)
// TestLifecycleSizeFilterGreaterThan: ObjectSizeGreaterThan is a strict >
// gate (filterAllows in match.go uses ev.Size <= rule.FilterSizeGreaterThan
// to reject). Two backdated objects on the same prefix — only the one
// strictly larger than the threshold is expired.
func TestLifecycleSizeFilterGreaterThan(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("size-filter")
mustCreateBucket(t, c, bucket)
const threshold = 100
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
Bucket: aws.String(bucket),
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
Rules: []types.LifecycleRule{{
ID: aws.String("size-gt"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilter{
And: &types.LifecycleRuleAndOperator{
Prefix: aws.String("sz/"),
ObjectSizeGreaterThan: aws.Int64(threshold),
},
},
Expiration: &types.LifecycleExpiration{Days: aws.Int32(1)},
}},
},
})
require.NoError(t, err)
const smallKey = "sz/small.txt" // size <= threshold → must remain
const largeKey = "sz/large.txt" // size > threshold → must expire
putObject(t, c, bucket, smallKey, strings.Repeat("a", threshold)) // exactly at boundary; gate is strictly >, so rejects
putObject(t, c, bucket, largeKey, strings.Repeat("a", threshold+50))
backdateMtime(t, fc, bucket, smallKey, 30)
backdateMtime(t, fc, bucket, largeKey, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// Larger-than-threshold object expired.
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(largeKey),
})
return err != nil
}, 30*time.Second, 500*time.Millisecond, "object larger than threshold must expire")
// Boundary object (size == threshold) survives — strictly-greater gate.
_, err = c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(smallKey),
})
require.NoError(t, err, "object at exact threshold must remain (gate is strictly >)")
}
@@ -1,117 +0,0 @@
// Suspended-versioning Expiration integration scenario.
package lifecycle
import (
"context"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/require"
)
// putVersioningSuspended flips Status=Suspended. PUTs after this point
// overwrite the null version in place rather than creating new versionIds.
func putVersioningSuspended(t *testing.T, c *s3.Client, bucket string) {
t.Helper()
_, err := c.PutBucketVersioning(context.Background(), &s3.PutBucketVersioningInput{
Bucket: aws.String(bucket),
VersioningConfiguration: &types.VersioningConfiguration{Status: types.BucketVersioningStatusSuspended},
})
require.NoError(t, err)
}
// TestLifecycleSuspendedVersioningExpiration: on a suspended-versioning
// bucket, Expiration takes a different code path than the Enabled and
// Off cases. lifecycleDispatch's VersioningSuspended branch first
// deletes the null version (via deleteSpecificObjectVersion(versionId="null"))
// and then writes a fresh delete marker on top.
//
// Setup: enable then suspend versioning so any noncurrent versions can
// exist alongside the null one; PUT once-with-versioning + once-after-
// suspend (so we have a noncurrent version + a null version), backdate
// the null, run the worker. The null must be deleted and replaced by a
// delete marker; the prior noncurrent version stays addressable.
func TestLifecycleSuspendedVersioningExpiration(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("suspended")
mustCreateBucket(t, c, bucket)
putVersioningEnabled(t, c, bucket)
const key = "s/obj.txt"
// First PUT under Enabled: produces a real versionId.
putObject(t, c, bucket, key, "v1")
headEnabled, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key),
})
require.NoError(t, err)
v1 := aws.ToString(headEnabled.VersionId)
require.NotEmpty(t, v1)
require.NotEqual(t, "null", v1)
// Suspend versioning, then PUT again. The new write becomes the
// "null" version that overwrites the bare-key entry; v1 demotes to
// a noncurrent version.
putVersioningSuspended(t, c, bucket)
putObject(t, c, bucket, key, "null-version")
// Set the Expiration rule AFTER the writes so the rule
// configuration race against PUTs is deterministic. Days=1 for
// AWS-spec compliance; backdating the null entry makes the worker
// fire immediately.
putExpirationLifecycle(t, c, bucket, "s/", 1)
// Backdate the bare-key entry (the null version): on suspended
// buckets the null lives at the bare path, NOT under .versions/.
backdateMtime(t, fc, bucket, key, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// HEAD without versionId hits the latest, which after the worker
// runs should be a delete marker (404 NoSuchKey).
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key),
})
return err != nil
}, 30*time.Second, 500*time.Millisecond, "delete marker must dominate latest after suspended-versioning expiration")
// The noncurrent v1 (created under Enabled) must remain — Expiration
// in the suspended branch only touches the null version, not other
// versions in the .versions/ folder.
directHead, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(v1),
})
require.NoError(t, err, "noncurrent v1 must survive — suspended Expiration only touches the null")
require.NotNil(t, directHead)
// ListObjectVersions must show: v1 still present, a fresh delete
// marker as latest, and no more "null" version.
listOut, err := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket), Prefix: aws.String(key),
})
require.NoError(t, err)
var sawV1, sawMarker bool
for _, v := range listOut.Versions {
if aws.ToString(v.Key) == key {
vid := aws.ToString(v.VersionId)
if vid == v1 {
sawV1 = true
}
require.NotEqual(t, "null", vid, "null version must have been removed")
}
}
for _, m := range listOut.DeleteMarkers {
if aws.ToString(m.Key) == key && aws.ToBool(m.IsLatest) {
sawMarker = true
}
}
require.True(t, sawV1, "v1 must still appear in ListObjectVersions")
require.True(t, sawMarker, "fresh delete marker must dominate latest")
}
+6 -45
View File
@@ -40,14 +40,12 @@ const (
defaultS3Endpoint = "http://localhost:8333"
defaultS3GrpcEndpoint = "localhost:18333"
defaultMasterEndpt = "http://localhost:9333"
defaultAdminEndpoint = "http://localhost:23646"
// Pinned off the FILER_PORT+10000 convention; see Makefile.
defaultFilerGRPC = "localhost:18890"
defaultFilerGRPC = "localhost:18888"
bucketLifecycleXMLKey = "s3-bucket-lifecycle-configuration-xml"
bucketsPath = "/buckets"
accessKey = "some_access_key1"
secretKey = "some_secret_key1"
region = "us-east-1"
secretKey = "some_secret_key1"
region = "us-east-1"
)
func envOr(key, def string) string {
@@ -92,50 +90,13 @@ func mustCreateBucket(t *testing.T, c *s3.Client, name string) {
_, err := c.CreateBucket(context.Background(), &s3.CreateBucketInput{Bucket: aws.String(name)})
require.NoError(t, err)
t.Cleanup(func() {
// Drop the lifecycle configuration first so any subsequent
// shell-driven shard sweep stops loading rules for this bucket
// — without this, a later test's run-shard would pick up the
// dead bucket's config and produce phantom dispatches.
c.DeleteBucketLifecycle(context.Background(), &s3.DeleteBucketLifecycleInput{Bucket: aws.String(name)})
// Empty every version + delete marker (versioning-aware buckets
// hold state that ListObjectsV2 doesn't surface). Best-effort:
// errors are tolerated because the bucket might already be
// half-torn-down.
listOut, _ := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{Bucket: aws.String(name)})
// Best effort: empty + delete.
listOut, _ := c.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{Bucket: aws.String(name)})
if listOut != nil {
for _, v := range listOut.Versions {
c.DeleteObject(context.Background(), &s3.DeleteObjectInput{
Bucket: aws.String(name), Key: v.Key, VersionId: v.VersionId,
})
}
for _, m := range listOut.DeleteMarkers {
c.DeleteObject(context.Background(), &s3.DeleteObjectInput{
Bucket: aws.String(name), Key: m.Key, VersionId: m.VersionId,
})
}
}
// Catch any non-versioned objects ListObjectVersions missed.
objs, _ := c.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{Bucket: aws.String(name)})
if objs != nil {
for _, o := range objs.Contents {
for _, o := range listOut.Contents {
c.DeleteObject(context.Background(), &s3.DeleteObjectInput{Bucket: aws.String(name), Key: o.Key})
}
}
// Abort any in-flight multipart uploads — the AbortMPU lifecycle
// test leaves these intentionally; without an explicit Abort the
// bucket DELETE refuses with NotEmpty.
mpus, _ := c.ListMultipartUploads(context.Background(), &s3.ListMultipartUploadsInput{Bucket: aws.String(name)})
if mpus != nil {
for _, u := range mpus.Uploads {
c.AbortMultipartUpload(context.Background(), &s3.AbortMultipartUploadInput{
Bucket: aws.String(name), Key: u.Key, UploadId: u.UploadId,
})
}
}
c.DeleteBucket(context.Background(), &s3.DeleteBucketInput{Bucket: aws.String(name)})
})
}
@@ -1,456 +0,0 @@
// Versioning + delete-marker + noncurrent integration scenarios for the
// event-driven lifecycle worker. Same backdating trick as the base test:
// AWS rejects Days < 1, so the test ages the object via the filer's
// UpdateEntry RPC and runs the shell command once with -runtime 10s.
package lifecycle
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/require"
)
// putVersioningEnabled flips Status=Enabled on the bucket so subsequent
// PUTs produce versioned siblings rather than overwriting.
func putVersioningEnabled(t *testing.T, c *s3.Client, bucket string) {
t.Helper()
_, err := c.PutBucketVersioning(context.Background(), &s3.PutBucketVersioningInput{
Bucket: aws.String(bucket),
VersioningConfiguration: &types.VersioningConfiguration{Status: types.BucketVersioningStatusEnabled},
})
require.NoError(t, err)
}
// putNoncurrentExpirationLifecycle sets a NoncurrentVersionExpiration rule
// targeting a prefix. Days=1 because AWS rejects 0; the test backdates
// the noncurrent version.
func putNoncurrentExpirationLifecycle(t *testing.T, c *s3.Client, bucket, prefix string, days int32) {
t.Helper()
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
Bucket: aws.String(bucket),
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
Rules: []types.LifecycleRule{
{
ID: aws.String("expire-noncurrent"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilter{Prefix: aws.String(prefix)},
NoncurrentVersionExpiration: &types.NoncurrentVersionExpiration{
NoncurrentDays: aws.Int32(days),
},
},
},
},
})
require.NoError(t, err)
}
// backdateVersionedMtime ages a specific .versions/v_<id> entry. The
// version files live under <buckets>/<bucket>/<key>.versions/v_<versionId>.
//
// Also clears the ExtNoncurrentSinceNsKey stamp if present. The stamp
// records when this version was demoted by its successor's PUT; tests
// that backdate a version's own mtime aren't expressing a coherent claim
// about the demotion moment, so leaving a real-now stamp would let it
// dominate the backdated mtime and stop the rule from firing. Clearing
// makes the lifecycle engine fall back to the sibling-mtime derivation,
// which is what these tests were originally written against. Production
// PUTs always write a stamp; this is a test-only escape hatch.
func backdateVersionedMtime(t *testing.T, fc filer_pb.SeaweedFilerClient, bucket, key, versionID string, daysOld int) {
t.Helper()
dir := bucketsPath + "/" + bucket + "/" + key + ".versions"
name := "v_" + versionID
resp, err := fc.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
Directory: dir, Name: name,
})
require.NoError(t, err, "lookup version %s/%s", dir, name)
require.NotNil(t, resp.Entry)
require.NotNil(t, resp.Entry.Attributes)
resp.Entry.Attributes.Mtime = time.Now().Add(-time.Duration(daysOld) * 24 * time.Hour).Unix()
resp.Entry.Attributes.MtimeNs = 0
delete(resp.Entry.Extended, s3_constants.ExtNoncurrentSinceNsKey)
_, err = fc.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
Directory: dir, Entry: resp.Entry,
})
require.NoError(t, err)
}
func runLifecycleShard(t *testing.T) string {
t.Helper()
// -refresh 1s drives an inter-pass loop inside the 10s runtime so
// rules that need two passes to settle (e.g.
// TestLifecycleExpiredDeleteMarkerCleanup: pass 1 removes v1, pass 2
// removes the now-orphaned marker) get the second pass.
out := runShellCommand(t, fmt.Sprintf(
"s3.lifecycle.run-shard -shards 0-15 -s3 %s -events 0 -refresh 1s -runtime 10s",
envOr("S3_GRPC_ENDPOINT", defaultS3GrpcEndpoint),
))
require.NotContains(t, out, "FATAL", "shell output:\n%s", out)
return out
}
// TestLifecycleVersionedBucketCreatesDeleteMarker: an Expiration rule on a
// versioned bucket must produce a delete marker (latest version after
// expiration) rather than removing the version itself. Pre-fix this would
// have unconditionally deleted the live version.
func TestLifecycleVersionedBucketCreatesDeleteMarker(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("vexp")
mustCreateBucket(t, c, bucket)
putVersioningEnabled(t, c, bucket)
putExpirationLifecycle(t, c, bucket, "expire/", 1)
const key = "expire/old.txt"
putObject(t, c, bucket, key, "v1")
// Look up the version we just created so we can backdate it. The bare
// filer entry under <bucket>/<key> is the latest-version pointer in
// SeaweedFS's versioned layout.
headOut, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key),
})
require.NoError(t, err)
require.NotNil(t, headOut.VersionId)
versionID := aws.ToString(headOut.VersionId)
require.NotEmpty(t, versionID)
backdateVersionedMtime(t, fc, bucket, key, versionID, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// HEAD without versionId returns the latest version. After the worker
// runs, the latest must be a delete marker (NoSuchKey on plain HEAD).
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key),
})
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond, "expected delete marker to become latest for %s/%s", bucket, key)
// The original version must still be addressable directly — Expiration
// on a versioned bucket creates a marker, it doesn't drop the version.
directHead, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(versionID),
})
require.NoError(t, err, "original version must still exist")
require.NotNil(t, directHead)
// ListObjectVersions should now show a delete marker dominating the
// version. The IsLatest=true marker proves the worker created a fresh
// marker rather than aging the existing version.
listOut, err := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket), Prefix: aws.String(key),
})
require.NoError(t, err)
var sawMarker bool
for _, m := range listOut.DeleteMarkers {
if aws.ToString(m.Key) == key && aws.ToBool(m.IsLatest) {
sawMarker = true
break
}
}
require.True(t, sawMarker, "ListObjectVersions must show a delete marker as latest for %s", key)
}
// TestLifecycleNoncurrentVersionExpiration: NoncurrentVersionExpiration
// only fires on noncurrent versions. PUT v1, PUT v2 (so v1 → noncurrent),
// backdate v1, run worker. v1 must be removed; v2 stays current.
func TestLifecycleNoncurrentVersionExpiration(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("noncurrent")
mustCreateBucket(t, c, bucket)
putVersioningEnabled(t, c, bucket)
putNoncurrentExpirationLifecycle(t, c, bucket, "v/", 1)
const key = "v/obj.txt"
// First PUT.
put1, err := c.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key), Body: strings.NewReader("v1"),
})
require.NoError(t, err)
v1 := aws.ToString(put1.VersionId)
// Second PUT promotes v1 to noncurrent.
put2, err := c.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key), Body: strings.NewReader("v2"),
})
require.NoError(t, err)
v2 := aws.ToString(put2.VersionId)
require.NotEqual(t, v1, v2)
// NoncurrentDays is clocked from the SUCCESSOR's mtime — i.e. when
// the version became noncurrent — not from the displaced version's
// own mtime. Backdating only v1 leaves the noncurrent clock (v2's
// mtime) at "now" and the rule never fires. Age both with v1 older
// than v2 so the ordering is realistic.
backdateVersionedMtime(t, fc, bucket, key, v1, 31)
backdateVersionedMtime(t, fc, bucket, key, v2, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// v1 must be gone.
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(v1),
})
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond, "noncurrent v1 must be expired")
// v2 must still be addressable BY VERSION ID — pinning that the
// noncurrent dispatch didn't accidentally remove the current
// version's file.
directHead, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key), VersionId: aws.String(v2),
})
require.NoError(t, err, "current version v2 (versionId=%s) must still be addressable directly", v2)
// And HEAD without versionId (i.e., the latest) must hit v2 — pinning
// that the bare-key pointer wasn't removed and a delete marker
// wasn't fabricated as a side effect of the noncurrent delete. On
// failure we dump ListObjectVersions so the failure log shows
// exactly what's there (versions vs delete markers vs bare).
currentHead, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key),
})
if err != nil {
listOut, listErr := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket), Prefix: aws.String(key),
})
if listErr != nil || listOut == nil {
t.Logf("HeadObject(latest) failed: %v\nListObjectVersions err=%v", err, listErr)
} else {
t.Logf("HeadObject(latest) failed: %v\nListObjectVersions err=%v versions=%d markers=%d", err, listErr, len(listOut.Versions), len(listOut.DeleteMarkers))
for _, v := range listOut.Versions {
t.Logf(" version key=%s id=%s isLatest=%v", aws.ToString(v.Key), aws.ToString(v.VersionId), aws.ToBool(v.IsLatest))
}
for _, m := range listOut.DeleteMarkers {
t.Logf(" marker key=%s id=%s isLatest=%v", aws.ToString(m.Key), aws.ToString(m.VersionId), aws.ToBool(m.IsLatest))
}
}
require.NoError(t, err, "HEAD(latest) on %s/%s must succeed; v2 (%s) must remain current", bucket, key, v2)
}
require.Equal(t, v2, aws.ToString(currentHead.VersionId), "latest pointer must still resolve to v2")
_ = directHead
}
// TestLifecycleExpiredDeleteMarkerCleanup: a sole-survivor delete marker
// must be removed by an ExpiredObjectDeleteMarker=true rule. Setup: PUT v1,
// DELETE (creates a marker that becomes latest, with v1 as noncurrent),
// expire v1 via NoncurrentVersionExpiration so the marker is alone, then
// the marker rule fires.
//
// We exercise this in two passes: pass 1 (Noncurrent rule on a sibling
// prefix) cleans v1; pass 2 (ExpiredObjectDeleteMarker rule, also on the
// same prefix) removes the now-orphaned marker. Since the lifecycle XML
// only carries one set of rules per bucket, we wire both.
func TestLifecycleExpiredDeleteMarkerCleanup(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("marker")
mustCreateBucket(t, c, bucket)
putVersioningEnabled(t, c, bucket)
// Combined rule: noncurrent expiration + delete-marker cleanup, both
// on the same prefix.
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
Bucket: aws.String(bucket),
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
Rules: []types.LifecycleRule{
{
ID: aws.String("expire-noncurrent"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilter{Prefix: aws.String("m/")},
NoncurrentVersionExpiration: &types.NoncurrentVersionExpiration{
NoncurrentDays: aws.Int32(1),
},
},
{
ID: aws.String("expire-marker"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilter{Prefix: aws.String("m/")},
Expiration: &types.LifecycleExpiration{
ExpiredObjectDeleteMarker: aws.Bool(true),
},
},
},
},
})
require.NoError(t, err)
const key = "m/obj.txt"
put1, err := c.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key), Body: strings.NewReader("v1"),
})
require.NoError(t, err)
v1 := aws.ToString(put1.VersionId)
// Plain DELETE with versioning on creates a marker; the marker becomes
// latest, v1 demotes to noncurrent.
_, err = c.DeleteObject(context.Background(), &s3.DeleteObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key),
})
require.NoError(t, err)
// Backdate v1 so the noncurrent rule expires it.
backdateVersionedMtime(t, fc, bucket, key, v1, 30)
// Backdate the delete marker too. ListObjectVersions tells us the
// marker's versionId.
listOut, err := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket), Prefix: aws.String(key),
})
require.NoError(t, err)
var markerID string
for _, m := range listOut.DeleteMarkers {
if aws.ToString(m.Key) == key {
markerID = aws.ToString(m.VersionId)
break
}
}
require.NotEmpty(t, markerID, "expected to find the delete marker we just created")
backdateVersionedMtime(t, fc, bucket, key, markerID, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// Both v1 and the marker must be gone — the .versions/<key>/ folder
// should hold nothing for this key.
require.Eventuallyf(t, func() bool {
listOut, err := c.ListObjectVersions(context.Background(), &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket), Prefix: aws.String(key),
})
if err != nil {
return false
}
for _, v := range listOut.Versions {
if aws.ToString(v.Key) == key {
return false
}
}
for _, m := range listOut.DeleteMarkers {
if aws.ToString(m.Key) == key {
return false
}
}
return true
}, 30*time.Second, 500*time.Millisecond, "every version and marker for %s must be gone", key)
}
// TestLifecycleDisabledRuleSkipsObject: a rule with Status=Disabled must
// not produce dispatches, even on a backdated object that would otherwise
// match. Negative test for the engine's enabled-status gate.
func TestLifecycleDisabledRuleSkipsObject(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("disabled")
mustCreateBucket(t, c, bucket)
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
Bucket: aws.String(bucket),
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
Rules: []types.LifecycleRule{{
ID: aws.String("disabled-rule"),
Status: types.ExpirationStatusDisabled,
Filter: &types.LifecycleRuleFilter{Prefix: aws.String("d/")},
Expiration: &types.LifecycleExpiration{Days: aws.Int32(1)},
}},
},
})
require.NoError(t, err)
const key = "d/obj.txt"
putObject(t, c, bucket, key, "still-here")
backdateMtime(t, fc, bucket, key, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
// Wait the same window; the object MUST still exist after.
time.Sleep(2 * time.Second) // give the worker a chance to (incorrectly) act
_, err = c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(key),
})
require.NoError(t, err, "Disabled rule must not delete the object")
}
// TestLifecycleTagFilter: a rule with a tag filter must only match objects
// carrying that tag. Two backdated objects, one tagged, one not — only the
// tagged one is removed.
func TestLifecycleTagFilter(t *testing.T) {
c := s3Client(t)
fc, fcClose := filerClient(t)
defer fcClose()
bucket := uniqueBucket("tagfilter")
mustCreateBucket(t, c, bucket)
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
Bucket: aws.String(bucket),
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
Rules: []types.LifecycleRule{{
ID: aws.String("tag-only"),
Status: types.ExpirationStatusEnabled,
Filter: &types.LifecycleRuleFilter{
And: &types.LifecycleRuleAndOperator{
Prefix: aws.String("t/"),
Tags: []types.Tag{
{Key: aws.String("env"), Value: aws.String("temp")},
},
},
},
Expiration: &types.LifecycleExpiration{Days: aws.Int32(1)},
}},
},
})
require.NoError(t, err)
const taggedKey = "t/tagged.txt"
const untaggedKey = "t/untagged.txt"
putObject(t, c, bucket, taggedKey, "tagged")
putObject(t, c, bucket, untaggedKey, "untagged")
// Stamp the tag on one of them.
_, err = c.PutObjectTagging(context.Background(), &s3.PutObjectTaggingInput{
Bucket: aws.String(bucket), Key: aws.String(taggedKey),
Tagging: &types.Tagging{TagSet: []types.Tag{
{Key: aws.String("env"), Value: aws.String("temp")},
}},
})
require.NoError(t, err)
backdateMtime(t, fc, bucket, taggedKey, 30)
backdateMtime(t, fc, bucket, untaggedKey, 30)
out := runLifecycleShard(t)
t.Logf("shell output:\n%s", out)
require.Eventuallyf(t, func() bool {
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(taggedKey),
})
return isS3NotFound(err)
}, 30*time.Second, 500*time.Millisecond, "tagged object must be expired")
_, err = c.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucket), Key: aws.String(untaggedKey),
})
require.NoError(t, err, "untagged object must remain")
}
-1
View File
@@ -127,7 +127,6 @@ start-primary: check-deps
-webdav.port=$(PRIMARY_WEBDAV_PORT) \
-s3.allowDeleteBucketNotEmpty=true \
-s3.config=s3_config.json \
-volume.allowUntrustedRemoteEndpoints \
-dir=$(PRIMARY_DIR) \
-ip=127.0.0.1 \
-ip.bind=127.0.0.1 \
-41
View File
@@ -1,41 +0,0 @@
# AWS SDK V2 Route Disambiguation Integration Tests
#
# Pins the regression for the route collision between the regular S3 API
# and the S3 Tables REST API on shared top-level paths (/buckets,
# /get-table). The tests use the real AWS SDK V2 for Go so the SDK's own
# XML deserializer is the assertion — a JSON body produces an SDK error
# before any test code runs.
#
# Prerequisites:
# - SeaweedFS running with S3 API enabled on port 8333
# - Go 1.21+
#
# Usage:
# make test - Run the SDK V2 routing tests
# make test-verbose - Run with verbose output
# make clean - Clean test cache
.PHONY: all test test-verbose clean help
S3_ENDPOINT ?= http://127.0.0.1:8333
all: test
test:
@echo "Running SDK V2 routing tests against $(S3_ENDPOINT)..."
S3_ENDPOINT=$(S3_ENDPOINT) go test -v -timeout 5m ./...
test-verbose:
S3_ENDPOINT=$(S3_ENDPOINT) go test -v -timeout 5m -count=1 ./...
clean:
go clean -testcache
help:
@echo "AWS SDK V2 Route Disambiguation Tests"
@echo "Targets:"
@echo " test Run the routing tests"
@echo " test-verbose Run with verbose output"
@echo " clean Clean test cache"
@echo "Environment Variables:"
@echo " S3_ENDPOINT S3 endpoint URL (default: http://127.0.0.1:8333)"
@@ -1,210 +0,0 @@
// Package sdkv2routing_test exercises route disambiguation between the
// regular S3 API and the S3 Tables REST API on top-level paths the two
// share (/buckets, /get-table). The bug it pins:
//
// When a user has an S3 bucket named "buckets" (or "get-table"), a
// path-style ListObjectsV2 request sent by AWS SDK V2 / Hadoop s3a /
// Spark would be routed to the S3 Tables ListTableBuckets handler and
// receive a JSON body. AWS SDK V2 then fails XML parsing with
// "Unexpected character '{' (code 123) in prolog".
//
// These tests use the real AWS SDK V2 for Go, so the SDK's own
// deserializer is the assertion: if the server returns the wrong
// content type, the SDK errors out before any test assertion runs.
package sdkv2routing_test
import (
"context"
"io"
"os"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/require"
)
const (
defaultEndpoint = "http://127.0.0.1:8333"
defaultAccessKey = "some_access_key1"
defaultSecretKey = "some_secret_key1"
defaultRegion = "us-east-1"
)
func getS3Client(t *testing.T) *s3.Client {
t.Helper()
endpoint := os.Getenv("S3_ENDPOINT")
if endpoint == "" {
endpoint = defaultEndpoint
}
accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
if accessKey == "" {
accessKey = defaultAccessKey
}
secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
if secretKey == "" {
secretKey = defaultSecretKey
}
region := os.Getenv("AWS_REGION")
if region == "" {
region = defaultRegion
}
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(
func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
URL: endpoint,
SigningRegion: defaultRegion,
HostnameImmutable: true,
}, nil
})),
)
require.NoError(t, err)
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
})
}
// ensureBucket creates bucket if it doesn't already exist. It tolerates
// BucketAlreadyOwnedByYou / BucketAlreadyExists so the tests are
// idempotent across local re-runs.
func ensureBucket(t *testing.T, ctx context.Context, client *s3.Client, bucket string) {
t.Helper()
_, err := client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)})
if err == nil {
return
}
msg := err.Error()
if strings.Contains(msg, "BucketAlreadyOwnedByYou") || strings.Contains(msg, "BucketAlreadyExists") {
return
}
t.Fatalf("CreateBucket(%q) failed: %v", bucket, err)
}
// deleteBucket best-effort cleans up a bucket and any objects in it.
// Test does not fail if cleanup fails — the next run is idempotent.
func deleteBucket(ctx context.Context, client *s3.Client, bucket string) {
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{Bucket: aws.String(bucket)})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
break
}
for _, obj := range page.Contents {
client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: obj.Key})
}
}
client.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: aws.String(bucket)})
}
// TestListObjectsV2_OnBucketNamedBuckets is the direct reproducer for
// issue #9559: Spark / Hadoop s3a does a ListObjectsV2 against bucket
// "buckets" via AWS SDK V2, which fails with
// "Could not parse XML response. ... Unexpected character '{' (code 123)
// in prolog" when SeaweedFS routes the request to the JSON-returning
// ListTableBuckets handler. The SDK's response deserializer is the
// real assertion here — a JSON body produces an SDK error before we
// reach require.NoError.
func TestListObjectsV2_OnBucketNamedBuckets(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := getS3Client(t)
const bucket = "buckets"
ensureBucket(t, ctx, client, bucket)
t.Cleanup(func() { deleteBucket(ctx, client, bucket) })
out, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String("logs/"),
})
require.NoError(t, err, "AWS SDK V2 must parse the response as XML")
require.NotNil(t, out)
}
// TestPutGetObject_OnBucketNamedBuckets exercises the full read/write
// round-trip on the colliding bucket name. PutObject and GetObject go
// through different routes than ListObjectsV2, and verifying them
// guards against future regressions that re-route only some verbs.
func TestPutGetObject_OnBucketNamedBuckets(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := getS3Client(t)
const bucket = "buckets"
const key = "logs/hello.txt"
const body = "hello from issue 9559"
ensureBucket(t, ctx, client, bucket)
t.Cleanup(func() { deleteBucket(ctx, client, bucket) })
_, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: strings.NewReader(body),
})
require.NoError(t, err)
out, err := client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)})
require.NoError(t, err)
defer out.Body.Close()
got, err := io.ReadAll(out.Body)
require.NoError(t, err)
require.Equal(t, body, string(got))
}
// TestListObjectsV2_OnBucketNamedGetTable covers the second colliding
// path. The S3 Tables GET /get-table endpoint shares its path with a
// bucket literally named "get-table", which is a legal S3 bucket name.
func TestListObjectsV2_OnBucketNamedGetTable(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := getS3Client(t)
const bucket = "get-table"
ensureBucket(t, ctx, client, bucket)
t.Cleanup(func() { deleteBucket(ctx, client, bucket) })
out, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(bucket)})
require.NoError(t, err)
require.NotNil(t, out)
}
// TestCreateAndListBuckets_ServiceLevel verifies the SDK's service-level
// ListBuckets still parses as XML when a bucket named "buckets" exists.
// ListBuckets goes through GET / (root) which is unaffected by the
// /buckets route collision — this is a guard against the matcher
// accidentally widening to top-level paths.
func TestCreateAndListBuckets_ServiceLevel(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := getS3Client(t)
const bucket = "buckets"
ensureBucket(t, ctx, client, bucket)
t.Cleanup(func() { deleteBucket(ctx, client, bucket) })
out, err := client.ListBuckets(ctx, &s3.ListBucketsInput{})
require.NoError(t, err)
require.NotNil(t, out)
found := false
for _, b := range out.Buckets {
if aws.ToString(b.Name) == bucket {
found = true
break
}
}
require.True(t, found, "bucket %q must appear in service-level ListBuckets", bucket)
}
+3 -23
View File
@@ -17,9 +17,6 @@ import (
"testing"
"time"
"github.com/aws/aws-sdk-go/aws/credentials"
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
"github.com/seaweedfs/seaweedfs/test/testutil"
)
@@ -454,22 +451,19 @@ func icebergPath(prefix, path string) string {
return withPrefix
}
// createTableBucket creates a table bucket via the S3Tables REST API.
// The request is AWS V4 signed for SERVICE=s3tables so the S3 Tables
// route matcher accepts it; signing with regular SERVICE=s3 would let
// the request fall through to the S3 CreateBucket handler.
// createTableBucket creates a table bucket via the S3Tables REST API
func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
t.Helper()
// Use S3Tables REST API to create the bucket
endpoint := fmt.Sprintf("http://localhost:%d/buckets", env.s3Port)
reqBody := fmt.Sprintf(`{"name":"%s"}`, bucketName)
reqBody := fmt.Sprintf(`{"name":"%s"}`, bucketName)
req, err := http.NewRequest(http.MethodPut, endpoint, strings.NewReader(reqBody))
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
signS3TablesRequest(t, req, reqBody)
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -486,20 +480,6 @@ func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
t.Logf("Created table bucket %s", bucketName)
}
// signS3TablesRequest signs req with AWS V4 for SERVICE=s3tables. The
// underlying weed mini instance runs in default-allow mode so the
// signature itself is not verified; only the credential scope matters,
// because the S3 Tables route matcher requires SERVICE=s3tables to
// distinguish S3 Tables traffic from regular S3 calls on the same paths.
func signS3TablesRequest(t *testing.T, req *http.Request, body string) {
t.Helper()
creds := credentials.NewStaticCredentials("test-ak", "test-sk", "")
signer := v4.NewSigner(creds)
if _, err := signer.Sign(req, strings.NewReader(body), "s3tables", "us-east-1", time.Now()); err != nil {
t.Fatalf("Failed to sign S3 Tables request: %v", err)
}
}
// randomSuffix returns a short random hex suffix for unique resource naming.
func randomSuffix() string {
return fmt.Sprintf("%x", time.Now().UnixNano()&0xffffffff)
@@ -411,12 +411,7 @@ func createIcebergTable(t *testing.T, env *TestEnvironment, bucketName, namespac
func listFilerContents(t *testing.T, env *TestEnvironment, path string) {
t.Helper()
// Bound diagnostic listing so a hung weed shell during cleanup can't
// burn the whole 20-minute test timeout.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "weed", "shell",
cmd := exec.Command("weed", "shell",
fmt.Sprintf("-master=%s", env.hostMasterAddress()),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf("fs.ls -R %s\nexit\n", path))
-20
View File
@@ -1,20 +0,0 @@
FROM chrislusf/seaweedfs:e2e
RUN apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update && \
DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 install -y \
--no-install-recommends \
--no-install-suggests \
samba \
smbclient \
python3-minimal \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY smb.conf.template /smb.conf.template
COPY smb_tests.sh /smb_tests.sh
COPY lock_tests.sh /lock_tests.sh
COPY entrypoint.sh /entrypoint.sh
COPY run_inside_container.sh /run_inside_container.sh
RUN chmod +x /smb_tests.sh /lock_tests.sh /entrypoint.sh /run_inside_container.sh
ENTRYPOINT ["/entrypoint.sh"]
-96
View File
@@ -1,96 +0,0 @@
# Samba on FUSE integration test
Exports a SeaweedFS FUSE mount over SMB with Samba's `smbd` and drives it with
`smbclient`, verifying that SMB file operations work correctly on top of the
mount and that data stays consistent across both protocols.
## What it checks
The functional battery in `smb_tests.sh` covers:
- connecting to the share and listing the root
- 1 MiB upload/download round-trip with content verification
- subdirectory creation and writes into it
- file rename
- 64 MiB upload/download (exercises SeaweedFS chunk splitting)
- recursive upload of a directory tree
- cross-protocol consistency: files written over SMB appear on the FUSE mount
with identical content, and files written directly on the FUSE mount are
readable over SMB
- deleting files and directory trees
The locking / concurrency battery in `lock_tests.sh` covers the harder cases a
network-filesystem backend has to get right:
- **POSIX `fcntl` byte-range locking** on the FUSE mount: a held exclusive lock
denies a conflicting lock, allows a non-overlapping range, and is reacquirable
after release (exercises the mount's `SetLk`/`GetLk`)
- **Distributed locking** (`-dlm`): a file held open for writing on one mount
blocks a writer on a second mount until it is released
- **Distributed-lock integrity**: concurrent writers to the same file from two
mounts leave exactly one intact payload, never a torn mix
- **Concurrency**: parallel writers to distinct files all succeed
Both FUSE mounts are started with `-dlm` (distributed lock manager). The second
mount (`/mnt/seaweedfs2`) exists only to contend with the smbd-backed mount in
the distributed-locking tests; both see the same filer path, so `.../share` is
the same data on each.
> Note on DLM semantics: `-dlm` coordinates *write access* (one mount writes a
> file at a time) and guarantees writes are not torn. It does not guarantee
> which concurrent writer wins or instant cross-mount read convergence — the
> holder's buffered data is flushed on close, asynchronously to lock release.
> When a holder closes the file, a writer on another mount acquires the freed
> lock within ~1s and completes.
## Layout
| File | Purpose |
| --- | --- |
| `smb_tests.sh` | SMB functional battery. Shared by both runners. |
| `lock_tests.sh` | SMB locking / concurrency battery. Shared by both runners. |
| `smb.conf.template` | Samba config; placeholders are filled in at run time. |
| `run.sh` | Local runner: `weed mini` + two `-dlm` mounts + `smbd` + both batteries, all as the current user on unprivileged ports. |
| `entrypoint.sh` | Container entrypoint: starts two `-dlm` FUSE mounts and runs `smbd`. |
| `run_inside_container.sh` | Runs both batteries inside the container against the local `smbd`. |
| `Dockerfile` | Adds Samba to the `chrislusf/seaweedfs:e2e` image. |
| `docker-compose.yml` | master + volume + filer + samba services. |
## Running locally
Requirements: `weed` on `$PATH`, `fusermount3`, and Samba's `smbd` /
`smbclient` / `smbpasswd` (Debian/Ubuntu: `apt-get install samba smbclient`).
```sh
test/samba/run.sh
```
No `sudo` is needed: `smbd` runs as the current user on port 4450 and all state
lives under a temp work dir that is cleaned up on exit.
## Running with Docker
Mirrors the CI job. Requires `/dev/fuse` and `SYS_ADMIN` (provided in the
compose file).
```sh
# build the base e2e image first (from the repo's docker/ dir)
docker compose -f test/samba/docker-compose.yml up --wait
docker compose -f test/samba/docker-compose.yml exec -T samba /run_inside_container.sh
docker compose -f test/samba/docker-compose.yml down -v
```
## CI
`.github/workflows/samba-integration.yml` runs on changes to `weed/mount/**`,
`weed/filer/**`, or `test/samba/**`. It builds the e2e image, builds the Samba
harness image on top, brings up the cluster, runs the battery, and uploads
server logs as artifacts.
## Notes
- The share disables Samba's DOS-attribute / xattr mapping and oplocks. The
SeaweedFS FUSE mount does not implement that surface, and leaving it on
produces `NT_STATUS_NOT_SUPPORTED` errors unrelated to data integrity.
- The share path is a subdirectory of the mount (`.../share`) so the runner can
verify SMB-side operations directly on the FUSE side.
-60
View File
@@ -1,60 +0,0 @@
services:
master:
image: chrislusf/seaweedfs:e2e
command: "-v=4 master -ip=master -ip.bind=0.0.0.0 -raftBootstrap"
healthcheck:
test: ["CMD", "curl", "--fail", "-I", "http://localhost:9333/cluster/healthz"]
interval: 2s
timeout: 10s
retries: 30
start_period: 10s
volume:
image: chrislusf/seaweedfs:e2e
command: "-v=4 volume -master=master:9333 -ip=volume -ip.bind=0.0.0.0 -preStopSeconds=1"
healthcheck:
test: ["CMD", "curl", "--fail", "-I", "http://localhost:8080/healthz"]
interval: 2s
timeout: 10s
retries: 15
start_period: 5s
depends_on:
master:
condition: service_healthy
filer:
image: chrislusf/seaweedfs:e2e
command: "-v=4 filer -master=master:9333 -ip=filer -ip.bind=0.0.0.0"
healthcheck:
test: ["CMD", "curl", "--fail", "-I", "http://localhost:8888/healthz"]
interval: 2s
timeout: 10s
retries: 15
start_period: 5s
depends_on:
volume:
condition: service_healthy
samba:
image: chrislusf/seaweedfs:samba
build:
context: .
environment:
FILER: filer:8888
cap_add:
- SYS_ADMIN
devices:
- /dev/fuse
security_opt:
- apparmor:unconfined
healthcheck:
test:
- "CMD-SHELL"
- "mountpoint -q /mnt/seaweedfs && mountpoint -q /mnt/seaweedfs2 && smbclient -L 127.0.0.1 -p 445 -U smbtest%smbtest -m SMB3 >/dev/null 2>&1"
interval: 3s
timeout: 10s
retries: 20
start_period: 15s
depends_on:
filer:
condition: service_healthy
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env bash
#
# Entrypoint for the samba test container.
#
# Mounts SeaweedFS over FUSE twice, both with distributed locking (-dlm) so the
# locking tests can exercise cross-mount write coordination:
# - MOUNT_DIR (/mnt/seaweedfs) is exported over SMB by smbd
# - MOUNT2_DIR (/mnt/seaweedfs2) is a second, independent mount of the same
# filer used to contend with the SMB writer
#
# Both mounts see the same filer path, so .../share is the same data on each.
# smbd runs in the foreground (as root, which owns the mounts), so the share
# uses "force user = root".
set -euo pipefail
FILER="${FILER:-filer:8888}"
MOUNT_DIR="${MOUNT_DIR:-/mnt/seaweedfs}"
MOUNT2_DIR="${MOUNT2_DIR:-/mnt/seaweedfs2}"
SHARE_DIR="${MOUNT_DIR}/share"
STATE_DIR="${STATE_DIR:-/var/lib/samba-test}"
SMB_PORT="${SMB_PORT:-445}"
SMB_USER="${SMB_USER:-smbtest}"
SMB_PASS="${SMB_PASS:-smbtest}"
mkdir -p "${MOUNT_DIR}" "${MOUNT2_DIR}" \
"${STATE_DIR}/private" "${STATE_DIR}/state" "${STATE_DIR}/cache" \
"${STATE_DIR}/lock" "${STATE_DIR}/pid" "${STATE_DIR}/ncalrpc"
# mount_seaweedfs <mountpoint> <logfile> — mount with -dlm and wait for it.
mount_seaweedfs() {
local dir="$1" log="$2"
echo "==> Mounting SeaweedFS (${FILER}) at ${dir} with -dlm"
weed -v=1 mount \
-filer="${FILER}" \
-dir="${dir}" \
-filer.path=/ \
-dirAutoCreate \
-allowOthers \
-dlm \
>"${log}" 2>&1 &
local pid=$!
for _ in $(seq 1 120); do
if mountpoint -q "${dir}"; then
return 0
fi
if ! kill -0 "${pid}" 2>/dev/null; then
echo "weed mount (${dir}) exited early; log tail:" >&2
tail -n 100 "${log}" >&2 || true
exit 1
fi
sleep 0.5
done
echo "FUSE mount ${dir} did not come up" >&2
tail -n 100 "${log}" >&2 || true
exit 1
}
mount_seaweedfs "${MOUNT_DIR}" /var/log/weed-mount.log
mount_seaweedfs "${MOUNT2_DIR}" /var/log/weed-mount2.log
mkdir -p "${SHARE_DIR}"
chmod 0777 "${SHARE_DIR}"
# --- configure and start smbd ----------------------------------------------
echo "==> Configuring Samba share on port ${SMB_PORT}"
sed -e "s#@SHARE_PATH@#${SHARE_DIR}#g" \
-e "s#@STATE_DIR@#${STATE_DIR}#g" \
-e "s#@SMB_PORT@#${SMB_PORT}#g" \
-e "s#@FORCE_USER@#root#g" \
/smb.conf.template >/etc/samba/smb.conf
id -u "${SMB_USER}" >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin "${SMB_USER}"
printf '%s\n%s\n' "${SMB_PASS}" "${SMB_PASS}" | smbpasswd -a -s "${SMB_USER}"
echo "==> Starting smbd"
exec smbd -F --no-process-group -s /etc/samba/smb.conf
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env bash
#
# Locking / concurrency test battery for Samba on a SeaweedFS FUSE mount.
#
# Covers the challenges a network-filesystem backend has to get right:
# 1. POSIX fcntl byte-range locking on the FUSE mount (SetLk/GetLk)
# 2. Distributed locking (-dlm): a write held open on one mount blocks a
# writer on another mount until it is released
# 3. Distributed locking integrity: concurrent writers to the same file from
# two mounts produce intact (non-torn) data
# 4. Concurrent writers to distinct files all succeed
#
# Required env:
# SMB_USER, SMB_PASS samba credentials
# MOUNT_SHARE dir on the smbd-backed FUSE mount (mount 1)
# MOUNT2_SHARE dir on the second FUSE mount (mount 2)
# Optional env:
# SMB_HOST (127.0.0.1), SMB_SHARE (seaweedfs), SMB_PORT (445)
set -uo pipefail
SMB_HOST="${SMB_HOST:-127.0.0.1}"
SMB_SHARE="${SMB_SHARE:-seaweedfs}"
SMB_PORT="${SMB_PORT:-445}"
SMB_USER="${SMB_USER:?SMB_USER is required}"
SMB_PASS="${SMB_PASS:?SMB_PASS is required}"
MOUNT_SHARE="${MOUNT_SHARE:?MOUNT_SHARE is required}"
MOUNT2_SHARE="${MOUNT2_SHARE:?MOUNT2_SHARE is required}"
WORK="$(mktemp -d /tmp/samba-locktest.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
PASS=0
FAIL=0
pass() { printf ' [PASS] %s\n' "$1"; PASS=$((PASS + 1)); }
fail() { printf ' [FAIL] %s\n' "$1"; FAIL=$((FAIL + 1)); }
smb() {
smbclient "//${SMB_HOST}/${SMB_SHARE}" -p "${SMB_PORT}" \
-U "${SMB_USER}%${SMB_PASS}" -m SMB3 -c "$1"
}
md5() { md5sum "$1" | awk '{print $1}'; }
# 1. POSIX fcntl byte-range locking on the FUSE mount ------------------------
# Exercises the mount's SetLk/GetLk via two processes contending over fcntl
# (F_SETLK) byte-range locks. python3's fcntl.lockf issues real POSIX locks.
echo "==> 1. POSIX fcntl byte-range locking (FUSE mount SetLk/GetLk)"
lockfile="${MOUNT_SHARE}/fcntl_lock.dat"
: >"${lockfile}"
fcntl_out="$(python3 - "${lockfile}" <<'PY'
import fcntl, os, sys
path = sys.argv[1]
parent_to_child_r, parent_to_child_w = os.pipe() # release signal
child_to_parent_r, child_to_parent_w = os.pipe() # locked signal
pid = os.fork()
if pid == 0: # child: hold an exclusive lock on [0,100)
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
fcntl.lockf(fd, fcntl.LOCK_EX, 100, 0, 0)
os.write(child_to_parent_w, b"L")
os.read(parent_to_child_r, 1) # wait until parent says release
fcntl.lockf(fd, fcntl.LOCK_UN, 100, 0, 0)
os.close(fd)
os._exit(0)
# parent
os.read(child_to_parent_r, 1) # wait until child holds the lock
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
results = []
# a. a conflicting exclusive lock must be denied while the child holds it
try:
fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB, 100, 0, 0)
fcntl.lockf(fd, fcntl.LOCK_UN, 100, 0, 0)
results.append(("conflicting exclusive lock denied while held", False))
except OSError:
results.append(("conflicting exclusive lock denied while held", True))
# b. a non-overlapping range must be grantable
try:
fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB, 100, 200, 0)
fcntl.lockf(fd, fcntl.LOCK_UN, 100, 200, 0)
results.append(("non-overlapping range lock granted", True))
except OSError:
results.append(("non-overlapping range lock granted", False))
# c. after the holder releases, the lock must be acquirable
os.write(parent_to_child_w, b"R")
os.waitpid(pid, 0)
try:
fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB, 100, 0, 0)
fcntl.lockf(fd, fcntl.LOCK_UN, 100, 0, 0)
results.append(("lock acquirable after holder releases", True))
except OSError:
results.append(("lock acquirable after holder releases", False))
for name, ok in results:
print((" [PASS] " if ok else " [FAIL] ") + name)
sys.exit(0 if all(ok for _, ok in results) else 1)
PY
)"
echo "${fcntl_out}"
PASS=$((PASS + $(grep -c '\[PASS\]' <<<"${fcntl_out}")))
FAIL=$((FAIL + $(grep -c '\[FAIL\]' <<<"${fcntl_out}")))
# 2. Distributed lock blocks a cross-mount writer, then hands it off ----------
# mount 2 holds a file open for writing (holding the DLM lock on its path).
# An SMB put of the same file goes through mount 1 and must (a) block while
# mount 2 holds it and (b) succeed once mount 2 releases, leaving the SMB
# writer's payload on disk. smbclient gets a long client timeout (-t) so we are
# testing the lock handoff itself, not smbclient's own ~20s default timeout.
echo "==> 2. distributed lock: cross-mount write coordination"
dlmfile="dlm_coord.bin"
newdata="${WORK}/dlm_new.bin"
head -c 4096 /dev/urandom >"${newdata}"
# Hold the file open for writing on mount 2 via fd 9 -> holds the DLM lock.
exec 9>"${MOUNT2_SHARE}/${dlmfile}"
printf 'held-by-mount2' >&9
# Start the SMB write; record its real exit code when it returns. The subshell
# must NOT inherit fd 9 (9>&-): otherwise the SMB writer keeps the file open and
# waits on a DLM lock held by its own inherited descriptor, deadlocking the
# handoff this test is meant to exercise.
rm -f "${WORK}/dlm_put.rc"
(
smbclient "//${SMB_HOST}/${SMB_SHARE}" -p "${SMB_PORT}" \
-U "${SMB_USER}%${SMB_PASS}" -m SMB3 -t 120 \
-c "put ${newdata} ${dlmfile}" >/dev/null 2>&1
echo "$?" >"${WORK}/dlm_put.rc"
) 9>&- &
smb_bg=$!
sleep 4
if [[ ! -f "${WORK}/dlm_put.rc" ]]; then
pass "SMB write blocks while another mount holds the file open"
else
fail "SMB write returned early instead of blocking (rc=$(cat "${WORK}/dlm_put.rc"))"
fi
# Release mount 2's DLM lock; the blocked SMB write must now complete.
exec 9>&-
# Wait (bounded) for the SMB put to finish so a stuck handoff fails the test
# instead of hanging the suite.
put_rc="timeout"
for _ in $(seq 1 20); do
if [[ -f "${WORK}/dlm_put.rc" ]]; then
put_rc="$(cat "${WORK}/dlm_put.rc")"
break
fi
sleep 1
done
kill "${smb_bg}" 2>/dev/null
wait "${smb_bg}" 2>/dev/null
if [[ "${put_rc}" == "0" ]]; then
pass "blocked SMB write succeeds after the other mount releases"
else
fail "blocked SMB write succeeds after the other mount releases (rc=${put_rc})"
fi
# A correct handoff leaves the SMB writer's payload on disk: mount 1 acquired
# the lock and wrote after mount 2 released.
got="${WORK}/dlm_got.bin"
if smb "get ${dlmfile} ${got}" >/dev/null 2>&1 && [[ "$(md5 "${got}")" == "$(md5 "${newdata}")" ]]; then
pass "post-release content is the SMB writer's payload (correct handoff)"
else
fail "post-release content is the SMB writer's payload (correct handoff)"
fi
# 3. Distributed lock integrity: concurrent writers, same file ---------------
# An SMB writer (mount 1) and a direct writer (mount 2) race on one file. DLM
# serializes them, so the result must be exactly one of the two payloads.
echo "==> 3. distributed lock: concurrent writers produce intact data"
racefile="dlm_race.bin"
payloadA="${WORK}/dlm_raceA.bin"
head -c 1048576 /dev/urandom >"${payloadA}"
payloadB="direct-write-from-mount2-payload"
(smb "put ${payloadA} ${racefile}" >/dev/null 2>&1) &
(printf '%s' "${payloadB}" >"${MOUNT2_SHARE}/${racefile}") &
wait
racegot="${WORK}/dlm_race_got.bin"
if smb "get ${racefile} ${racegot}" >/dev/null 2>&1 &&
{ [[ "$(md5 "${racegot}")" == "$(md5 "${payloadA}")" ]] || [[ "$(cat "${racegot}")" == "${payloadB}" ]]; }; then
pass "concurrent same-file writers leave one intact payload"
else
fail "concurrent same-file writers leave one intact payload"
fi
# 4. Concurrent writers to distinct files ------------------------------------
echo "==> 4. concurrent writers to distinct files"
n=6
declare -a srcs=()
for i in $(seq 1 "${n}"); do
s="${WORK}/cc_${i}.bin"
head -c 1048576 /dev/urandom >"${s}"
srcs+=("${s}")
(smb "put ${s} concurrent_${i}.bin" >/dev/null 2>&1) &
done
wait
all_ok=true
for i in $(seq 1 "${n}"); do
g="${WORK}/cc_got_${i}.bin"
if ! smb "get concurrent_${i}.bin ${g}" >/dev/null 2>&1 ||
[[ "$(md5 "${srcs[$((i - 1))]}")" != "$(md5 "${g}")" ]]; then
all_ok=false
fi
done
if ${all_ok}; then
pass "${n} concurrent distinct-file writes all intact"
else
fail "${n} concurrent distinct-file writes all intact"
fi
echo
echo "==> Summary: ${PASS} passed, ${FAIL} failed"
[[ "${FAIL}" -eq 0 ]]
-203
View File
@@ -1,203 +0,0 @@
#!/usr/bin/env bash
#
# Run the SMB (Samba) integration test against a SeaweedFS FUSE mount.
#
# Pipeline:
# 1. start a self-contained "weed mini" (master + volume + filer in one)
# 2. mount the filesystem with "weed mount"
# 3. export a subdirectory of the mount over SMB with smbd
# 4. drive the share with smbclient (test/samba/smb_tests.sh)
#
# Everything runs as the current user on unprivileged ports, so no sudo is
# required. State lives under a temp work dir and is removed on exit.
#
# Requirements: weed in $PATH, fusermount3, and Samba's smbd / smbclient /
# smbpasswd (Debian/Ubuntu: apt-get install samba smbclient).
#
# Usage:
# test/samba/run.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEED_BIN="${WEED_BIN:-weed}"
WORK_DIR="${WORK_DIR:-$(mktemp -d /tmp/seaweedfs-samba.XXXXXX)}"
MOUNT_DIR="${MOUNT_DIR:-${WORK_DIR}/mnt}"
MOUNT2_DIR="${MOUNT2_DIR:-${WORK_DIR}/mnt2}"
DATA_DIR="${DATA_DIR:-${WORK_DIR}/data}"
LOG_DIR="${LOG_DIR:-${WORK_DIR}/logs}"
STATE_DIR="${WORK_DIR}/samba"
SHARE_DIR="${MOUNT_DIR}/share"
SHARE_DIR2="${MOUNT2_DIR}/share"
FILER_PORT="${FILER_PORT:-28888}"
FILER_ADDR="127.0.0.1:${FILER_PORT}"
SMB_PORT="${SMB_PORT:-4450}"
SMB_SHARE="seaweedfs"
SMB_USER="${SMB_USER:-$(id -un)}"
SMB_PASS="${SMB_PASS:-seaweedfs}"
SMBD_BIN="$(command -v smbd || echo /usr/sbin/smbd)"
SMBPASSWD_BIN="$(command -v smbpasswd || echo /usr/bin/smbpasswd)"
CI_LOG_DIR="/tmp/seaweedfs-samba-logs"
mini_pid=""
mount_pid=""
mount2_pid=""
smbd_pid=""
unmount_dir() {
local dir="$1"
if mountpoint -q "${dir}" 2>/dev/null; then
fusermount3 -u "${dir}" 2>/dev/null ||
fusermount -u "${dir}" 2>/dev/null || true
fi
}
cleanup() {
set +e
if [[ -n "${smbd_pid}" ]] && kill -0 "${smbd_pid}" 2>/dev/null; then
kill -TERM "${smbd_pid}" 2>/dev/null || true
wait "${smbd_pid}" 2>/dev/null || true
fi
for p in "${mount_pid}" "${mount2_pid}"; do
if [[ -n "${p}" ]] && kill -0 "${p}" 2>/dev/null; then
kill -TERM "${p}" 2>/dev/null || true
wait "${p}" 2>/dev/null || true
fi
done
unmount_dir "${MOUNT_DIR}"
unmount_dir "${MOUNT2_DIR}"
if [[ -n "${mini_pid}" ]] && kill -0 "${mini_pid}" 2>/dev/null; then
kill -TERM "${mini_pid}" 2>/dev/null || true
wait "${mini_pid}" 2>/dev/null || true
fi
# Copy logs to a fixed path for CI artifact upload.
mkdir -p "${CI_LOG_DIR}"
cp "${LOG_DIR}"/*.log "${LOG_DIR}"/*.out "${STATE_DIR}/smbd.log" "${CI_LOG_DIR}/" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
mkdir -p "${MOUNT_DIR}" "${MOUNT2_DIR}" "${DATA_DIR}" "${LOG_DIR}" \
"${STATE_DIR}/private" "${STATE_DIR}/state" "${STATE_DIR}/cache" \
"${STATE_DIR}/lock" "${STATE_DIR}/pid" "${STATE_DIR}/ncalrpc"
# --- 1. weed mini -----------------------------------------------------------
echo "==> Starting weed mini on ${FILER_ADDR}"
"${WEED_BIN}" mini \
-dir="${DATA_DIR}" \
-ip=127.0.0.1 \
-filer.port="${FILER_PORT}" \
-s3=false \
-webdav=false \
-admin.ui=false \
>"${LOG_DIR}/mini.log" 2>&1 &
mini_pid=$!
for i in $(seq 1 60); do
if (echo >"/dev/tcp/127.0.0.1/${FILER_PORT}") 2>/dev/null; then
break
fi
if ! kill -0 "${mini_pid}" 2>/dev/null; then
echo "weed mini exited early; log tail:" >&2
tail -n 100 "${LOG_DIR}/mini.log" >&2 || true
exit 1
fi
sleep 0.5
done
if ! (echo >"/dev/tcp/127.0.0.1/${FILER_PORT}") 2>/dev/null; then
echo "weed mini filer did not become reachable within 30s; log tail:" >&2
tail -n 100 "${LOG_DIR}/mini.log" >&2 || true
exit 1
fi
# --- 2. weed mount (two mounts, both with -dlm) -----------------------------
# mount_with_dlm <mountpoint> <logfile> <pid-var-name>
mount_with_dlm() {
local dir="$1" log="$2" pidvar="$3" pid
echo "==> Mounting SeaweedFS at ${dir} with -dlm"
"${WEED_BIN}" mount \
-filer="${FILER_ADDR}" \
-dir="${dir}" \
-filer.path=/ \
-dirAutoCreate \
-dlm \
>"${log}" 2>&1 &
pid=$!
printf -v "${pidvar}" '%s' "${pid}"
for _ in $(seq 1 60); do
if mountpoint -q "${dir}"; then
return 0
fi
if ! kill -0 "${pid}" 2>/dev/null; then
echo "weed mount (${dir}) exited early; log tail:" >&2
tail -n 100 "${log}" >&2 || true
exit 1
fi
sleep 0.5
done
echo "FUSE mount ${dir} did not come up within 30s" >&2
tail -n 100 "${log}" >&2 || true
exit 1
}
mount_with_dlm "${MOUNT_DIR}" "${LOG_DIR}/mount.log" mount_pid
mount_with_dlm "${MOUNT2_DIR}" "${LOG_DIR}/mount2.log" mount2_pid
mkdir -p "${SHARE_DIR}"
# --- 3. smbd ----------------------------------------------------------------
echo "==> Generating smb.conf and starting smbd on port ${SMB_PORT}"
SMB_CONF="${STATE_DIR}/smb.conf"
sed -e "s#@SHARE_PATH@#${SHARE_DIR}#g" \
-e "s#@STATE_DIR@#${STATE_DIR}#g" \
-e "s#@SMB_PORT@#${SMB_PORT}#g" \
-e "s#@FORCE_USER@#${SMB_USER}#g" \
"${SCRIPT_DIR}/smb.conf.template" >"${SMB_CONF}"
printf '%s\n%s\n' "${SMB_PASS}" "${SMB_PASS}" |
"${SMBPASSWD_BIN}" -c "${SMB_CONF}" -a -s "${SMB_USER}"
"${SMBD_BIN}" -F --no-process-group -s "${SMB_CONF}" >"${LOG_DIR}/smbd.out" 2>&1 &
smbd_pid=$!
for i in $(seq 1 60); do
if (echo >"/dev/tcp/127.0.0.1/${SMB_PORT}") 2>/dev/null; then
break
fi
if ! kill -0 "${smbd_pid}" 2>/dev/null; then
echo "smbd exited early; log tail:" >&2
tail -n 100 "${LOG_DIR}/smbd.out" "${STATE_DIR}/smbd.log" 2>/dev/null >&2 || true
exit 1
fi
sleep 0.5
done
if ! (echo >"/dev/tcp/127.0.0.1/${SMB_PORT}") 2>/dev/null; then
echo "smbd did not become reachable within 30s; log tail:" >&2
tail -n 100 "${LOG_DIR}/smbd.out" "${STATE_DIR}/smbd.log" 2>/dev/null >&2 || true
exit 1
fi
# --- 4. run the test batteries ---------------------------------------------
rc=0
echo "==> Running SMB functional test battery"
SMB_HOST=127.0.0.1 \
SMB_SHARE="${SMB_SHARE}" \
SMB_PORT="${SMB_PORT}" \
SMB_USER="${SMB_USER}" \
SMB_PASS="${SMB_PASS}" \
SHARE_FS_PATH="${SHARE_DIR}" \
"${SCRIPT_DIR}/smb_tests.sh" || rc=1
echo "==> Running SMB locking / concurrency test battery"
SMB_HOST=127.0.0.1 \
SMB_SHARE="${SMB_SHARE}" \
SMB_PORT="${SMB_PORT}" \
SMB_USER="${SMB_USER}" \
SMB_PASS="${SMB_PASS}" \
MOUNT_SHARE="${SHARE_DIR}" \
MOUNT2_SHARE="${SHARE_DIR2}" \
"${SCRIPT_DIR}/lock_tests.sh" || rc=1
exit "${rc}"
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
#
# Runs the SMB test batteries inside the samba container against the local smbd,
# which serves /mnt/seaweedfs/share over a SeaweedFS FUSE mount. A second FUSE
# mount (/mnt/seaweedfs2) backs the distributed-locking tests.
# Invoked via: docker compose exec samba /run_inside_container.sh
set -euo pipefail
export SMB_HOST=127.0.0.1
export SMB_SHARE=seaweedfs
export SMB_PORT="${SMB_PORT:-445}"
export SMB_USER="${SMB_USER:-smbtest}"
export SMB_PASS="${SMB_PASS:-smbtest}"
export SHARE_FS_PATH="${SHARE_FS_PATH:-/mnt/seaweedfs/share}"
export MOUNT_SHARE="${MOUNT_SHARE:-/mnt/seaweedfs/share}"
export MOUNT2_SHARE="${MOUNT2_SHARE:-/mnt/seaweedfs2/share}"
rc=0
echo "############ SMB functional tests ############"
/smb_tests.sh || rc=1
echo
echo "############ SMB locking / concurrency tests ############"
/lock_tests.sh || rc=1
exit "${rc}"
-52
View File
@@ -1,52 +0,0 @@
[global]
server role = standalone server
workgroup = WORKGROUP
server string = SeaweedFS FUSE Samba test
security = user
server min protocol = SMB2
smb ports = @SMB_PORT@
bind interfaces only = yes
interfaces = lo 127.0.0.1
# Self-contained state so smbd can run rootless and leaves nothing behind
# outside the test work directory.
private dir = @STATE_DIR@/private
state directory = @STATE_DIR@/state
cache directory = @STATE_DIR@/cache
lock directory = @STATE_DIR@/lock
pid directory = @STATE_DIR@/pid
ncalrpc dir = @STATE_DIR@/ncalrpc
log file = @STATE_DIR@/smbd.log
log level = 1
usershare max shares = 0
# No printing subsystem in a file-server test.
load printers = no
printing = bsd
printcap name = /dev/null
disable spoolss = yes
# The SeaweedFS FUSE mount does not implement the full xattr / DOS-attribute
# surface Samba uses by default. Disabling these avoids spurious
# NT_STATUS_NOT_SUPPORTED / EOPNOTSUPP errors unrelated to data integrity.
ea support = no
store dos attributes = no
map archive = no
map hidden = no
map system = no
map readonly = no
# A network-filesystem backend should not advertise local oplocks/leases.
oplocks = no
level2 oplocks = no
kernel oplocks = no
posix locking = no
[seaweedfs]
path = @SHARE_PATH@
comment = SeaweedFS share backed by a FUSE mount
browseable = yes
read only = no
create mask = 0644
directory mask = 0755
force user = @FORCE_USER@
-172
View File
@@ -1,172 +0,0 @@
#!/usr/bin/env bash
#
# SMB protocol test battery against a Samba share backed by a SeaweedFS FUSE
# mount. Driven both by the local runner (test/samba/run.sh) and by the Docker
# harness (run_inside_container.sh).
#
# Required env:
# SMB_USER samba username
# SMB_PASS samba password
# Optional env:
# SMB_HOST samba host (default 127.0.0.1)
# SMB_SHARE share name (default seaweedfs)
# SMB_PORT smbd port (default 445)
# SHARE_FS_PATH directory on the FUSE mount that backs the share. When set,
# the suite also checks cross-protocol consistency: data written
# over SMB is visible on the FUSE mount, and vice versa.
set -uo pipefail
SMB_HOST="${SMB_HOST:-127.0.0.1}"
SMB_SHARE="${SMB_SHARE:-seaweedfs}"
SMB_PORT="${SMB_PORT:-445}"
SMB_USER="${SMB_USER:?SMB_USER is required}"
SMB_PASS="${SMB_PASS:?SMB_PASS is required}"
SHARE_FS_PATH="${SHARE_FS_PATH:-}"
WORK="$(mktemp -d /tmp/samba-smbtest.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
PASS=0
FAIL=0
pass() { printf ' [PASS] %s\n' "$1"; PASS=$((PASS + 1)); }
fail() { printf ' [FAIL] %s\n' "$1"; FAIL=$((FAIL + 1)); }
# Run one or more smbclient commands (separated by ';') against the share.
smb() {
smbclient "//${SMB_HOST}/${SMB_SHARE}" -p "${SMB_PORT}" \
-U "${SMB_USER}%${SMB_PASS}" -m SMB3 -c "$1"
}
md5() { md5sum "$1" | awk '{print $1}'; }
echo "==> Target //${SMB_HOST}/${SMB_SHARE} (port ${SMB_PORT}) as ${SMB_USER}"
[[ -n "${SHARE_FS_PATH}" ]] && echo "==> Cross-protocol checks against ${SHARE_FS_PATH}"
# 1. Connectivity ------------------------------------------------------------
echo "==> 1. connectivity"
if smb "ls" >/dev/null 2>&1; then
pass "connect and list share root"
else
fail "connect and list share root"
fi
# 2. Upload / download round-trip -------------------------------------------
echo "==> 2. upload / download round-trip"
src="${WORK}/src.bin"
head -c 1048576 /dev/urandom >"${src}" # 1 MiB
if smb "put ${src} roundtrip.bin" >/dev/null 2>&1; then
pass "put 1 MiB file"
else
fail "put 1 MiB file"
fi
got="${WORK}/got.bin"
if smb "get roundtrip.bin ${got}" >/dev/null 2>&1 && [[ "$(md5 "${src}")" == "$(md5 "${got}")" ]]; then
pass "get returns identical content"
else
fail "get returns identical content"
fi
if [[ -n "${SHARE_FS_PATH}" ]]; then
if [[ -f "${SHARE_FS_PATH}/roundtrip.bin" ]] && [[ "$(md5 "${SHARE_FS_PATH}/roundtrip.bin")" == "$(md5 "${src}")" ]]; then
pass "SMB-written file visible on FUSE mount with identical content"
else
fail "SMB-written file visible on FUSE mount with identical content"
fi
fi
# 3. Directory operations ----------------------------------------------------
echo "==> 3. directory operations"
if smb "mkdir docs; cd docs; put ${src} nested.bin; ls" >/dev/null 2>&1; then
pass "mkdir + put into subdirectory"
else
fail "mkdir + put into subdirectory"
fi
if [[ -z "${SHARE_FS_PATH}" || -f "${SHARE_FS_PATH}/docs/nested.bin" ]]; then
pass "nested file present"
else
fail "nested file present"
fi
# 4. Rename ------------------------------------------------------------------
echo "==> 4. rename"
if smb "rename roundtrip.bin renamed.bin" >/dev/null 2>&1; then
pass "rename file"
else
fail "rename file"
fi
renback="${WORK}/renamed.bin"
if smb "get renamed.bin ${renback}" >/dev/null 2>&1 && [[ "$(md5 "${renback}")" == "$(md5 "${src}")" ]]; then
pass "renamed file readable with original content"
else
fail "renamed file readable with original content"
fi
if [[ -n "${SHARE_FS_PATH}" ]]; then
if [[ -f "${SHARE_FS_PATH}/renamed.bin" && ! -e "${SHARE_FS_PATH}/roundtrip.bin" ]]; then
pass "rename reflected on FUSE mount"
else
fail "rename reflected on FUSE mount"
fi
fi
# 5. Large file (exercises SeaweedFS chunking) -------------------------------
echo "==> 5. large file (SeaweedFS chunking)"
big="${WORK}/big.bin"
head -c 67108864 /dev/urandom >"${big}" # 64 MiB
bigback="${WORK}/big.back"
if smb "put ${big} big.bin" >/dev/null 2>&1 &&
smb "get big.bin ${bigback}" >/dev/null 2>&1 &&
[[ "$(md5 "${big}")" == "$(md5 "${bigback}")" ]]; then
pass "64 MiB put/get round-trip"
else
fail "64 MiB put/get round-trip"
fi
# 6. Recursive upload --------------------------------------------------------
echo "==> 6. recursive upload"
tree="${WORK}/tree"
mkdir -p "${tree}/a/b"
echo one >"${tree}/f1.txt"
echo two >"${tree}/a/f2.txt"
echo three >"${tree}/a/b/f3.txt"
if (cd "${WORK}" && smb "recurse ON; prompt OFF; mput tree" >/dev/null 2>&1) &&
{ [[ -z "${SHARE_FS_PATH}" ]] || [[ -f "${SHARE_FS_PATH}/tree/a/b/f3.txt" ]]; }; then
pass "recursive mput"
else
fail "recursive mput"
fi
# 7. Cross-protocol read (FUSE writes, SMB reads) ----------------------------
if [[ -n "${SHARE_FS_PATH}" ]]; then
echo "==> 7. cross-protocol read (FUSE write -> SMB read)"
echo "written-via-fuse" >"${SHARE_FS_PATH}/from_fuse.txt"
cpb="${WORK}/from_fuse.back"
if smb "get from_fuse.txt ${cpb}" >/dev/null 2>&1 && grep -q written-via-fuse "${cpb}"; then
pass "FUSE-written file readable over SMB"
else
fail "FUSE-written file readable over SMB"
fi
fi
# 8. Delete ------------------------------------------------------------------
echo "==> 8. delete"
smb "del renamed.bin" >/dev/null 2>&1
smb "del big.bin" >/dev/null 2>&1
smb "deltree docs" >/dev/null 2>&1
smb "deltree tree" >/dev/null 2>&1
if [[ -n "${SHARE_FS_PATH}" ]]; then
if [[ ! -e "${SHARE_FS_PATH}/renamed.bin" && ! -e "${SHARE_FS_PATH}/big.bin" &&
! -e "${SHARE_FS_PATH}/docs" && ! -e "${SHARE_FS_PATH}/tree" ]]; then
pass "delete files and directory trees"
else
fail "delete files and directory trees"
fi
else
if ! smb "get renamed.bin /dev/null" >/dev/null 2>&1; then
pass "deleted file no longer retrievable"
else
fail "deleted file no longer retrievable"
fi
fi
echo
echo "==> Summary: ${PASS} passed, ${FAIL} failed"
[[ "${FAIL}" -eq 0 ]]
+5 -12
View File
@@ -51,13 +51,6 @@ func MustAllocatePorts(t *testing.T, count int) []int {
// from recycling ports between allocations. Use this when ports will be
// passed to weed mini without explicit gRPC port flags, so mini will
// derive gRPC ports as HTTP + 10000.
//
// Listeners are bound on all interfaces (":port") rather than 127.0.0.1
// to match weed mini's availability check (isPortAvailable). A port can
// be free on loopback but held by another process on a different
// interface; reserving only on loopback lets mini's check fail and
// trigger gRPC port shifting, which then causes weed shell to dial the
// wrong port and hang.
func AllocateMiniPorts(count int) ([]int, error) {
const (
minPort = 10000
@@ -82,12 +75,12 @@ func AllocateMiniPorts(count int) ([]int, error) {
continue
}
l1, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
l1, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
continue
}
l2, err := net.Listen("tcp", fmt.Sprintf(":%d", grpcPort))
l2, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", grpcPort))
if err != nil {
l1.Close()
continue
@@ -152,11 +145,11 @@ func AllocatePortSet(miniCount, regularCount int) (mini []int, regular []int, er
if reserved[port] || reserved[grpcPort] {
continue
}
l1, lErr := net.Listen("tcp", fmt.Sprintf(":%d", port))
l1, lErr := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if lErr != nil {
continue
}
l2, lErr := net.Listen("tcp", fmt.Sprintf(":%d", grpcPort))
l2, lErr := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", grpcPort))
if lErr != nil {
l1.Close()
continue
@@ -175,7 +168,7 @@ func AllocatePortSet(miniCount, regularCount int) (mini []int, regular []int, er
regular = make([]int, 0, regularCount)
for i := 0; i < regularCount; i++ {
l, lErr := net.Listen("tcp", ":0")
l, lErr := net.Listen("tcp", "127.0.0.1:0")
if lErr != nil {
return nil, nil, lErr
}
-36
View File
@@ -184,38 +184,6 @@ func (c *Cluster) Stop() {
})
}
// RestartVolumeServer kills the volume server and starts a new one against
// the same data dirs and ports. The master keeps running. The previous run's
// volume.log is moved to volume.log.previous so the first run's logs survive
// a second-run startup failure.
func (c *Cluster) RestartVolumeServer() {
c.testingTB.Helper()
stopProcess(c.volumeCmd)
c.volumeCmd = nil
// Rotate the log; absent on the first call after a clean start, which
// is fine — startVolume will create it. Any real filesystem failure
// surfaces immediately on the next os.Create in startVolume.
oldLog := filepath.Join(c.logsDir, "volume.log")
_ = os.Rename(oldLog, filepath.Join(c.logsDir, "volume.log.previous"))
if err := c.startVolume(c.volumeDataDirs); err != nil {
c.testingTB.Fatalf("restart volume server: %v", err)
}
if err := c.waitForHTTP(c.VolumeAdminURL() + "/status"); err != nil {
c.testingTB.Fatalf("wait for volume admin readiness after restart: %v\nvolume log tail:\n%s", err, c.tailLog("volume.log"))
}
if err := c.waitForTCP(c.VolumeGRPCAddress()); err != nil {
c.testingTB.Fatalf("wait for volume grpc readiness after restart: %v\nvolume log tail:\n%s", err, c.tailLog("volume.log"))
}
}
// StopVolumeServer kills the volume server but leaves the master and data
// dirs alone. Pair with RestartVolumeServer or Stop.
func (c *Cluster) StopVolumeServer() {
c.testingTB.Helper()
stopProcess(c.volumeCmd)
c.volumeCmd = nil
}
func (c *Cluster) startMaster(dataDir string) error {
logFile, err := os.Create(filepath.Join(c.logsDir, "master.log"))
if err != nil {
@@ -264,10 +232,6 @@ func (c *Cluster) startVolume(dataDirs []string) error {
"-readMode=" + c.profile.ReadMode,
"-concurrentUploadLimitMB=" + strconv.Itoa(c.profile.ConcurrentUploadLimitMB),
"-concurrentDownloadLimitMB=" + strconv.Itoa(c.profile.ConcurrentDownloadLimitMB),
// Integration tests deliberately exercise loopback S3 endpoints
// (the test rig boots weed-mini next to the volume server); allow
// the SSRF guard to be bypassed for them.
"-volume.allowUntrustedRemoteEndpoints",
}
if c.profile.InflightUploadTimeout > 0 {
args = append(args, "-inflightUploadDataTimeout="+c.profile.InflightUploadTimeout.String())
@@ -256,8 +256,6 @@ func (c *MixedVolumeCluster) startGoVolume(index int, dataDir string) error {
"-readMode=" + c.profile.ReadMode,
"-concurrentUploadLimitMB=" + strconv.Itoa(c.profile.ConcurrentUploadLimitMB),
"-concurrentDownloadLimitMB=" + strconv.Itoa(c.profile.ConcurrentDownloadLimitMB),
// Integration tests deliberately exercise loopback S3 endpoints; allow the SSRF guard to be bypassed for them.
"-volume.allowUntrustedRemoteEndpoints",
}
if c.profile.InflightUploadTimeout > 0 {
args = append(args, "-inflightUploadDataTimeout="+c.profile.InflightUploadTimeout.String())
@@ -227,8 +227,6 @@ func (c *MultiVolumeCluster) startVolume(index int, dataDir string) error {
"-readMode=" + c.profile.ReadMode,
"-concurrentUploadLimitMB=" + strconv.Itoa(c.profile.ConcurrentUploadLimitMB),
"-concurrentDownloadLimitMB=" + strconv.Itoa(c.profile.ConcurrentDownloadLimitMB),
// Integration tests deliberately exercise loopback S3 endpoints; allow the SSRF guard to be bypassed for them.
"-volume.allowUntrustedRemoteEndpoints",
}
if c.profile.InflightUploadTimeout > 0 {
args = append(args, "-inflightUploadDataTimeout="+c.profile.InflightUploadTimeout.String())
+23 -38
View File
@@ -8,9 +8,6 @@ import (
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/cluster"
@@ -324,18 +321,15 @@ func TestPingVolumeTargetAndLeaveAffectsHealthz(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Volume servers only track their masters as ping peers, so drive the
// success path through the master target and rely on the master->volume
// admission for cross-type coverage.
pingResp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
TargetType: cluster.MasterType,
Target: clusterHarness.MasterAddress(),
TargetType: cluster.VolumeServerType,
Target: clusterHarness.VolumeServerAddress(),
})
if err != nil {
t.Fatalf("Ping master from volume server failed: %v", err)
t.Fatalf("Ping target volume server failed: %v", err)
}
if pingResp.GetRemoteTimeNs() == 0 {
t.Fatalf("expected remote timestamp from ping master")
t.Fatalf("expected remote timestamp from ping target volume server")
}
if _, err = grpcClient.VolumeServerLeave(ctx, &volume_server_pb.VolumeServerLeaveRequest{}); err != nil {
@@ -405,21 +399,20 @@ func TestPingUnknownAndUnreachableTargetPaths(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// The volume server gates Ping on a known peer list. Unknown target types
// and addresses outside that list are rejected with InvalidArgument
// before any outbound dial is attempted.
_, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
unknownResp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
TargetType: "unknown-type",
Target: "127.0.0.1:12345",
})
if err == nil {
t.Fatalf("Ping unknown target type should be rejected by admission")
if err != nil {
t.Fatalf("Ping unknown target type should not return grpc error, got: %v", err)
}
if got := status.Code(err); got != codes.InvalidArgument {
t.Fatalf("Ping unknown target type expected InvalidArgument, got %s: %v", got, err)
if unknownResp.GetRemoteTimeNs() != 0 {
t.Fatalf("Ping unknown target type expected remote_time_ns=0, got %d", unknownResp.GetRemoteTimeNs())
}
if unknownResp.GetStopTimeNs() < unknownResp.GetStartTimeNs() {
t.Fatalf("Ping unknown target type expected stop_time_ns >= start_time_ns")
}
// Empty target stays as an unauthenticated self-liveness probe.
emptyTargetResp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{})
if err != nil {
t.Fatalf("Ping empty target should not return grpc error, got: %v", err)
@@ -431,30 +424,26 @@ func TestPingUnknownAndUnreachableTargetPaths(t *testing.T) {
t.Fatalf("Ping empty target expected stop_time_ns >= start_time_ns")
}
// 127.0.0.1:1 is not in the seed/current master set, so admission rejects
// the call before it can reach the network.
_, err = grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
TargetType: cluster.MasterType,
Target: "127.0.0.1:1",
})
if err == nil {
t.Fatalf("Ping master target should be rejected when not in the known-peer set")
t.Fatalf("Ping master target should fail when target is unreachable")
}
if got := status.Code(err); got != codes.InvalidArgument {
t.Fatalf("Ping unknown master expected InvalidArgument, got %s: %v", got, err)
if !strings.Contains(err.Error(), "ping master") {
t.Fatalf("Ping master unreachable error mismatch: %v", err)
}
// Volume servers do not carry a peer-filer list at all; any filer target
// is rejected at admission regardless of reachability.
_, err = grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
TargetType: cluster.FilerType,
Target: "127.0.0.1:1",
})
if err == nil {
t.Fatalf("Ping filer target should be rejected by admission")
t.Fatalf("Ping filer target should fail when target is unreachable")
}
if got := status.Code(err); got != codes.InvalidArgument {
t.Fatalf("Ping filer expected InvalidArgument, got %s: %v", got, err)
if !strings.Contains(err.Error(), "ping filer") {
t.Fatalf("Ping filer unreachable error mismatch: %v", err)
}
}
@@ -490,10 +479,6 @@ func TestPingFilerTargetSuccess(t *testing.T) {
t.Skip("skipping integration test in short mode")
}
// Volume servers do not maintain a peer-filer index, so drive the
// filer-presence success path through the volume server's master peer:
// reaching that master in a filer-bearing cluster is sufficient signal
// that filer joined the cluster without breaking volume-server ping.
clusterHarness := framework.StartSingleVolumeClusterWithFiler(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
@@ -502,16 +487,16 @@ func TestPingFilerTargetSuccess(t *testing.T) {
defer cancel()
resp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
TargetType: cluster.MasterType,
Target: clusterHarness.MasterAddress(),
TargetType: cluster.FilerType,
Target: clusterHarness.FilerServerAddress(),
})
if err != nil {
t.Fatalf("Ping master from filer-bearing cluster failed: %v", err)
t.Fatalf("Ping filer target success path failed: %v", err)
}
if resp.GetRemoteTimeNs() == 0 {
t.Fatalf("Ping master expected non-zero remote time")
t.Fatalf("Ping filer target expected non-zero remote time")
}
if resp.GetStopTimeNs() < resp.GetStartTimeNs() {
t.Fatalf("Ping master expected stop >= start, got start=%d stop=%d", resp.GetStartTimeNs(), resp.GetStopTimeNs())
t.Fatalf("Ping filer target expected stop >= start, got start=%d stop=%d", resp.GetStartTimeNs(), resp.GetStopTimeNs())
}
}
@@ -1,515 +0,0 @@
package volume_server_grpc_test
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
)
// TestEcLifecycleAcrossMultipleDisks drives encode, mount, read, drop-dat,
// cross-disk redistribute + restart + reconcile, blob delete, and rebuild on
// a 2-disk volume server.
func TestEcLifecycleAcrossMultipleDisks(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
const (
dataDirCount = 2
volumeID = uint32(9500)
collection = "ec-multi-disk"
)
clusterHarness := framework.StartSingleVolumeClusterWithDataDirs(t, matrix.P1(), dataDirCount)
dataDirs := clusterHarness.VolumeDataDirs()
if len(dataDirs) != dataDirCount {
t.Fatalf("expected %d data dirs, got %d: %v", dataDirCount, len(dataDirs), dataDirs)
}
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
framework.AllocateVolume(t, grpcClient, volumeID, collection)
// populate
type needleSpec struct {
fid string
payload []byte
}
needles := []needleSpec{
{framework.NewFileID(volumeID, 9501, 0xAA000001), bytesOfLen(11, 0xA1)},
{framework.NewFileID(volumeID, 9502, 0xAA000002), bytesOfLen(1024, 0xA2)},
{framework.NewFileID(volumeID, 9503, 0xAA000003), bytesOfLen(63*1024+17, 0xA3)},
{framework.NewFileID(volumeID, 9504, 0xAA000004), bytesOfLen(2, 0xA4)},
{framework.NewFileID(volumeID, 9505, 0xAA000005), bytesOfLen(513*1024, 0xA5)},
}
httpClient := framework.NewHTTPClient()
for _, n := range needles {
resp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload)
_ = framework.ReadAllAndClose(t, resp)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("upload %s expected 201, got %d", n.fid, resp.StatusCode)
}
}
// encode — every shard lands on the .dat's disk
if _, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID,
Collection: collection,
}); err != nil {
t.Fatalf("VolumeEcShardsGenerate: %v", err)
}
postGenerateLayout := scanShardLayout(t, dataDirs, collection, volumeID)
if len(postGenerateLayout[0]) != erasure_coding.TotalShardsCount {
t.Fatalf("post-generate: expected all %d shards on disk 0, got per-disk layout %v",
erasure_coding.TotalShardsCount, postGenerateLayout)
}
if len(postGenerateLayout[1]) != 0 {
t.Fatalf("post-generate: disk 1 should be empty, got %v", postGenerateLayout[1])
}
// .ecj is created lazily on first EC delete
for _, side := range []string{".ecx", ".vif"} {
if !fileExistsIn(dataDirs[0], collection, volumeID, side) {
t.Fatalf("post-generate: expected %s on disk 0", side)
}
}
// mount + read via EC
if _, err := grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID,
Collection: collection,
ShardIds: dataShardIds(),
}); err != nil {
t.Fatalf("VolumeEcShardsMount data shards: %v", err)
}
for _, n := range needles {
verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload, "after-mount")
}
// drop the original volume so EC shards are the only source
if _, err := grpcClient.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{
VolumeId: volumeID,
}); err != nil {
t.Fatalf("VolumeDelete (drop .dat): %v", err)
}
if fileExistsIn(dataDirs[0], collection, volumeID, ".dat") {
t.Fatalf("VolumeDelete left .dat in place on disk 0")
}
for _, n := range needles {
verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload, "after-dat-delete")
}
// move half the shards onto disk 1, leaving .ecx on disk 0
clusterHarness.StopVolumeServer()
const splitAt = 7
for shard := 0; shard < splitAt; shard++ {
movedFile(t, dataDirs[0], dataDirs[1], collection, volumeID, erasure_coding.ToExt(shard))
}
postMoveLayout := scanShardLayout(t, dataDirs, collection, volumeID)
if len(postMoveLayout[0]) != erasure_coding.TotalShardsCount-splitAt {
t.Fatalf("post-move: expected %d shards on disk 0, got %v", erasure_coding.TotalShardsCount-splitAt, postMoveLayout[0])
}
if len(postMoveLayout[1]) != splitAt {
t.Fatalf("post-move: expected %d shards on disk 1, got %v", splitAt, postMoveLayout[1])
}
if !fileExistsIn(dataDirs[0], collection, volumeID, ".ecx") {
t.Fatalf("post-move: .ecx must stay on disk 0 to exercise the cross-disk reconcile path")
}
if fileExistsIn(dataDirs[1], collection, volumeID, ".ecx") {
t.Fatalf("post-move: .ecx leaked onto disk 1; reconcile path would not be exercised")
}
clusterHarness.RestartVolumeServer()
conn2, grpcClient2 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn2.Close()
postReconcileLayout := scanShardLayout(t, dataDirs, collection, volumeID)
if got, want := totalShardsInLayout(postReconcileLayout), erasure_coding.TotalShardsCount; got != want {
t.Fatalf("post-reconcile: total shards on disk mismatch: got %d, want %d (layout=%v)", got, want, postReconcileLayout)
}
if got, want := len(postReconcileLayout[0]), erasure_coding.TotalShardsCount-splitAt; got != want {
t.Fatalf("post-reconcile: disk 0 shard count drift: got %d, want %d (layout=%v)", got, want, postReconcileLayout)
}
if got, want := len(postReconcileLayout[1]), splitAt; got != want {
t.Fatalf("post-reconcile: disk 1 shard count drift: got %d, want %d (layout=%v)", got, want, postReconcileLayout)
}
// VolumeEcShardsInfo must walk every DiskLocation and report the full
// shard set — the verification step in ec_task.go gates source-volume
// deletion on this RPC returning a complete shard inventory.
infoResp, err := grpcClient2.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("VolumeEcShardsInfo after redistribute restart: %v", err)
}
if got, want := len(infoResp.GetEcShardInfos()), erasure_coding.TotalShardsCount; got != want {
t.Fatalf("VolumeEcShardsInfo after redistribute restart: got %d shards, want %d (per-disk layout=%v)",
got, want, postReconcileLayout)
}
gotShardIds := make(map[uint32]struct{}, len(infoResp.GetEcShardInfos()))
for _, info := range infoResp.GetEcShardInfos() {
gotShardIds[info.GetShardId()] = struct{}{}
}
for shardId := uint32(0); shardId < uint32(erasure_coding.TotalShardsCount); shardId++ {
if _, ok := gotShardIds[shardId]; !ok {
t.Fatalf("VolumeEcShardsInfo missing shard %d (per-disk layout=%v)", shardId, postReconcileLayout)
}
}
for _, n := range needles {
verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload, "after-cross-disk-reconcile")
}
// blob delete on a cross-disk layout
deletedNeedle := needles[2]
deleteReq, err := http.NewRequest(http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+deletedNeedle.fid, nil)
if err != nil {
t.Fatalf("build delete request: %v", err)
}
deleteResp := framework.DoRequest(t, httpClient, deleteReq)
_ = framework.ReadAllAndClose(t, deleteResp)
if deleteResp.StatusCode != http.StatusAccepted {
t.Fatalf("HTTP DELETE %s expected 202, got %d", deletedNeedle.fid, deleteResp.StatusCode)
}
readResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), deletedNeedle.fid)
_ = framework.ReadAllAndClose(t, readResp)
if readResp.StatusCode == http.StatusOK {
t.Fatalf("HTTP GET on deleted EC needle expected non-200, got %d", readResp.StatusCode)
}
for _, n := range needles {
if n.fid == deletedNeedle.fid {
continue
}
verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload, "after-blob-delete")
}
// rebuild a shard whose inputs are split across two disks
clusterHarness.StopVolumeServer()
const repairTargetShard = 9 // sits on disk 0 after the split
shardPath := filepath.Join(dataDirs[0], shardFileName(collection, volumeID, repairTargetShard))
if _, statErr := os.Stat(shardPath); statErr != nil {
t.Fatalf("repair setup: shard %d not on disk 0 (%s): %v", repairTargetShard, shardPath, statErr)
}
if err := os.Remove(shardPath); err != nil {
t.Fatalf("repair setup: remove shard %d on disk 0: %v", repairTargetShard, err)
}
clusterHarness.RestartVolumeServer()
conn3, grpcClient3 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn3.Close()
rebuildResp, err := grpcClient3.VolumeEcShardsRebuild(ctx, &volume_server_pb.VolumeEcShardsRebuildRequest{
VolumeId: volumeID,
Collection: collection,
})
if err != nil {
t.Fatalf("VolumeEcShardsRebuild after cross-disk shard loss: %v", err)
}
if !containsUint32(rebuildResp.GetRebuiltShardIds(), repairTargetShard) {
t.Fatalf("VolumeEcShardsRebuild expected to rebuild shard %d, got %v",
repairTargetShard, rebuildResp.GetRebuiltShardIds())
}
// The rebuilder picks whichever disk hosts the most shards plus a
// matching .ecx. After the boot-time mirror runs, every shard-
// bearing disk owns its own sidecars, so rebuild can legitimately
// restore the shard on either disk — accept both.
rebuiltOn := -1
for i, dir := range dataDirs {
candidate := filepath.Join(dir, shardFileName(collection, volumeID, repairTargetShard))
if _, statErr := os.Stat(candidate); statErr == nil {
rebuiltOn = i
shardPath = candidate
break
}
}
if rebuiltOn < 0 {
t.Fatalf("rebuild did not restore shard %d on any disk (checked %v)", repairTargetShard, dataDirs)
}
if _, err := grpcClient3.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID,
Collection: collection,
ShardIds: []uint32{repairTargetShard},
}); err != nil {
t.Fatalf("VolumeEcShardsMount rebuilt shard %d: %v", repairTargetShard, err)
}
for _, n := range needles {
if n.fid == deletedNeedle.fid {
continue
}
verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload, "after-repair")
}
}
// TestEcPartialShardsOnSiblingDiskCleanedUpOnRestart seeds a healthy .dat on
// disk 0, plants the on-disk footprint of an interrupted EC encode on disk 1
// (one shard plus .ecx / .ecj / .vif, no .dat), and asserts the startup prune
// wipes disk 1 without touching disk 0.
func TestEcPartialShardsOnSiblingDiskCleanedUpOnRestart(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
const (
dataDirCount = 2
volumeID = uint32(9501)
collection = "ec-9478"
partialShard = 1
)
clusterHarness := framework.StartSingleVolumeClusterWithDataDirs(t, matrix.P1(), dataDirCount)
dataDirs := clusterHarness.VolumeDataDirs()
if len(dataDirs) != dataDirCount {
t.Fatalf("expected %d data dirs, got %d: %v", dataDirCount, len(dataDirs), dataDirs)
}
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
framework.AllocateVolume(t, grpcClient, volumeID, collection)
httpClient := framework.NewHTTPClient()
needleFid := framework.NewFileID(volumeID, 7777, 0xCAFEBABE)
needlePayload := bytesOfLen(4096, 0xC7)
upResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), needleFid, needlePayload)
_ = framework.ReadAllAndClose(t, upResp)
if upResp.StatusCode != http.StatusCreated {
t.Fatalf("seed upload expected 201, got %d", upResp.StatusCode)
}
datPath := filepath.Join(dataDirs[0], datFileName(collection, volumeID))
datInfo, err := os.Stat(datPath)
if err != nil {
t.Fatalf("seed setup: .dat must exist on disk 0 (%s): %v", datPath, err)
}
// prune's size guard refuses cleanup if the sibling .dat looks truncated
if datInfo.Size() == 0 {
t.Fatalf("seed setup: .dat on disk 0 is zero bytes")
}
clusterHarness.StopVolumeServer()
plantPartialEc(t, dataDirs[1], collection, volumeID, partialShard, datInfo.Size())
preRestartLayout := scanShardLayout(t, dataDirs, collection, volumeID)
if len(preRestartLayout[0]) != 0 {
t.Fatalf("seed setup: disk 0 should hold no EC shards before restart, got %v", preRestartLayout[0])
}
if len(preRestartLayout[1]) != 1 {
t.Fatalf("seed setup: disk 1 should hold exactly one planted shard, got %v", preRestartLayout[1])
}
clusterHarness.RestartVolumeServer()
conn2, grpcClient2 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn2.Close()
// the volume admin port answers before the startup prune finishes
postPruneLayout := waitForLayout(t, dataDirs, collection, volumeID, func(layout map[int][]int) bool {
return len(layout[1]) == 0
}, 10*time.Second)
if len(postPruneLayout[1]) != 0 {
t.Fatalf("partial EC shards survived restart on disk 1: %v", postPruneLayout[1])
}
// .vif is intentionally left behind: with no .ecx / shard to anchor it,
// the next startup ignores it.
for _, side := range []string{".ecx", ".ecj"} {
if fileExistsIn(dataDirs[1], collection, volumeID, side) {
t.Fatalf("%s survived prune on disk 1", side)
}
}
if !fileExistsIn(dataDirs[0], collection, volumeID, ".dat") {
t.Fatalf("prune wiped the healthy .dat on disk 0")
}
if !fileExistsIn(dataDirs[0], collection, volumeID, ".idx") {
t.Fatalf("prune wiped the healthy .idx on disk 0")
}
if _, err = grpcClient2.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{
VolumeId: volumeID,
}); err == nil {
t.Fatalf("VolumeEcShardsInfo returned success after prune; the partial EC mount should be gone")
}
verifyHTTPRead(t, httpClient, clusterHarness.VolumeAdminURL(), needleFid, needlePayload, "after-prune")
}
func bytesOfLen(n int, seed byte) []byte {
out := make([]byte, n)
for i := range out {
out[i] = byte(int(seed) + i*31)
}
return out
}
func dataShardIds() []uint32 {
ids := make([]uint32, erasure_coding.DataShardsCount)
for i := range ids {
ids[i] = uint32(i)
}
return ids
}
func datFileName(collection string, volumeID uint32) string {
if collection == "" {
return fmt.Sprintf("%d.dat", volumeID)
}
return fmt.Sprintf("%s_%d.dat", collection, volumeID)
}
func shardFileName(collection string, volumeID uint32, shardID int) string {
if collection == "" {
return fmt.Sprintf("%d%s", volumeID, erasure_coding.ToExt(shardID))
}
return fmt.Sprintf("%s_%d%s", collection, volumeID, erasure_coding.ToExt(shardID))
}
func sideFileName(collection string, volumeID uint32, ext string) string {
if collection == "" {
return fmt.Sprintf("%d%s", volumeID, ext)
}
return fmt.Sprintf("%s_%d%s", collection, volumeID, ext)
}
func fileExistsIn(dir, collection string, volumeID uint32, ext string) bool {
_, err := os.Stat(filepath.Join(dir, sideFileName(collection, volumeID, ext)))
return err == nil
}
func scanShardLayout(t testing.TB, dataDirs []string, collection string, volumeID uint32) map[int][]int {
t.Helper()
out := make(map[int][]int, len(dataDirs))
for diskIdx, dir := range dataDirs {
out[diskIdx] = nil
for shard := 0; shard < erasure_coding.TotalShardsCount; shard++ {
if _, err := os.Stat(filepath.Join(dir, shardFileName(collection, volumeID, shard))); err == nil {
out[diskIdx] = append(out[diskIdx], shard)
}
}
sort.Ints(out[diskIdx])
}
return out
}
func waitForLayout(t testing.TB, dataDirs []string, collection string, volumeID uint32, pred func(map[int][]int) bool, timeout time.Duration) map[int][]int {
t.Helper()
deadline := time.Now().Add(timeout)
var layout map[int][]int
for time.Now().Before(deadline) {
layout = scanShardLayout(t, dataDirs, collection, volumeID)
if pred(layout) {
return layout
}
time.Sleep(100 * time.Millisecond)
}
return layout
}
func movedFile(t testing.TB, fromDir, toDir, collection string, volumeID uint32, ext string) {
t.Helper()
name := sideFileName(collection, volumeID, ext)
src := filepath.Join(fromDir, name)
dst := filepath.Join(toDir, name)
if err := os.Rename(src, dst); err != nil {
t.Fatalf("move %s → %s: %v", src, dst, err)
}
}
func totalShardsInLayout(layout map[int][]int) int {
n := 0
for _, ids := range layout {
n += len(ids)
}
return n
}
func containsUint32(s []uint32, v uint32) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
func verifyHTTPRead(t testing.TB, client *http.Client, base, fid string, want []byte, stage string) {
t.Helper()
resp := framework.ReadBytes(t, client, base, fid)
body := framework.ReadAllAndClose(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("[%s] GET %s expected 200, got %d", stage, fid, resp.StatusCode)
}
if len(body) != len(want) {
t.Fatalf("[%s] GET %s size mismatch: got %d, want %d", stage, fid, len(body), len(want))
}
for i := range want {
if body[i] != want[i] {
t.Fatalf("[%s] GET %s byte %d mismatch: got %d, want %d", stage, fid, i, body[i], want[i])
}
}
}
// plantPartialEc writes the footprint of an interrupted EC encode (one shard
// sized for datFileSize, plus .ecx / .ecj / .vif, no .dat) into dir.
func plantPartialEc(t testing.TB, dir, collection string, volumeID uint32, shardID int, datFileSize int64) {
t.Helper()
shardPath := filepath.Join(dir, shardFileName(collection, volumeID, shardID))
if f, err := os.Create(shardPath); err == nil {
expectedShardSize := plantedShardSize(datFileSize)
if err := f.Truncate(expectedShardSize); err != nil {
f.Close()
t.Fatalf("truncate planted shard: %v", err)
}
f.Close()
} else {
t.Fatalf("create planted shard %s: %v", shardPath, err)
}
for _, side := range []struct {
ext string
content []byte
}{
{".ecx", []byte("dummy ecx")},
{".ecj", nil},
{".vif", nil},
} {
p := filepath.Join(dir, sideFileName(collection, volumeID, side.ext))
f, err := os.Create(p)
if err != nil {
t.Fatalf("create planted %s: %v", side.ext, err)
}
if side.content != nil {
if _, err := f.Write(side.content); err != nil {
f.Close()
t.Fatalf("write planted %s: %v", side.ext, err)
}
}
f.Close()
}
}
// mirrors calculateExpectedShardSize in weed/storage/disk_location_ec.go
func plantedShardSize(datFileSize int64) int64 {
const dataShards = int64(erasure_coding.DataShardsCount)
largeBatch := int64(erasure_coding.ErasureCodingLargeBlockSize) * dataShards
numLarge := datFileSize / largeBatch
shardSize := numLarge * int64(erasure_coding.ErasureCodingLargeBlockSize)
remaining := datFileSize - numLarge*largeBatch
if remaining > 0 {
smallBatch := int64(erasure_coding.ErasureCodingSmallBlockSize) * dataShards
numSmall := (remaining + smallBatch - 1) / smallBatch
shardSize += numSmall * int64(erasure_coding.ErasureCodingSmallBlockSize)
}
return shardSize
}
@@ -1,175 +0,0 @@
package volume_server_grpc_test
import (
"context"
"net/http"
"sort"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// TestVolumeEcShardsInfoReturnsAllShardsAcrossDisks drives the full path
// behind the ec.encode source-deletion gate. A multi-disk volume server
// ends up with EC shards split across disks (each registers its own
// EcVolume entry in DiskLocation.ecVolumes), and the volume server's
// VolumeEcShardsInfo RPC must walk every DiskLocation rather than
// reporting whichever disk Store.FindEcVolume picks first.
//
// Pre-fix, verifyEcShardsBeforeDelete refused to delete source volumes —
// the shard-bitmap union across destinations fell short of dataShards +
// parityShards because each destination only reported shards on one of
// its disks. With the handler fix, the same VerifyShardsAcrossServers
// call returns a complete bitmap and the gate opens.
func TestVolumeEcShardsInfoReturnsAllShardsAcrossDisks(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
const (
dataDirCount = 2
volumeID = uint32(9558)
collection = "ec-multi-disk-verify"
)
clusterHarness := framework.StartSingleVolumeClusterWithDataDirs(t, matrix.P1(), dataDirCount)
dataDirs := clusterHarness.VolumeDataDirs()
if len(dataDirs) != dataDirCount {
t.Fatalf("expected %d data dirs, got %d: %v", dataDirCount, len(dataDirs), dataDirs)
}
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
framework.AllocateVolume(t, grpcClient, volumeID, collection)
httpClient := framework.NewHTTPClient()
needles := []struct {
fid string
payload []byte
}{
{framework.NewFileID(volumeID, 9559, 0xC0FFEE01), bytesOfLen(64, 0xB1)},
{framework.NewFileID(volumeID, 9560, 0xC0FFEE02), bytesOfLen(8192, 0xB2)},
{framework.NewFileID(volumeID, 9561, 0xC0FFEE03), bytesOfLen(131072, 0xB3)},
}
for _, n := range needles {
resp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), n.fid, n.payload)
_ = framework.ReadAllAndClose(t, resp)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("upload %s expected 201, got %d", n.fid, resp.StatusCode)
}
}
if _, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID,
Collection: collection,
}); err != nil {
t.Fatalf("VolumeEcShardsGenerate: %v", err)
}
// Generate places every shard plus the .ecx/.ecj/.vif on the .dat's
// disk (disk 0). Mount all 14 there first so the next step's restart
// has a steady starting state.
allShards := make([]uint32, erasure_coding.TotalShardsCount)
for i := range allShards {
allShards[i] = uint32(i)
}
if _, err := grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID,
Collection: collection,
ShardIds: allShards,
}); err != nil {
t.Fatalf("VolumeEcShardsMount all shards: %v", err)
}
// Drop the .dat so the EC shards are the only data path — mirrors the
// real ec.encode flow before verifyEcShardsBeforeDelete fires.
if _, err := grpcClient.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{
VolumeId: volumeID,
}); err != nil {
t.Fatalf("VolumeDelete (drop .dat): %v", err)
}
// Move half the shards onto disk 1, leaving .ecx on disk 0. After
// restart, the cross-disk reconcile path attaches each disk's shards
// against its own EcVolume entry — the exact in-memory shape the bug
// reporter saw on a multi-disk destination.
clusterHarness.StopVolumeServer()
const splitAt = 7
for shard := 0; shard < splitAt; shard++ {
movedFile(t, dataDirs[0], dataDirs[1], collection, volumeID, erasure_coding.ToExt(shard))
}
if fileExistsIn(dataDirs[1], collection, volumeID, ".ecx") {
t.Fatalf("setup: .ecx must stay on disk 0 to exercise the multi-disk path")
}
clusterHarness.RestartVolumeServer()
conn2, grpcClient2 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn2.Close()
postReconcileLayout := scanShardLayout(t, dataDirs, collection, volumeID)
if got, want := totalShardsInLayout(postReconcileLayout), erasure_coding.TotalShardsCount; got != want {
t.Fatalf("post-reconcile: total shards on disk mismatch: got %d, want %d (layout=%v)", got, want, postReconcileLayout)
}
if len(postReconcileLayout[0]) == 0 || len(postReconcileLayout[1]) == 0 {
t.Fatalf("post-reconcile: expected shards on BOTH disks, got per-disk layout %v", postReconcileLayout)
}
// Direct RPC assertion: VolumeEcShardsInfo must report every shard
// the server holds, not just the ones registered against the first
// matching DiskLocation.
infoResp, err := grpcClient2.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("VolumeEcShardsInfo: %v", err)
}
gotShardIds := make([]int, 0, len(infoResp.GetEcShardInfos()))
for _, info := range infoResp.GetEcShardInfos() {
if info.GetVolumeId() != volumeID {
t.Errorf("EcShardInfo VolumeId=%d, want %d", info.GetVolumeId(), volumeID)
}
gotShardIds = append(gotShardIds, int(info.GetShardId()))
}
sort.Ints(gotShardIds)
wantShardIds := make([]int, erasure_coding.TotalShardsCount)
for i := range wantShardIds {
wantShardIds[i] = i
}
if len(gotShardIds) != len(wantShardIds) {
t.Fatalf("VolumeEcShardsInfo returned %d shards (ids=%v), want %d (ids=%v) — per-disk layout=%v",
len(gotShardIds), gotShardIds, len(wantShardIds), wantShardIds, postReconcileLayout)
}
for i, sid := range wantShardIds {
if gotShardIds[i] != sid {
t.Fatalf("VolumeEcShardsInfo shard ids=%v, want %v (per-disk layout=%v)",
gotShardIds, wantShardIds, postReconcileLayout)
}
}
// End-to-end assertion via the same helper the worker uses to gate
// source-volume deletion (weed/worker/tasks/erasure_coding/ec_task.go
// verifyEcShardsBeforeDelete). The union across destinations is what
// RequireFullShardSet measures; with one destination that holds every
// shard, the union must cover dataShards + parityShards.
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
servers := []string{clusterHarness.VolumeServerAddress()}
union, perServer := erasure_coding.VerifyShardsAcrossServers(ctx, volumeID, servers, dialOption)
if err := erasure_coding.RequireFullShardSet(volumeID, union, erasure_coding.TotalShardsCount); err != nil {
t.Fatalf("verifyEcShardsBeforeDelete-equivalent gate failed: %v\nper-server inventory: %s\nper-disk layout: %v",
err, erasure_coding.SummarizeShardInventory(perServer), postReconcileLayout)
}
if got, want := union.Count(), erasure_coding.TotalShardsCount; got != want {
t.Fatalf("VerifyShardsAcrossServers union covered %d/%d shards (per-server=%s, layout=%v)",
got, want, erasure_coding.SummarizeShardInventory(perServer), postReconcileLayout)
}
}
@@ -2,8 +2,6 @@ package volume_server_http_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
@@ -46,17 +44,6 @@ func TestReplicatedUploadSucceedsImmediatelyAfterAllocate(t *testing.T) {
fid := framework.NewFileID(volumeID, 881001, 0x0B0C0D0E)
payload := []byte("replicated-upload-after-allocate")
// The master only learns about replica locations through volume-server
// heartbeats, which lag behind the direct AllocateVolume gRPC calls above.
// In production a client obtains its fid from the master assign flow, which
// guarantees the master already knows every replica; this test crafts the
// fid by hand, so the replicated write would otherwise look up the master
// before the second replica is registered and fail with a 500. Wait until
// the master reports both replicas before uploading.
if !waitForMasterReplicaCount(t, client, clusterHarness.MasterURL(), volumeID, 2, 10*time.Second) {
t.Fatalf("master did not report 2 replica locations for volume %d within deadline", volumeID)
}
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
@@ -74,29 +61,3 @@ func TestReplicatedUploadSucceedsImmediatelyAfterAllocate(t *testing.T) {
t.Fatalf("replica body mismatch: got %q want %q", string(replicaBody), string(payload))
}
}
// waitForMasterReplicaCount polls the master volume lookup until it reports at
// least want locations for volumeID, or the timeout elapses.
func waitForMasterReplicaCount(t testing.TB, client *http.Client, masterURL string, volumeID uint32, want int, timeout time.Duration) bool {
t.Helper()
lookupURL := fmt.Sprintf("%s/dir/lookup?volumeId=%d", masterURL, volumeID)
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, lookupURL))
body := framework.ReadAllAndClose(t, resp)
if resp.StatusCode == http.StatusOK {
var result struct {
Locations []struct {
Url string `json:"url"`
} `json:"locations"`
}
if err := json.Unmarshal(body, &result); err == nil && len(result.Locations) >= want {
return true
}
}
time.Sleep(200 * time.Millisecond)
}
return false
}
-16
View File
@@ -193,22 +193,6 @@ func NewAdminServer(masters string, templateFS http.FileSystem, dataDir string,
} else {
glog.V(0).Infof("Credential store %s does not support filer address function", store.GetName())
}
// Mirror the filer's jwt.filer_signing.key so the admin UI's
// Users/Groups pages can present a valid Bearer token when the
// filer enforces IAM gRPC auth. When the key is empty, both
// sides run unauthenticated and no token is sent.
if signer, ok := store.(interface {
SetAdminSigning(security.SigningKey, int)
}); ok {
viper := util.GetViper()
key := security.SigningKey(viper.GetString("jwt.filer_signing.key"))
expires := viper.GetInt("jwt.filer_signing.expires_after_seconds")
signer.SetAdminSigning(key, expires)
if len(key) > 0 {
glog.V(0).Infof("Credential store configured with admin Bearer token signing")
}
}
}
}
-17
View File
@@ -12,8 +12,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"google.golang.org/grpc"
)
// WithMasterClient executes a function with a master client connection
@@ -40,21 +38,6 @@ func (s *AdminServer) WithVolumeServerClient(address pb.ServerAddress, f func(cl
})
}
// GetMasterClient returns the admin server's wdclient.MasterClient. It is used
// by file browser download paths that stream chunks straight from the volume
// servers via filer.PrepareStreamContent so they keep working when the filer
// has -disableHttp=true.
func (s *AdminServer) GetMasterClient() *wdclient.MasterClient {
return s.masterClient
}
// GetGrpcDialOption returns the dial option used for all admin-originated
// gRPC connections (TLS or insecure). File browser uploads need this when
// they perform the assign + volume HTTP POST + create-entry flow.
func (s *AdminServer) GetGrpcDialOption() grpc.DialOption {
return s.grpcDialOption
}
// GetFilerAddress returns a filer address, discovering from masters if needed
func (s *AdminServer) GetFilerAddress() string {
// Discover filers from masters
+5 -8
View File
@@ -734,8 +734,8 @@ func (s *AdminServer) parseOrBuildClusterContext(raw json.RawMessage) (*plugin_p
if len(contextMessage.MasterGrpcAddresses) == 0 {
contextMessage.MasterGrpcAddresses = append(contextMessage.MasterGrpcAddresses, fallback.MasterGrpcAddresses...)
}
if len(contextMessage.FilerAddresses) == 0 {
contextMessage.FilerAddresses = append(contextMessage.FilerAddresses, fallback.FilerAddresses...)
if len(contextMessage.FilerGrpcAddresses) == 0 {
contextMessage.FilerGrpcAddresses = append(contextMessage.FilerGrpcAddresses, fallback.FilerGrpcAddresses...)
}
if len(contextMessage.VolumeGrpcAddresses) == 0 {
contextMessage.VolumeGrpcAddresses = append(contextMessage.VolumeGrpcAddresses, fallback.VolumeGrpcAddresses...)
@@ -754,7 +754,7 @@ func (s *AdminServer) parseOrBuildClusterContext(raw json.RawMessage) (*plugin_p
func (s *AdminServer) buildDefaultPluginClusterContext() *plugin_pb.ClusterContext {
clusterContext := &plugin_pb.ClusterContext{
MasterGrpcAddresses: make([]string, 0),
FilerAddresses: make([]string, 0),
FilerGrpcAddresses: make([]string, 0),
VolumeGrpcAddresses: make([]string, 0),
S3GrpcAddresses: make([]string, 0),
Metadata: map[string]string{
@@ -767,9 +767,6 @@ func (s *AdminServer) buildDefaultPluginClusterContext() *plugin_pb.ClusterConte
clusterContext.MasterGrpcAddresses = append(clusterContext.MasterGrpcAddresses, masterAddress)
}
// Master returns filers in pb.ServerAddress form (host:httpPort.grpcPort).
// Forward that verbatim; each worker converts to a gRPC or HTTP address as
// it needs (dialing wants gRPC, the admin shell wants the ServerAddress).
filerSeen := map[string]struct{}{}
for _, filer := range s.GetAllFilers() {
filer = strings.TrimSpace(filer)
@@ -780,7 +777,7 @@ func (s *AdminServer) buildDefaultPluginClusterContext() *plugin_pb.ClusterConte
continue
}
filerSeen[filer] = struct{}{}
clusterContext.FilerAddresses = append(clusterContext.FilerAddresses, filer)
clusterContext.FilerGrpcAddresses = append(clusterContext.FilerGrpcAddresses, filer)
}
volumeSeen := map[string]struct{}{}
@@ -828,7 +825,7 @@ func (s *AdminServer) buildDefaultPluginClusterContext() *plugin_pb.ClusterConte
}
sort.Strings(clusterContext.MasterGrpcAddresses)
sort.Strings(clusterContext.FilerAddresses)
sort.Strings(clusterContext.FilerGrpcAddresses)
sort.Strings(clusterContext.VolumeGrpcAddresses)
sort.Strings(clusterContext.S3GrpcAddresses)
+2 -9
View File
@@ -2,7 +2,6 @@ package dash
import (
"context"
"errors"
"fmt"
"io"
"net"
@@ -18,9 +17,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
const (
@@ -276,13 +273,9 @@ func (s *WorkerGrpcServer) WorkerStream(stream worker_pb.WorkerService_WorkerStr
msg, err := stream.Recv()
if err != nil {
switch {
case err == io.EOF:
if err == io.EOF {
glog.Infof("Worker %s disconnected", workerID)
case errors.Is(err, context.Canceled), status.Code(err) == codes.Canceled:
// Graceful shutdown on either side cancels the stream.
glog.V(1).Infof("Worker %s stream canceled: %v", workerID, err)
default:
} else {
glog.Errorf("Error receiving from worker %s: %v", workerID, err)
}
s.unregisterWorker(conn)
-291
View File
@@ -1,291 +0,0 @@
package handlers
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"net/http"
"path"
"strconv"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// Admin file browser upload chunk sizing — kept in sync with the values
// s3api uses so files end up split into the same fid-sized pieces the rest of
// the cluster expects.
const (
adminUploadChunkSize = 8 * 1024 * 1024
adminUploadSmallFileLimit = 256 * 1024
)
// File browser handlers backed by the filer gRPC service. They bypass the
// filer's HTTP listener so the UI keeps working when the filer is started
// with -disableHttp=true; chunk bytes still flow through the volume server
// HTTP endpoints (which run on their own ports).
// fetchFileContentGrpc reads file content via the filer gRPC service, looking
// the entry up and then streaming the chunks straight from the volume servers.
// When maxBytes > 0 the stream is truncated to that many bytes — used by the
// "is this text?" sniff so unknown-MIME files don't get fully downloaded.
func (h *FileBrowserHandlers) fetchFileContentGrpc(ctx context.Context, filePath string, maxBytes int) (string, error) {
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
return "", err
}
entry, err := h.lookupEntry(ctx, cleanFilePath)
if err != nil {
return "", err
}
if entry.IsDirectory {
return "", fmt.Errorf("path is a directory")
}
size := int64(filer.FileSize(entry))
streamSize := size
if maxBytes > 0 && streamSize > int64(maxBytes) {
streamSize = int64(maxBytes)
}
var buf bytes.Buffer
if err := h.streamEntryContent(ctx, entry, streamSize, &buf); err != nil {
return "", err
}
return buf.String(), nil
}
// downloadFileGrpc streams a file via gRPC + volume server HTTP. The
// response writer receives the canonical attachment headers and the raw
// bytes; this replaces the HTTP-to-filer proxy that used to run in
// DownloadFile.
func (h *FileBrowserHandlers) downloadFileGrpc(ctx context.Context, filePath string, w http.ResponseWriter) error {
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
return err
}
entry, err := h.lookupEntry(ctx, cleanFilePath)
if err != nil {
return err
}
if entry.IsDirectory {
return fmt.Errorf("path is a directory")
}
size := int64(filer.FileSize(entry))
fileName := path.Base(cleanFilePath)
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": fileName}))
contentType := ""
if entry.Attributes != nil {
contentType = entry.Attributes.Mime
}
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(http.StatusOK)
return h.streamEntryContent(ctx, entry, size, w)
}
// uploadFileGrpc streams the upload to volume servers in 8 MiB chunks via the
// shared chunked-upload helper, then registers the assembled entry through the
// filer gRPC service. Bytes never enter the admin process's heap as a whole —
// each chunk is sized to adminUploadChunkSize. Small files (< 256 KiB) are
// stored inline on the entry, matching the S3 server's behaviour.
func (h *FileBrowserHandlers) uploadFileGrpc(ctx context.Context, filePath string, fileName string, mimeType string, reader io.Reader) error {
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
return err
}
dir := path.Dir(cleanFilePath)
if dir == "." {
dir = "/"
}
entryName := path.Base(cleanFilePath)
if mimeType == "" {
mimeType = "application/octet-stream"
}
assignFunc := func(ctx context.Context, count int, expectedDataSize uint64) (*operation.VolumeAssignRequest, *operation.AssignResult, error) {
var assignResp *filer_pb.AssignVolumeResponse
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, assignErr := client.AssignVolume(ctx, &filer_pb.AssignVolumeRequest{
Count: int32(count),
Path: cleanFilePath,
ExpectedDataSize: expectedDataSize,
})
if assignErr != nil {
return assignErr
}
if resp.Error != "" {
return fmt.Errorf("%s", resp.Error)
}
assignResp = resp
return nil
})
if err != nil {
return nil, nil, err
}
if assignResp.Location == nil || assignResp.FileId == "" {
return nil, nil, fmt.Errorf("assign volume returned empty location")
}
return nil, &operation.AssignResult{
Fid: assignResp.FileId,
Url: assignResp.Location.Url,
PublicUrl: assignResp.Location.PublicUrl,
Count: uint64(count),
Auth: security.EncodedJwt(assignResp.Auth),
}, nil
}
chunkResult, err := operation.UploadReaderInChunks(ctx, reader, &operation.ChunkedUploadOption{
ChunkSize: adminUploadChunkSize,
SmallFileLimit: adminUploadSmallFileLimit,
SaveSmallInline: true,
MimeType: mimeType,
AssignFunc: assignFunc,
})
if err != nil {
// Partial chunks come back even on error so we can clean them up rather
// than leaving orphaned data on volume servers.
if chunkResult != nil && len(chunkResult.FileChunks) > 0 {
h.deleteOrphanedChunks(chunkResult.FileChunks)
}
return fmt.Errorf("upload: %w", err)
}
now := time.Now()
entry := &filer_pb.Entry{
Name: entryName,
Attributes: &filer_pb.FuseAttributes{
FileSize: uint64(chunkResult.TotalSize),
Mtime: now.Unix(),
Crtime: now.Unix(),
FileMode: 0644,
Mime: mimeType,
},
}
if len(chunkResult.SmallContent) > 0 {
entry.Content = chunkResult.SmallContent
} else {
entry.Chunks = chunkResult.FileChunks
}
err = h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
_, createErr := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: dir,
Entry: entry,
})
return createErr
})
if err != nil {
if len(chunkResult.FileChunks) > 0 {
h.deleteOrphanedChunks(chunkResult.FileChunks)
}
return fmt.Errorf("create entry: %w", err)
}
return nil
}
// deleteOrphanedChunks best-effort removes the chunk fids when an upload
// fails partway through. Errors are logged; we can't surface them past the
// caller's primary failure.
func (h *FileBrowserHandlers) deleteOrphanedChunks(chunks []*filer_pb.FileChunk) {
fileIds := make([]string, 0, len(chunks))
for _, c := range chunks {
if fid := c.GetFileIdString(); fid != "" {
fileIds = append(fileIds, fid)
}
}
if len(fileIds) == 0 {
return
}
master := h.adminServer.GetMasterClient()
results := operation.DeleteFileIds(master.GetMaster, false, h.adminServer.GetGrpcDialOption(), fileIds)
for _, r := range results {
if r.Error != "" {
glog.Warningf("admin file browser: orphan chunk %s cleanup: %s", r.FileId, r.Error)
}
}
}
func (h *FileBrowserHandlers) lookupEntry(ctx context.Context, cleanFilePath string) (*filer_pb.Entry, error) {
dir := path.Dir(cleanFilePath)
if dir == "." {
dir = "/"
}
name := path.Base(cleanFilePath)
var entry *filer_pb.Entry
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, lookupErr := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if lookupErr != nil {
return lookupErr
}
if resp.Entry == nil {
return fmt.Errorf("not found")
}
entry = resp.Entry
return nil
})
return entry, err
}
func (h *FileBrowserHandlers) streamEntryContent(ctx context.Context, entry *filer_pb.Entry, size int64, w io.Writer) error {
if size == 0 {
// Inline content (small files stored directly on the entry) skip the
// chunk pipeline entirely.
if len(entry.Content) > 0 {
_, err := w.Write(entry.Content)
return err
}
return nil
}
if len(entry.Content) > 0 && len(entry.GetChunks()) == 0 {
_, err := w.Write(entry.Content)
return err
}
streamFn, err := filer.PrepareStreamContentWithThrottler(
ctx,
h.adminServer.GetMasterClient(),
volumeServerReadJwt,
entry.GetChunks(),
0,
size,
0,
)
if err != nil {
return fmt.Errorf("prepare stream: %w", err)
}
return streamFn(w)
}
// volumeServerReadJwt mints a per-fileId Bearer token for reads against a
// volume server when jwt.signing.read.key is configured. The volume servers
// are unaware of jwt.filer_signing.read.key — that one only gates the filer
// HTTP surface, which this code path doesn't touch.
func volumeServerReadJwt(fileId string) string {
v := util.GetViper()
signingKey := security.SigningKey(v.GetString("jwt.signing.read.key"))
if len(signingKey) == 0 {
return ""
}
expiresAfterSec := v.GetInt("jwt.signing.read.expires_after_seconds")
return string(security.GenJwtForVolumeServer(signingKey, expiresAfterSec, fileId))
}
+371 -52
View File
@@ -1,10 +1,15 @@
package handlers
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
@@ -16,7 +21,9 @@ import (
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/http/client"
)
@@ -42,6 +49,15 @@ func NewFileBrowserHandlers(adminServer *dash.AdminServer) *FileBrowserHandlers
}
}
// newClientWithTimeout creates a temporary http.Client with the specified timeout,
// reusing the TLS transport from the shared httpClient.
func (h *FileBrowserHandlers) newClientWithTimeout(timeout time.Duration) http.Client {
return http.Client{
Transport: h.httpClient.Client.Transport,
Timeout: timeout,
}
}
// ShowFileBrowser renders the file browser page
func (h *FileBrowserHandlers) ShowFileBrowser(w http.ResponseWriter, r *http.Request) {
// Get path from query parameter, default to root
@@ -293,7 +309,7 @@ func (h *FileBrowserHandlers) UploadFile(w http.ResponseWriter, r *http.Request)
}
// Upload file to filer
err = h.uploadFileToFiler(r.Context(), fullPath, fileHeader)
err = h.uploadFileToFiler(fullPath, fileHeader)
if err != nil {
failedUploads = append(failedUploads, fmt.Sprintf("%s: %v", fileName, err))
@@ -330,21 +346,139 @@ func (h *FileBrowserHandlers) UploadFile(w http.ResponseWriter, r *http.Request)
}
}
// uploadFileToFiler uploads a file to the cluster via filer gRPC + volume
// HTTP. This works whether or not the filer is running with -disableHttp=true,
// since the bytes never traverse the filer's HTTP listener. The multipart
// file is streamed through the chunked uploader, so the admin process never
// buffers the entire payload in memory. The caller passes the request
// context so a client disconnect cancels the in-flight chunk uploads instead
// of letting them run to completion against the volume servers.
func (h *FileBrowserHandlers) uploadFileToFiler(ctx context.Context, filePath string, fileHeader *multipart.FileHeader) error {
// uploadFileToFiler uploads a file directly to the filer using multipart form data
func (h *FileBrowserHandlers) uploadFileToFiler(filePath string, fileHeader *multipart.FileHeader) error {
// Get filer address from admin server
filerAddress := h.adminServer.GetFilerAddress()
if filerAddress == "" {
return fmt.Errorf("filer address not configured")
}
// Validate and sanitize the filer address
if err := h.validateFilerAddress(filerAddress); err != nil {
return fmt.Errorf("invalid filer address: %w", err)
}
filerHttpAddress := pb.ServerAddress(filerAddress).ToHttpAddress()
// Validate and sanitize the file path
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
return fmt.Errorf("invalid file path: %w", err)
}
// Open the file
file, err := fileHeader.Open()
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
return h.uploadFileGrpc(ctx, filePath, fileHeader.Filename, fileHeader.Header.Get("Content-Type"), file)
// Create multipart form data
var body bytes.Buffer
writer := multipart.NewWriter(&body)
// Create form file field with normalized base filename
// Use path.Base (not filepath.Base) since cleanFilePath uses URL path semantics
baseFileName := path.Base(cleanFilePath)
part, err := writer.CreateFormFile("file", baseFileName)
if err != nil {
return fmt.Errorf("failed to create form file: %w", err)
}
// Copy file content to form
_, err = io.Copy(part, file)
if err != nil {
return fmt.Errorf("failed to copy file content: %w", err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
return fmt.Errorf("failed to close multipart writer: %w", err)
}
// Create the upload URL - the httpClient will normalize to the correct scheme (http/https)
// based on the https.client configuration in security.toml
uploadURL := filerFileURL(filerHttpAddress, cleanFilePath)
// Normalize the URL scheme based on TLS configuration
uploadURL, err = h.httpClient.NormalizeHttpScheme(uploadURL)
if err != nil {
return fmt.Errorf("failed to normalize URL scheme: %w", err)
}
// Create HTTP request
req, err := http.NewRequest("POST", uploadURL, &body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set content type with boundary
req.Header.Set("Content-Type", writer.FormDataContentType())
// Add JWT Token to Authorization Header
h.setupFilerJwtAuth(req, "jwt.filer_signing.key", "jwt.filer_signing.expires_after_seconds", "filer upload")
// Send request using TLS-aware HTTP client with 60s timeout for large file uploads
// lgtm[go/ssrf]
// Safe: filerAddress validated by validateFilerAddress() to match configured filer
// Safe: cleanFilePath validated and cleaned by validateAndCleanFilePath() to prevent path traversal
client := h.newClientWithTimeout(60 * time.Second)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to upload file: %w", err)
}
defer resp.Body.Close()
// Check response
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
responseBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(responseBody))
}
return nil
}
// validateFilerAddress validates that the filer address is safe to use
func (h *FileBrowserHandlers) validateFilerAddress(address string) error {
if address == "" {
return fmt.Errorf("filer address cannot be empty")
}
// CRITICAL: Only allow the configured filer address to prevent SSRF
configuredFiler := h.adminServer.GetFilerAddress()
normalizedAddress := pb.ServerAddress(address).ToHttpAddress()
normalizedConfigured := pb.ServerAddress(configuredFiler).ToHttpAddress()
if normalizedAddress != normalizedConfigured {
return fmt.Errorf("address does not match configured filer: got %s, expected %s", address, configuredFiler)
}
// Parse the normalized HTTP address to validate it's a proper host:port format.
host, port, err := net.SplitHostPort(normalizedAddress)
if err != nil {
return fmt.Errorf("invalid address format: %w", err)
}
// Validate host is not empty
if host == "" {
return fmt.Errorf("host cannot be empty")
}
// Validate port is numeric and in valid range
if port == "" {
return fmt.Errorf("port cannot be empty")
}
portNum, err := strconv.Atoi(port)
if err != nil {
return fmt.Errorf("invalid port number: %w", err)
}
if portNum < 1 || portNum > 65535 {
return fmt.Errorf("port number must be between 1 and 65535")
}
return nil
}
// validateAndCleanFilePath validates and cleans the file path to prevent path traversal
@@ -373,56 +507,161 @@ func (h *FileBrowserHandlers) validateAndCleanFilePath(filePath string) (string,
return cleanPath, nil
}
// fetchFileContent fetches file content via the filer gRPC service. It is
// used for the "view as text" path, so the maxBytes cap matches the 1 MB
// limit the caller already applies before invoking us.
func (h *FileBrowserHandlers) fetchFileContent(filePath string, timeout time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return h.fetchFileContentGrpc(ctx, filePath, 0)
// filerFileURL joins the filer HTTP address with a validated file path, URL-escaping
// the path so that control characters and other bytes that are legal in S3 object keys
// cannot inject into the HTTP request target.
func filerFileURL(filerHttpAddress, cleanFilePath string) string {
return filerHttpAddress + (&url.URL{Path: cleanFilePath}).EscapedPath()
}
// DownloadFile streams a file straight from the volume servers via the filer
// gRPC service, so the admin file browser keeps working even when the filer
// is started with -disableHttp=true.
// fetchFileContent fetches file content from the filer and returns the content or an error.
func (h *FileBrowserHandlers) fetchFileContent(filePath string, timeout time.Duration) (string, error) {
filerAddress := h.adminServer.GetFilerAddress()
if filerAddress == "" {
return "", fmt.Errorf("filer address not configured")
}
if err := h.validateFilerAddress(filerAddress); err != nil {
return "", fmt.Errorf("invalid filer address configuration: %w", err)
}
filerHttpAddress := pb.ServerAddress(filerAddress).ToHttpAddress()
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
return "", err
}
// Create the file URL with proper scheme based on TLS configuration
fileURL := filerFileURL(filerHttpAddress, cleanFilePath)
fileURL, err = h.httpClient.NormalizeHttpScheme(fileURL)
if err != nil {
return "", fmt.Errorf("failed to construct file URL: %w", err)
}
// lgtm[go/ssrf]
// Safe: filerAddress validated by validateFilerAddress() to match configured filer
// Safe: cleanFilePath validated and cleaned by validateAndCleanFilePath() to prevent path traversal
client := h.newClientWithTimeout(timeout)
req, err := http.NewRequest("GET", fileURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
h.addFilerJwtAuthHeader(req)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch file from filer: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("filer returned status %d but failed to read response body: %w", resp.StatusCode, err)
}
return "", fmt.Errorf("filer returned status %d: %s", resp.StatusCode, string(body))
}
contentBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read file content: %w", err)
}
return string(contentBytes), nil
}
// DownloadFile handles file download requests by proxying through the Admin UI server
// This ensures mTLS works correctly since the Admin UI server has the client certificates
func (h *FileBrowserHandlers) DownloadFile(w http.ResponseWriter, r *http.Request) {
filePath := r.URL.Query().Get("path")
if filePath == "" {
writeJSONError(w, http.StatusBadRequest, "File path is required")
return
}
tracker := &responseWriteTracker{ResponseWriter: w}
if err := h.downloadFileGrpc(r.Context(), filePath, tracker); err != nil {
// Once bytes have been written we can't switch to a JSON error body
// without corrupting the partial response — log and stop. Before any
// write the response is still uncommitted, so a 502 with details is
// safe.
if tracker.committed {
glog.Errorf("Error streaming file download: %v", err)
// Get filer address
filerAddress := h.adminServer.GetFilerAddress()
if filerAddress == "" {
writeJSONError(w, http.StatusInternalServerError, "Filer address not configured")
return
}
// Validate filer address to prevent SSRF
if err := h.validateFilerAddress(filerAddress); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Invalid filer address configuration")
return
}
filerHttpAddress := pb.ServerAddress(filerAddress).ToHttpAddress()
// Validate and sanitize the file path
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid file path: "+err.Error())
return
}
// Create the download URL with proper scheme based on TLS configuration
downloadURL := filerFileURL(filerHttpAddress, cleanFilePath)
downloadURL, err = h.httpClient.NormalizeHttpScheme(downloadURL)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to construct download URL: "+err.Error())
return
}
// Proxy the download through the Admin UI server to support mTLS
// lgtm[go/ssrf]
// Safe: filerAddress validated by validateFilerAddress() to match configured filer
// Safe: cleanFilePath validated and cleaned by validateAndCleanFilePath() to prevent path traversal
// Use request context so download is cancelled when client disconnects
req, err := http.NewRequestWithContext(r.Context(), "GET", downloadURL, nil)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to create request: "+err.Error())
return
}
client := h.newClientWithTimeout(5 * time.Minute) // Longer timeout for large file downloads
h.addFilerJwtAuthHeader(req)
resp, err := client.Do(req)
if err != nil {
writeJSONError(w, http.StatusBadGateway, "Failed to fetch file from filer: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
writeJSONError(w, resp.StatusCode, fmt.Sprintf("Filer returned status %d but failed to read response body: %v", resp.StatusCode, err))
return
}
writeJSONError(w, http.StatusBadGateway, "Failed to fetch file: "+err.Error())
writeJSONError(w, resp.StatusCode, fmt.Sprintf("Filer returned status %d: %s", resp.StatusCode, string(body)))
return
}
}
// responseWriteTracker wraps http.ResponseWriter to record whether the
// response has been committed (status line + headers sent). DownloadFile
// uses this instead of probing Header() so future header-setting code
// reorganization can't silently break the "did we already send bytes?"
// detection.
type responseWriteTracker struct {
http.ResponseWriter
committed bool
}
// Set headers for file download
fileName := filepath.Base(cleanFilePath)
// Use mime.FormatMediaType for RFC 6266 compliant Content-Disposition,
// properly handling non-ASCII characters and special characters
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": fileName}))
func (t *responseWriteTracker) WriteHeader(code int) {
t.committed = true
t.ResponseWriter.WriteHeader(code)
}
// Use content type from filer response, or default to octet-stream
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
func (t *responseWriteTracker) Write(p []byte) (int, error) {
t.committed = true
return t.ResponseWriter.Write(p)
// Set content length if available
if resp.ContentLength > 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", resp.ContentLength))
}
// Stream the response body to the client
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, resp.Body)
if err != nil {
glog.Errorf("Error streaming file download: %v", err)
}
}
// ViewFile handles file viewing requests (for text files, images, etc.)
@@ -644,16 +883,64 @@ func (h *FileBrowserHandlers) formatBytes(bytes int64) string {
// Helper function to check if a file is likely a text file by checking content
func (h *FileBrowserHandlers) isLikelyTextFile(filePath string, maxCheckSize int64) bool {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
content, err := h.fetchFileContentGrpc(ctx, filePath, int(maxCheckSize))
filerAddress := h.adminServer.GetFilerAddress()
if filerAddress == "" {
return false
}
// Validate filer address to prevent SSRF
if err := h.validateFilerAddress(filerAddress); err != nil {
glog.Errorf("Invalid filer address: %v", err)
return false
}
filerHttpAddress := pb.ServerAddress(filerAddress).ToHttpAddress()
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
return false
}
if len(content) == 0 {
return true
// Create the file URL with proper scheme based on TLS configuration
fileURL := filerFileURL(filerHttpAddress, cleanFilePath)
fileURL, err = h.httpClient.NormalizeHttpScheme(fileURL)
if err != nil {
glog.Errorf("Failed to normalize URL scheme: %v", err)
return false
}
return h.isPrintableText([]byte(content))
// lgtm[go/ssrf]
// Safe: filerAddress validated by validateFilerAddress() to match configured filer
// Safe: cleanFilePath validated and cleaned by validateAndCleanFilePath() to prevent path traversal
client := h.newClientWithTimeout(10 * time.Second)
req, err := http.NewRequest("GET", fileURL, nil)
if err != nil {
glog.Errorf("Failed to create request: %v", err)
return false
}
h.addFilerJwtAuthHeader(req)
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false
}
// Read first few bytes to check if it's text
buffer := make([]byte, min(maxCheckSize, 512))
n, err := resp.Body.Read(buffer)
if err != nil && err != io.EOF {
return false
}
if n == 0 {
return true // Empty file can be considered text
}
// Check if content is printable text
return h.isPrintableText(buffer[:n])
}
// Helper function to check if content is printable text
@@ -686,3 +973,35 @@ func min(a, b int64) int64 {
return b
}
// setupFilerJwtAuth generates a JWT token and adds it to the request Authorization header if configured.
func (h *FileBrowserHandlers) setupFilerJwtAuth(req *http.Request, keyPath, expiresPath, operation string) {
// Load security configuration
v := util.GetViper()
// Read Filer JWT token from security.toml
signingKey := security.SigningKey(v.GetString(keyPath))
expiresAfterSec := v.GetInt(expiresPath)
// Generate JWT token to authenticate with Filer
var jwtToken security.EncodedJwt
if len(signingKey) > 0 {
jwtToken = security.GenJwtForFilerServer(signingKey, expiresAfterSec)
glog.V(4).Infof("Generated JWT token for %s (expires in %d sec)", operation, expiresAfterSec)
} else {
if v.GetString("jwt.signing.key") != "" {
glog.Warningf("JWT %s key not configured, but general JWT security is enabled. %s without authentication.", keyPath, operation)
} else {
glog.V(1).Infof("No JWT signing key configured, %s without authentication", operation)
}
}
// Add JWT Token to Authorization Header
if jwtToken != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", string(jwtToken)))
glog.V(4).Infof("Added JWT authorization header for %s", operation)
}
}
func (h *FileBrowserHandlers) addFilerJwtAuthHeader(req *http.Request) {
h.setupFilerJwtAuth(req, "jwt.filer_signing.read.key", "jwt.filer_signing.read.expires_after_seconds", "filer request")
}
@@ -42,3 +42,21 @@ func TestValidateAndCleanFilePath_RejectsEmpty(t *testing.T) {
}
}
func TestFilerFileURL_EscapesControlChars(t *testing.T) {
cases := []struct {
addr string
path string
want string
}{
{"http://127.0.0.1:8888", "/buckets/profilebuilder/3testGB.zip\n ", "http://127.0.0.1:8888/buckets/profilebuilder/3testGB.zip%0A%20"},
{"http://127.0.0.1:8888", "/buckets/profilebuilder/file\rname", "http://127.0.0.1:8888/buckets/profilebuilder/file%0Dname"},
{"http://127.0.0.1:8888", "/buckets/profilebuilder/file\x00name", "http://127.0.0.1:8888/buckets/profilebuilder/file%00name"},
// Plain path round-trips unchanged.
{"http://h:1", "/a/b.txt", "http://h:1/a/b.txt"},
}
for _, tc := range cases {
if got := filerFileURL(tc.addr, tc.path); got != tc.want {
t.Errorf("filerFileURL(%q, %q) = %q, want %q", tc.addr, tc.path, got, tc.want)
}
}
}

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