Compare commits

..
Author SHA1 Message Date
Chris Lu 379e292189 ci: disable signing config for legacy cosign signatures 2026-09-03 16:29:19 -07:00
419 changed files with 4831 additions and 31198 deletions
+2 -6
View File
@@ -25,12 +25,8 @@ runs:
set -euo pipefail
# The .sig tag layout: the OCI-referrer bundle cosign 3 writes by default
# is not read by the Kyverno and policy-controller releases in use today.
# Cosign 3 also defaults to --use-signing-config, which insists on a
# bundle for its output; turning it off falls back to the default
# Fulcio and Rekor URLs, which is all the .sig layout ever used.
cosign sign --yes --recursive \
--new-bundle-format=false --use-signing-config=false \
$IMAGES
# Its signing config only supports bundles, so disable that path too.
cosign sign --yes --recursive --new-bundle-format=false --use-signing-config=false $IMAGES
- name: Verify
shell: bash
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""Render the repository's star history to note/star_history.svg.
Uses the GitHub REST stargazers endpoint with the starred-at accept header,
which caps at 40,000 entries (400 pages of 100). The chart is regenerated on
a schedule; if the repo grows past that cap the script stops at 40,000 and
logs a warning rather than under-reporting.
"""
import json
import os
import sys
import urllib.request
from datetime import datetime
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.dates import AutoDateLocator, DateFormatter # noqa: E402
REPO = "seaweedfs/seaweedfs"
TOKEN = os.environ["GITHUB_TOKEN"]
OUT = os.environ.get("OUT", "note/star_history.svg")
PAGE_CAP = 400 # GitHub's hard limit on stargazer pagination
def fetch_stargazers():
stars = []
page = 1
while page <= PAGE_CAP:
url = f"https://api.github.com/repos/{REPO}/stargazers?per_page=100&page={page}"
req = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github.star+json",
"Authorization": f"Bearer {TOKEN}",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "seaweedfs-star-history",
},
)
with urllib.request.urlopen(req) as resp:
batch = json.load(resp)
if not batch:
break
for u in batch:
sa = u.get("starred_at")
if sa:
stars.append(datetime.fromisoformat(sa.replace("Z", "+00:00")))
if len(batch) < 100:
break
page += 1
if page > PAGE_CAP:
print(
f"::warning::Hit the {PAGE_CAP}-page stargazer pagination cap; "
"chart reflects the first 40,000 stars only."
)
return stars
def render(stars, out):
stars.sort()
counts = list(range(1, len(stars) + 1))
fig, ax = plt.subplots(figsize=(10, 6), dpi=130)
ax.plot(stars, counts, color="#0969da", linewidth=1.6)
ax.set_xlabel("Date")
ax.set_ylabel("Stars")
ax.set_title(f"{REPO} star history")
ax.grid(True, linestyle="--", alpha=0.3)
ax.xaxis.set_major_locator(AutoDateLocator())
ax.xaxis.set_major_formatter(DateFormatter("%Y-%m"))
fig.autofmt_xdate()
fig.tight_layout()
fig.savefig(out, format="svg", transparent=False)
plt.close(fig)
def main():
stars = fetch_stargazers()
if not stars:
print("::error::No stargazers fetched; not updating the chart.")
sys.exit(1)
render(stars, OUT)
print(f"Rendered {len(stars)} stars to {OUT}")
if __name__ == "__main__":
main()
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.38.0
uses: github/codeql-action/init@v4.37.9
# Override language selection by uncommenting this and choosing your languages
with:
languages: go
@@ -35,7 +35,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below).
- name: Autobuild
uses: github/codeql-action/autobuild@v4.38.0
uses: github/codeql-action/autobuild@v4.37.9
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -49,4 +49,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.38.0
uses: github/codeql-action/analyze@v4.37.9
+1 -1
View File
@@ -152,7 +152,7 @@ jobs:
org.opencontainers.image.vendor=Chris Lu
- name: Set up QEMU
uses: docker/setup-qemu-action@v4.3.0
uses: docker/setup-qemu-action@v4.2.0
- name: Create BuildKit config
run: |
@@ -129,7 +129,7 @@ jobs:
echo "seaweedfs_ref=$seaweed" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v4.3.0
uses: docker/setup-qemu-action@v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+2 -2
View File
@@ -236,7 +236,7 @@ jobs:
org.opencontainers.image.vendor=Chris Lu
- name: Set up QEMU
if: matrix.platform != 'amd64'
uses: docker/setup-qemu-action@v4.3.0
uses: docker/setup-qemu-action@v4.2.0
- name: Create BuildKit config
run: |
cat > /tmp/buildkitd.toml <<EOF
@@ -405,7 +405,7 @@ jobs:
output: trivy-results.sarif
exit-code: '0'
- name: Upload Trivy scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v4.38.0
uses: github/codeql-action/upload-sarif@v4.37.9
if: always()
with:
sarif_file: trivy-results.sarif
@@ -46,7 +46,7 @@ jobs:
org.opencontainers.image.vendor=Chris Lu
-
name: Set up QEMU
uses: docker/setup-qemu-action@v4.3.0
uses: docker/setup-qemu-action@v4.2.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -231,7 +231,7 @@ jobs:
- name: Set up QEMU
if: (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant) && matrix.qemu
uses: docker/setup-qemu-action@v4.3.0
uses: docker/setup-qemu-action@v4.2.0
- name: Create BuildKit config
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
@@ -456,7 +456,7 @@ jobs:
- name: Upload Trivy scan results to GitHub Security
if: always()
uses: github/codeql-action/upload-sarif@v4.38.0
uses: github/codeql-action/upload-sarif@v4.37.9
with:
sarif_file: trivy-results.sarif
category: trivy-${{ matrix.variant }}
@@ -85,7 +85,7 @@ jobs:
echo "seaweedfs_ref=$seaweed" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v1
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v1
+2 -11
View File
@@ -227,9 +227,6 @@ jobs:
out = render({
"global.seaweedfs.securityConfig.jwtSigning.filerWrite": "true",
"admin.enabled": "true",
# admin.ip defaults to 0.0.0.0 (non-loopback), which weed admin 4.46
# refuses to bind without authentication.
"admin.secret.adminPassword": "ci-admin-password",
})
cm = configmap(out, "test-seaweedfs-security-config")
if cm is None:
@@ -1144,9 +1141,6 @@ jobs:
"s3.enabled": "true",
"sftp.enabled": "true",
"admin.enabled": "true",
# admin.ip defaults to 0.0.0.0 (non-loopback), which weed admin 4.46
# refuses to bind without authentication.
"admin.secret.adminPassword": "ci-admin-password",
"worker.enabled": "true",
"cosi.enabled": "true",
"s3.createBuckets[0].name": "b",
@@ -1343,7 +1337,7 @@ jobs:
# Which means egress on its own must render for a release that runs
# neither COSI nor a resize: no component of it reaches the API server,
# so nothing may demand a CIDR for one.
for label, values in {"defaults": {}, "admin": {"admin.enabled": "true", "admin.secret.adminPassword": "ci-admin-password"}}.items():
for label, values in {"defaults": {}, "admin": {"admin.enabled": "true"}}.items():
try:
render(dict(values, **{"networkPolicy.enabled": "true",
"networkPolicy.egress.enabled": "true"}))
@@ -1404,9 +1398,6 @@ jobs:
ALL_ON = {
"admin.enabled": "true",
# admin.ip defaults to 0.0.0.0 (non-loopback), which weed admin 4.46
# refuses to bind without authentication.
"admin.secret.adminPassword": "ci-admin-password",
"s3.enabled": "true",
"sftp.enabled": "true",
"worker.enabled": "true",
@@ -1589,7 +1580,7 @@ jobs:
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
- name: Create kind cluster
uses: helm/kind-action@v1.15.0
uses: helm/kind-action@v1.14.0
- name: Run chart-testing (install)
run: |
-11
View File
@@ -185,19 +185,8 @@ jobs:
if (Test-Path S:\walk) { throw "directory survived recursive delete" }
Write-Host "::endgroup::"
# Tear down everything we started so the runner's later steps (Logs,
# post-checkout) launch into a clean environment. A WinFsp mount left
# active and a weed.exe holding winfsp-x64.dll have been seen to make
# the next step's pwsh.exe fail with STATUS_DLL_INIT_FAILED
# (0xC0000142), which fails a job whose actual test step passed.
try { Stop-Mount } catch { Write-Host "no mount to stop at teardown: $_" }
Get-CimInstance Win32_Process -Filter "Name = 'weed.exe'" |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep -Seconds 3
- name: Logs
if: always()
continue-on-error: true
shell: pwsh
run: |
foreach ($f in 'C:\seaweed-mount.log','C:\seaweed-mount.err.log','C:\seaweed-remount.log','C:\seaweed-remount.err.log','C:\seaweed-remount2.log','C:\seaweed-remount2.err.log','C:\seaweed-dirmount.log','C:\seaweed-dirmount.err.log','C:\seaweed-mini.log','C:\seaweed-mini.err.log') {
+2 -3
View File
@@ -206,9 +206,8 @@ jobs:
# The dispatched workflow pins seaweedfs with `go get -u ...@latest`, so
# wait until the proxy serves the release commit as the tip. Asking for
# the commit by name is what makes the proxy fetch it. The proxy can
# take longer than five minutes to refresh @latest after a new tag.
for _ in $(seq 120); do
# the commit by name is what makes the proxy fetch it.
for _ in $(seq 30); do
curl -sf "https://proxy.golang.org/${MODULE}/@v/${SHA}.info" >/dev/null || true
TIP=$(curl -sf "https://proxy.golang.org/${MODULE}/@latest" | jq -r '.Origin.Hash // ""' || true)
[ "$TIP" = "$SHA" ] && break
@@ -59,18 +59,9 @@ jobs:
- name: Build Rust volume server
run: cd seaweed-volume && cargo build --release
# The crate is warning-free under clippy as of the sweep that added
# this step. Uncomment to make that a gate; `[lints.clippy]` in
# seaweed-volume/Cargo.toml is where crate-wide exceptions live.
# - name: Clippy
# run: cd seaweed-volume && cargo clippy --all-targets -- -D warnings
- name: Run Rust unit tests
run: cd seaweed-volume && cargo test
- name: Run Rust unit tests (redb experimental cursor)
run: cd seaweed-volume && cargo test --features redb-experimental-cursor --lib storage::needle_map
rust-integration-tests:
name: Rust Integration Tests
runs-on: ubuntu-22.04
-6
View File
@@ -73,12 +73,6 @@ jobs:
- name: Build the plugin workers
run: cd seaweed-worker && cargo build --release
# The workspace is warning-free under clippy as of the sweep that added
# this step. Uncomment to make that a gate; `[workspace.lints.clippy]`
# in seaweed-worker/Cargo.toml is where crate-wide exceptions live.
# - name: Clippy
# run: cd seaweed-worker && cargo clippy --workspace --all-targets -- -D warnings
# The tests that need a live gateway skip themselves without one, the way
# the Go integration tests skip without Docker; the lifecycle suite in
# test/s3tables/lifecycle is what runs them against a real cluster.
-6
View File
@@ -128,9 +128,6 @@ jobs:
echo "All SeaweedFS components are ready!"
cd ../s3-tests
sed -i "s/assert prefixes == \['foo%2B1\/', 'foo\/', 'quux%20ab\/'\]/assert prefixes == \['foo\/', 'foo%2B1\/', 'quux%20ab\/'\]/" s3tests/functional/test_s3.py
# The suite expects RGW's 400 InvalidPart for a partNumber past the last
# part; AWS answers 416 InvalidPartNumber, which is what we return.
sed -i "/# request PartNumber out of range/,+4{s/assert status == 400/assert status == 416/; s/assert error_code == 'InvalidPart'/assert error_code == 'InvalidPartNumber'/}" s3tests/functional/test_s3.py
# Debug: Show the config file contents
echo "=== S3 Config File Contents ==="
@@ -971,9 +968,6 @@ jobs:
echo "All SeaweedFS components are ready!"
cd ../s3-tests
sed -i "s/assert prefixes == \['foo%2B1\/', 'foo\/', 'quux%20ab\/'\]/assert prefixes == \['foo\/', 'foo%2B1\/', 'quux%20ab\/'\]/" s3tests/functional/test_s3.py
# The suite expects RGW's 400 InvalidPart for a partNumber past the last
# part; AWS answers 416 InvalidPartNumber, which is what we return.
sed -i "/# request PartNumber out of range/,+4{s/assert status == 400/assert status == 416/; s/assert error_code == 'InvalidPart'/assert error_code == 'InvalidPartNumber'/}" s3tests/functional/test_s3.py
# Create and update s3tests.conf to use port 8004
cp ../docker/compose/s3tests.conf ../docker/compose/s3tests-sql.conf
sed -i 's/port = 8000/port = 8004/g' ../docker/compose/s3tests-sql.conf
-74
View File
@@ -1,74 +0,0 @@
---
name: Star History
on:
schedule:
- cron: "0 0 * * *" # daily, 00:00 UTC
workflow_dispatch:
permissions:
contents: write
concurrency:
# Only one chart regeneration per branch at a time; a newer run on the same
# branch cancels an in-flight one so overlapping runs never conflict on
# note/star_history.svg during rebase. Scoped by ref so a manual run on
# another branch can't cancel the daily master update.
group: star-history-${{ github.ref }}
cancel-in-progress: true
jobs:
render:
name: Regenerate star history chart
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
# Full history so the chart commit can rebase onto a moved master.
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.x"
cache: pip
cache-dependency-path: .github/scripts/star_history.py
- name: Install matplotlib
run: pip install matplotlib
- name: Render chart
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python .github/scripts/star_history.py
- name: Commit if changed
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git diff --quiet -- note/star_history.svg; then
echo "No changes to the chart."
exit 0
fi
git add note/star_history.svg
git commit -m "docs: regenerate star history chart"
# Rebase and retry so a concurrent push to master doesn't lose the chart.
for attempt in 1 2 3 4 5; do
if [ "$attempt" -gt 1 ]; then
# Guard the rebase: a transient fetch error or conflict must not
# abort the fail-fast shell before the remaining attempts run.
if ! git pull --rebase origin "$GITHUB_REF_NAME"; then
echo "rebase failed (attempt ${attempt}); aborting and retrying"
git rebase --abort || true
continue
fi
fi
if git push origin HEAD:"$GITHUB_REF_NAME"; then
exit 0
fi
echo "push rejected (attempt ${attempt}); will rebase and retry"
done
echo "::error::could not push star history chart after retries"
exit 1
+29 -33
View File
@@ -12,6 +12,33 @@
![SeaweedFS Logo](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/seaweedfs.png)
<h2 align="center"><a href="https://www.patreon.com/seaweedfs">Sponsor SeaweedFS via Patreon</a></h2>
SeaweedFS is an independent Apache-licensed open source project with its ongoing development made
possible entirely thanks to the support of these awesome [backers](https://github.com/seaweedfs/seaweedfs/blob/master/backers.md).
If you'd like to grow SeaweedFS even stronger, please consider joining our
<a href="https://www.patreon.com/seaweedfs">sponsors on Patreon</a>.
Your support will be really appreciated by me and other supporters!
<!--
<h4 align="center">Platinum</h4>
<p align="center">
<a href="" target="_blank">
<img src="https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/sponsor_nodion.png" width="200" alt="nodion">
</a>
</p>
-->
### Gold Sponsors
[![nodion](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/sponsor_nodion.png)](https://www.nodion.com)
[![piknik](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/piknik.png)](https://www.piknik.com)
[![keepsec](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/keepsec.png)](https://www.keepsec.ca)
[![zyner](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/sponsor_zyner.png)](https://zyner.org)
---
SeaweedFS is a simple and highly scalable distributed file system. There are two objectives:
1. to store billions of files!
@@ -51,7 +78,6 @@ Table of Contents
* [Benchmark](#benchmark)
* [Enterprise](#enterprise)
* [License](#license)
* [Sponsors](#sponsors)
# Quick Start #
@@ -404,38 +430,8 @@ The text of this page is available for modification and reuse under the terms of
[Back to TOC](#table-of-contents)
# Sponsors #
<h3 align="center"><a href="https://www.patreon.com/seaweedfs">Sponsor SeaweedFS via Patreon</a></h3>
SeaweedFS is an independent Apache-licensed open source project with its ongoing development made
possible entirely thanks to the support of these awesome [backers](https://github.com/seaweedfs/seaweedfs/blob/master/backers.md).
If you'd like to grow SeaweedFS even stronger, please consider joining our
<a href="https://www.patreon.com/seaweedfs">sponsors on Patreon</a>.
Your support will be really appreciated by me and other supporters!
<!--
<h4 align="center">Platinum</h4>
<p align="center">
<a href="" target="_blank">
<img src="https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/sponsor_nodion.png" width="200" alt="nodion">
</a>
</p>
-->
### Gold Sponsors
[![nodion](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/sponsor_nodion.png)](https://www.nodion.com)
[![piknik](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/piknik.png)](https://www.piknik.com)
[![keepsec](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/keepsec.png)](https://www.keepsec.ca)
[![zyner](https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/note/sponsor_zyner.png)](https://zyner.org)
[Back to TOC](#table-of-contents)
## Star History
![Star History](note/star_history.svg)
## Stargazers over time
[![Stargazers over time](https://starchart.cc/seaweedfs/seaweedfs.svg?variant=adaptive)](https://starchart.cc/seaweedfs/seaweedfs)
[WeedMini]: https://github.com/seaweedfs/seaweedfs/wiki/Quick-Start-with-weed-mini
[DockerComposeS3]: https://github.com/seaweedfs/seaweedfs/wiki/Docker-Compose-for-S3
-418
View File
@@ -1,418 +0,0 @@
# SeaweedFS as an Apache CloudStack Object Storage Provider
A CloudStack ObjectStore plugin that makes SeaweedFS a first-class object storage
backend inside Apache CloudStack, alongside the existing MinIO and Ceph RGW
providers. This is a collaboration with proIO (Swen), who builds private clouds on
CloudStack and wants SeaweedFS as a storage option.
## The request
> We can only add MinIO and Ceph as object storage [in CloudStack] today. I want
> to get SeaweedFS into this project... What we need is to build a provider which
> does the communication between Cloudstack and SeaweedFS.
This is **not** a SeaweedFS-side feature. The work lives in the Apache CloudStack
repo (Java): a new plugin under `plugins/storage/object/seaweedfs/` that implements
CloudStack's ObjectStore plugin framework and talks to SeaweedFS over its S3 and
IAM APIs. SeaweedFS itself needs no changes for the core to work — its S3 API
already covers every bucket operation CloudStack requires, and its IAM API covers
user/credential management.
## How the CloudStack ObjectStore framework works
CloudStack 4.18+ introduced an Object Storage framework. An admin registers an
object storage pool via `addObjectStoragePool` (URL + provider + credentials);
tenants then create and manage buckets on it through CloudStack APIs. CloudStack
manages pool and bucket lifecycle; the underlying provider handles the actual
object protocol.
A provider is a plugin module implementing three interfaces:
### 1. `ObjectStoreProvider` — registration
`MinIOObjectStoreProviderImpl` is the reference. It is a Spring `@Component` that:
- Returns a provider name (`"MinIO"`)
- Returns `DataStoreProviderType.OBJECT`
- In `configure()`, injects the lifecycle and driver implementations and calls
`storeMgr.registerDriver(name, driver)`
### 2. `ObjectStoreLifeCycle` — pool add/remove
`MinIOObjectStoreLifeCycleImpl.initialize()` reads the URL, name, and
`accesskey`/`secretkey` details from the `addObjectStoragePool` call, tests the
connection by listing buckets, and persists an `ObjectStoreVO` via
`ObjectStoreHelper`. The other methods (attachCluster/Host/Zone, maintain,
deleteDataStore) are no-ops for object storage.
### 3. `ObjectStoreDriver` — bucket + user operations
`ObjectStoreDriver` (in `engine/storage/.../object/ObjectStoreDriver.java`) extends
`DataStoreDriver` and defines the bucket/user contract. Every provider must
implement:
| Method | Purpose |
| --- | --- |
| `createBucket(Bucket, boolean objectLock)` | Create a bucket |
| `listBuckets(long storeId)` | List all buckets |
| `deleteBucket(BucketTO, long storeId)` | Delete a bucket |
| `createUser(long accountId, long storeId)` | Provision a user + credentials for a CloudStack account |
| `setBucketPolicy` / `getBucketPolicy` / `deleteBucketPolicy` | Bucket policy CRUD |
| `setBucketEncryption` / `deleteBucketEncryption` | SSE config |
| `setBucketVersioning` / `deleteBucketVersioning` | Versioning enable/suspend |
| `setBucketQuota(BucketTO, long storeId, long size)` | Per-bucket quota |
| `getAllBucketsUsage(long storeId)` | Usage map for billing/accounting |
| `getBucketAcl` / `setBucketAcl` | ACLs (MinIO/Ceph return null / no-op) |
`BaseObjectStoreDriverImpl` provides no-op defaults for the `DataStoreDriver`
methods (`createAsync`, `deleteAsync`, `copyAsync`, `canCopy`, `resize`,
`getTO`, `getStoreTO`), so object-store providers only implement the bucket/user
methods above.
## How the four existing providers differ (and where SeaweedFS lands)
CloudStack ships four object-store providers. Three are relevant; the simulator
is a test stub.
| Concern | MinIO | Ceph RGW | Cloudian HyperStore | SeaweedFS |
| --- | --- | --- | --- | --- |
| Bucket CRUD | `MinioClient` (S3) | `AmazonS3` (AWS SDK v1) | `AmazonS3` (AWS SDK v1) | `AmazonS3` (AWS SDK v1) |
| Bucket policy | `MinioClient` | `AmazonS3` | `AmazonS3` | `AmazonS3` |
| Versioning | `MinioClient` | `AmazonS3` | `AmazonS3` | `AmazonS3` |
| Encryption | `MinioClient` | not implemented | `AmazonS3` | `AmazonS3` |
| **User creation** | `MinioAdminClient` | `RgwAdmin` | **`AmazonIdentityManagement`** | **`AmazonIdentityManagement`** |
| **Per-bucket quota** | `MinioAdminClient` | `RgwAdmin` | **not supported** (throws) | **S3 extension** (`PUT /{bucket}?seaweedfs-quota`, SigV4, `s3:PutBucketQuota`) |
| **Usage reporting** | `MinioAdminClient` | `RgwAdmin` | Cloudian admin API | S3 `ListObjectsV2` (MVP); Prometheus / SOSAPI `capacity.xml` (recommended) |
**Cloudian HyperStore is the direct precedent.** It is an S3-compatible store
that, like SeaweedFS, manages users via the **standard AWS IAM API** using the
AWS IAM Java SDK (`com.amazonaws.services.identitymanagement`). Its driver
(`CloudianHyperStoreObjectStoreDriverImpl`) and util
(`CloudianHyperStoreUtil`) are the template this design follows almost line for
line. Cloudian even validates the quota limitation the same way this design
proposes for the MVP: `setBucketQuota` throws for any non-zero size and only
accepts `0` (no quota).
The SeaweedFS plugin is therefore a **simpler Cloudian** — same AWS S3 + IAM SDK
clients, same store-details keys (`s3Url`, `iamUrl`, `accesskey`, `secretkey`),
same IAM-user-with-restricted-policy pattern, but with no proprietary admin
client at all (Cloudian has its own `CloudianClient` for its admin API; SeaweedFS
needs only S3 + IAM). For quota, the plugin uses a narrow SeaweedFS S3 extension
(see below); for usage reporting, it falls back to S3 `ListObjectsV2` in the MVP
and recommends Prometheus or SOSAPI `capacity.xml` for production scale.
### Quota via the S3 `?seaweedfs-quota` extension
SeaweedFS supports bucket quota natively (server-side enforcement via a
read-only flag when usage exceeds the limit). Rather than exposing the broad
admin REST API (which would require a global bearer token and grant cluster-wide
admin access), the integration uses a **narrow, scoped S3 subresource**:
- `PUT /{bucket}?seaweedfs-quota` — set bucket quota (IAM permission `s3:PutBucketQuota`)
- `GET /{bucket}?seaweedfs-quota` — get bucket quota (IAM permission `s3:GetBucketQuota`)
**PUT request body** (JSON):
```json
{"quota_size": 100, "quota_unit": "GB", "quota_enabled": true}
```
**GET response body** (JSON):
```json
{"quota_size": 107374182400, "quota_unit": "B", "quota_enabled": true}
```
Note: GET always returns `quota_unit: "B"` and the absolute byte count, not
the original unit. A disabled-but-retained quota returns a positive
`quota_size` with `quota_enabled: false`.
Quota is stored on the bucket's filer entry (positive = enabled, negative =
disabled but retained, zero = no quota), matching the existing admin REST API
behavior. When quota is cleared, the bucket's read-only flag is also lifted.
**Authentication** uses the existing S3 SigV4 flow — no new global secret is
needed. The CloudStack service credential (the `accesskey`/`secretkey` on the
object store) is the admin credential used for all driver operations: bucket
CRUD, IAM user provisioning, and quota management. It must have broad S3 and
IAM permissions. The per-account IAM users created by `createUser` are the
ones with restricted permissions (full S3 access except bucket
creation/deletion). A future hardening could split quota management onto a
separate credential scoped to only `s3:PutBucketQuota`/`s3:GetBucketQuota`,
but the MVP uses the single admin credential for simplicity, matching how
the MinIO and Ceph providers work.
The plugin's `setBucketQuota` signs and sends the `PUT /{bucket}?seaweedfs-quota`
request using the AWS SDK v1 `AWSS3V4Signer` for SigV4 signing, then sends the
signed request via `java.net.http.HttpClient` (the AWS S3 SDK doesn't natively
support custom subresources, so we sign manually and send the request
ourselves). The `seaweedfs-quota` query parameter is included in the signed
canonical query string.
### Usage reporting
`getAllBucketsUsage` must return a `Map<String, Long>` of bucket name → size.
MinIO uses `MinioAdminClient.getDataUsageInfo`; Ceph uses
`RgwAdmin.listBucketInfo`. SeaweedFS has no admin rollup endpoint, so the MVP
plugin computes it by listing buckets and summing object sizes via S3
`ListObjectsV2` — expensive for large stores.
For production scale, SeaweedFS already exposes per-bucket size in:
- **Prometheus metrics** (`bucket_size_bytes` gauge, refreshed every minute)
- **SOSAPI `capacity.xml`** (reports capacity, available space, and usage
through the S3 endpoint)
Operators should consume one of those instead of S3 list-based aggregation for
large deployments. The MVP's list-based approach is correct but slow; flag it as
a known limitation.
## SeaweedFS API surface (what the plugin relies on)
SeaweedFS exposes two relevant APIs, both AWS-compatible:
### S3 API (`weed s3`)
Full S3-compatible surface. Confirmed against the SeaweedFS S3 wiki and code:
- `CreateBucket`, `HeadBucket`, `ListBuckets`, `DeleteBucket`
- `PutBucketPolicy`, `GetBucketPolicy`, `DeleteBucketPolicy`
- `PutBucketVersioning` (Enabled / Suspended), `GetBucketVersioning`
- `PutBucketEncryption`, `GetBucketEncryption`, `DeleteBucketEncryption`
- `PutBucketAcl`, `GetBucketAcl`
- `ListObjectsV2`, `HeadObject`, `GetObject`, `PutObject`, `DeleteObject`
- Bucket quota via extended attributes / `s3.bucket.quota` (enforced server-side,
surfaced as a read-only state when exceeded — see PR #10224)
### IAM API (`weed iam` / `iamapi`)
AWS IAM-compatible REST endpoints, implemented in `weed/iamapi/`. Confirmed by
the test suite which uses the **AWS IAM SDK** (`aws-sdk-go/service/iam`) against
the same handlers CloudStack would call:
- `CreateUser`, `DeleteUser`, `ListUsers`, `GetUser`
- `CreateAccessKey`, `DeleteAccessKey`, `ListAccessKeys`
- `PutUserPolicy`, `GetUserPolicy`, `DeleteUserPolicy`
- `AttachUserPolicy`, `ListAttachedUserPolicies`
This means the CloudStack plugin can manage SeaweedFS users with the **AWS IAM
Java SDK** (`com.amazonaws.services.identitymanagement.AmazonIdentityManagement`),
exactly the way the AWS IAM Go SDK is used in SeaweedFS's own tests. No proprietary
admin client is needed. **Cloudian HyperStore already does exactly this** in the
CloudStack tree — the SeaweedFS plugin follows the same pattern.
## Design
### Module layout
New CloudStack plugin module, mirroring `plugins/storage/object/cloudian/`
(the closest precedent — same AWS S3 + IAM SDK approach):
```
plugins/storage/object/seaweedfs/
pom.xml
src/main/java/org/apache/cloudstack/storage/datastore/
driver/SeaweedFSObjectStoreDriverImpl.java
lifecycle/SeaweedFSObjectStoreLifeCycleImpl.java
provider/SeaweedFSObjectStoreProviderImpl.java
util/SeaweedFSObjectStoreUtil.java
src/test/java/org/apache/cloudstack/storage/datastore/
driver/SeaweedFSObjectStoreDriverImplTest.java
provider/SeaweedFSObjectStoreProviderImplTest.java
src/main/resources/META-INF/cloudstack/storage-object-seaweedfs/
module.properties
spring-storage-object-seaweedfs-context.xml
```
### `SeaweedFSObjectStoreProviderImpl`
Direct copy of `MinIOObjectStoreProviderImpl` with `providerName = "SeaweedFS"`,
injecting the SeaweedFS lifecycle and driver. Registers via
`storeMgr.registerDriver`.
### `SeaweedFSObjectStoreLifeCycleImpl`
Copy of `MinIOObjectStoreLifeCycleImpl`. `initialize()` reads `url`, `name`,
`accesskey`, `secretkey` from the `addObjectStoragePool` details map, tests the
connection by calling `AmazonS3.listBuckets()` against the SeaweedFS S3 endpoint,
and persists the `ObjectStoreVO`. No proprietary client needed — the AWS S3 SDK
is enough for the health check.
### `SeaweedFSObjectStoreDriverImpl`
The substantive class. Uses two AWS SDK v1 clients (same dependency Ceph already
pulls in, so no new CloudStack dependency):
- `AmazonS3` for bucket operations (path-style, endpoint-pinned, `us-east-1`
region placeholder — same as Ceph's `getS3Client`)
- `AmazonIdentityManagement` for user/credential operations, pointed at the
SeaweedFS IAM endpoint
#### Bucket operations — straightforward S3
| Interface method | Implementation |
| --- | --- |
| `createBucket` | `s3.createBucket(name)`; reject if `doesBucketExistV2`; persist access/secret key + URL on `BucketVO` (same as Ceph) |
| `listBuckets` | `s3.listBuckets()` → wrap as `BucketObject` (same as Ceph) |
| `deleteBucket` | `s3.deleteBucket(name)` (same as Ceph) |
| `setBucketPolicy` | `s3.setBucketPolicy(...)` with the same public/private JSON the MinIO/Ceph drivers build |
| `getBucketPolicy` / `deleteBucketPolicy` | `s3.getBucketPolicy` / `s3.deleteBucketPolicy` |
| `setBucketVersioning` | `s3.setBucketVersioningConfiguration(Enabled)` |
| `deleteBucketVersioning` | `s3.setBucketVersioningConfiguration(Suspended)` |
| `setBucketEncryption` | `s3.setBucketEncryptionConfiguration(SSE-S3 rule)` |
| `deleteBucketEncryption` | `s3.deleteBucketEncryptionConfiguration` |
| `getBucketAcl` / `setBucketAcl` | no-op / null (same as MinIO and Ceph) |
#### User creation — the key difference
MinIO calls `MinioAdminClient.addUser`; Ceph calls `RgwAdmin.createUser`. SeaweedFS
exposes the standard AWS IAM API, so the plugin calls:
```java
AmazonIdentityManagement iam = getIamClient(storeId);
String userName = "acs-" + account.getUuid();
// CreateUser (idempotent — check GetUser first, like Ceph does)
iam.createUser(new CreateUserRequest(userName));
// CreateAccessKey → returns the access key + secret key to persist
CreateAccessKeyResult result = iam.createAccessKey(
new CreateAccessKeyRequest().withUserName(userName));
AccessKey key = result.getAccessKey();
// Persist per-account, same pattern as Ceph's CEPH_ACCESS_KEY/CEPH_SECRET_KEY
details.put(SEAWEEDFS_ACCESS_KEY, key.getAccessKeyId());
details.put(SEAWEEDFS_SECRET_KEY, key.getSecretAccessKey());
_accountDetailsDao.persist(accountId, details);
```
This is the cleanest mapping of the three providers: no proprietary admin client,
just the AWS IAM SDK that CloudStack already has access to. The IAM endpoint URL
is provided as `iamUrl` in the store details. If `iamUrl` is omitted, the driver
defaults it to `s3Url` — SeaweedFS registers its embedded IAM API at `POST /` on
the same S3 endpoint (`UnifiedPostHandler` in `s3api_server.go`), so the IAM
endpoint is the same as the S3 endpoint unless the deployment runs a separate
`weed iam` server.
#### Bucket quota — S3 `?seaweedfs-quota` extension
This is the one genuine gap. MinIO and Ceph both have an admin API to set a
per-bucket quota that the backend enforces. SeaweedFS enforces bucket quota
server-side, but the configuration path was **not exposed over a standard S3 or
IAM API** — it was only set via the admin REST API or shell commands.
The integration adds a **narrow S3 subresource** to SeaweedFS:
- `PUT /{bucket}?seaweedfs-quota` — set bucket quota (IAM permission `s3:PutBucketQuota`)
- `GET /{bucket}?seaweedfs-quota` — get bucket quota (IAM permission `s3:GetBucketQuota`)
This is implemented in SeaweedFS PR #11279. It uses SigV4 authentication and
dedicated IAM permissions, so the CloudStack service credential can be scoped
to quota management only — no global admin token, no cluster-wide admin access.
The enforcement already exists (PR #10224); this PR only adds the HTTP
configuration surface.
An earlier approach (PR #11278, closed) added bearer-token auth to the broad
admin REST API. After review, that was unnecessary for this integration —
static S3 config plus standard S3 APIs plus one scoped quota mutation API is
sufficient and far safer.
> **Note on AWS tools compatibility.** `?seaweedfs-quota` is a SeaweedFS-specific
> S3 subresource, not part of the AWS S3 API. Standard AWS tools (`aws s3api`,
> `s3cmd`, `rclone`) cannot call it directly. This is the same limitation MinIO
> and Ceph have — MinIO quota lives behind a separate admin API (`mc admin
> bucket quota`), and Ceph quota lives behind the Admin Ops API
> (`radosgw-admin quota set`). Neither is callable via `aws s3api` either.
> SeaweedFS's approach is the closest to standard S3 because it uses the same
> endpoint and same SigV4 credentials, just with a custom query parameter.
> Interactive quota management remains available via `weed shell`; the S3
> extension exists for programmatic integration (CloudStack) where the
> integrator can sign SigV4 requests but cannot run shell commands.
#### Usage reporting
`getAllBucketsUsage` must return a `Map<String, Long>` of bucket name → size.
MinIO uses `MinioAdminClient.getDataUsageInfo`; Ceph uses
`RgwAdmin.listBucketInfo`. SeaweedFS has no admin rollup endpoint, so the MVP
plugin computes it by listing buckets and summing object sizes via S3
`ListObjectsV2` — expensive for large stores. Better options exist in
SeaweedFS already:
- **Prometheus metrics** (`bucket_size_bytes` gauge, refreshed every minute)
- **SOSAPI `capacity.xml`** (reports capacity, available space, and usage
through the S3 endpoint — note: the current "return zero on backend error"
behavior should be validated before using it for billing)
For the MVP, `listBuckets` + per-bucket size via the S3 API is correct but slow;
flag it as a known limitation. Operators should consume Prometheus or SOSAPI
for production-scale usage reporting.
### Spring wiring
`spring-storage-object-seaweedfs-context.xml` registers the provider bean,
identical to the MinIO one. `module.properties` sets
`name=storage-object-seaweedfs`, `parent=storage`.
### `pom.xml`
Depends on `aws-java-sdk-s3` and `aws-java-sdk-iam` — both already in the
CloudStack dependency tree (Ceph uses the S3 SDK; the IAM SDK is the standard AWS
bundle). No new third-party dependency, unlike MinIO which pulls in the MinIO
Java client.
## What changes on the SeaweedFS side
**One narrow S3 extension is required for quota management.** SeaweedFS PR #11279
adds the `?seaweedfs-quota` S3 subresource:
- `PUT /{bucket}?seaweedfs-quota` — set bucket quota (IAM permission `s3:PutBucketQuota`)
- `GET /{bucket}?seaweedfs-quota` — get bucket quota (IAM permission `s3:GetBucketQuota`)
This is authenticated via standard S3 SigV4 and authorized via dedicated IAM
permissions, so no global admin token is needed. The enforcement already exists
(PR #10224); this PR only adds the HTTP configuration surface.
One follow-up improvement on the SeaweedFS side would close the usage reporting
gap:
1. **Validate SOSAPI `capacity.xml` usage calculation** — the current "return
zero on backend error" behavior should be validated before using it for
billing. If reliable, CloudStack can consume it directly instead of
list-based aggregation.
## Open questions for proIO / Swen
1. **IAM endpoint path.** ~~Where does `weed iam` listen relative to the S3
endpoint in a typical proIO deployment?~~ **Resolved.** SeaweedFS registers
its embedded IAM API at `POST /` on the same S3 endpoint
(`UnifiedPostHandler`), so the driver defaults `iamUrl` to `s3Url`. A
separate `iamUrl` is only needed if the deployment runs a standalone
`weed iam` server on a different host/port.
2. **Quota requirements.** Do proIO's customers need server-enforced per-bucket
quotas, or is CloudStack-side accounting sufficient for the first release?
The `?seaweedfs-quota` S3 extension (PR #11279) provides server-enforced
quotas via a scoped credential; this is the recommended path.
3. **Object Lock.** `createBucket` takes an `objectLock` boolean. MinIO supports
it; Ceph ignores it. SeaweedFS has Object Lock support. Should the plugin pass
it through?
4. **Contribution model.** Does proIO want to submit the PR to
`apache/cloudstack` themselves (with SeaweedFS maintainers as reviewers), or
the reverse? Apache CloudStack requires an ICLA for non-trivial contributions.
## Files
All in the `apache/cloudstack` repo (new module):
| File | Purpose |
| --- | --- |
| `plugins/storage/object/seaweedfs/pom.xml` | Maven module |
| `.../datastore/util/SeaweedFSObjectStoreUtil.java` | S3 + IAM client builders, constants, URL validators |
| `.../datastore/provider/SeaweedFSObjectStoreProviderImpl.java` | Spring provider registration |
| `.../datastore/lifecycle/SeaweedFSObjectStoreLifeCycleImpl.java` | Pool add/health-check |
| `.../datastore/driver/SeaweedFSObjectStoreDriverImpl.java` | Bucket + user ops via S3 + IAM SDK |
| `.../resources/META-INF/cloudstack/storage-object-seaweedfs/module.properties` | Module name |
| `.../resources/META-INF/cloudstack/storage-object-seaweedfs/spring-storage-object-seaweedfs-context.xml` | Spring bean |
| `plugins/pom.xml` | Register `storage/object/seaweedfs` module |
No files in `seaweedfs/seaweedfs` for the MVP.
### SeaweedFS-side changes (PR #11279)
| File | Purpose |
| --- | --- |
| `weed/s3api/s3_constants/s3_action_strings.go` | Add `S3_ACTION_PUT_BUCKET_QUOTA` and `S3_ACTION_GET_BUCKET_QUOTA` |
| `weed/s3api/s3_constants/s3_actions.go` | Add coarse-grained `ACTION_PUT_BUCKET_QUOTA` and `ACTION_GET_BUCKET_QUOTA` |
| `weed/s3api/s3_action_resolver.go` | Map `seaweedfs-quota` query param to fine-grained s3: actions |
| `weed/s3api/s3api_bucket_quota_handlers.go` | New — `PutBucketQuotaHandler` and `GetBucketQuotaHandler` |
| `weed/s3api/s3api_bucket_quota_handlers_test.go` | New — tests for unit conversion, validation, and error paths |
| `weed/s3api/s3api_server.go` | Register the two routes in the bucket subrouter |
+55 -59
View File
@@ -1,11 +1,11 @@
module github.com/seaweedfs/seaweedfs
go 1.26.0
go 1.26
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/pubsub v1.51.1
cloud.google.com/go/storage v1.67.0
cloud.google.com/go/storage v1.64.0
github.com/Shopify/sarama v1.38.1
github.com/aws/aws-sdk-go v1.55.8
github.com/beorn7/perks v1.0.1 // indirect
@@ -25,7 +25,7 @@ require (
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-redsync/redsync/v4 v4.17.0
github.com/go-sql-driver/mysql v1.10.1
github.com/go-sql-driver/mysql v1.10.0
github.com/go-zookeeper/zk v1.0.4 // indirect
github.com/golang/protobuf v1.5.4
github.com/golang/snappy v1.0.0
@@ -44,7 +44,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.19.2
github.com/klauspost/compress v1.19.1
github.com/klauspost/reedsolomon v1.14.2
github.com/kurin/blazer v0.5.3
github.com/linxGnu/grocksdb v1.10.8
@@ -59,13 +59,13 @@ require (
github.com/posener/complete v1.2.3
github.com/pquerna/cachecontrol v0.2.0
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.3
github.com/prometheus/common v0.70.1
github.com/prometheus/client_model v0.6.2
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.22.0
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 v2.0.0+incompatible
github.com/seaweedfs/raft v1.2.1
github.com/seaweedfs/raft v1.2.0
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@@ -90,16 +90,16 @@ require (
gocloud.dev v0.46.0
gocloud.dev/pubsub/natspubsub v0.46.0
gocloud.dev/pubsub/rabbitpubsub v0.46.0
golang.org/x/crypto v0.56.0
golang.org/x/crypto v0.55.0
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597
golang.org/x/image v0.46.0
golang.org/x/image v0.44.0
golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.48.0
golang.org/x/text v0.42.0 // indirect
golang.org/x/tools v0.49.0 // indirect
golang.org/x/sys v0.47.0
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.297.0
google.golang.org/api v0.296.0
google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect
google.golang.org/grpc v1.85.0-dev
google.golang.org/protobuf v1.36.12
@@ -118,19 +118,19 @@ require (
github.com/ThreeDotsLabs/watermill v1.5.2
github.com/a-h/templ v0.3.1020
github.com/apache/cassandra-gocql-driver/v2 v2.1.2
github.com/apache/iceberg-go v0.6.1-0.20260817192109-c2105090c9e2
github.com/apache/iceberg-go v0.6.0
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3
github.com/arangodb/go-driver v1.6.9
github.com/armon/go-metrics v0.4.1
github.com/aws/aws-sdk-go-v2 v1.47.0
github.com/aws/aws-sdk-go-v2/config v1.32.35
github.com/aws/aws-sdk-go-v2/credentials v1.20.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3
github.com/aws/aws-sdk-go-v2 v1.45.1
github.com/aws/aws-sdk-go-v2/config v1.32.33
github.com/aws/aws-sdk-go-v2/credentials v1.20.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2
github.com/cespare/xxhash/v2 v2.3.0
github.com/cognusion/imaging v1.0.4
github.com/fluent/fluent-logger-golang v1.10.1
github.com/getsentry/sentry-go v0.49.0
github.com/go-ldap/ldap/v3 v3.4.14
github.com/getsentry/sentry-go v0.48.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
github.com/hashicorp/golang-lru/v2 v2.0.7
@@ -144,23 +144,23 @@ require (
github.com/parquet-go/parquet-go v0.32.0
github.com/pkg/sftp v1.13.11
github.com/rabbitmq/amqp091-go v1.14.0
github.com/rclone/rclone v1.75.1
github.com/rclone/rclone v1.75.0
github.com/rdleal/intervalst v1.5.0
github.com/redis/go-redis/v9 v9.22.0
github.com/redis/go-redis/v9 v9.21.0
github.com/schollz/progressbar/v3 v3.19.1
github.com/seaweedfs/go-fuse/v2 v2.9.4
github.com/shirou/gopsutil/v4 v4.26.7
github.com/tarantool/go-option v1.1.0
github.com/tarantool/go-tarantool/v3 v3.0.1
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go v0.43.0
github.com/tikv/client-go/v2 v2.0.7
github.com/twmb/avro v1.8.0
github.com/twmb/avro v1.7.2
github.com/xeipuuv/gojsonschema v1.2.0
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.2
github.com/ydb-platform/ydb-go-sdk/v3 v3.151.1
go.etcd.io/etcd/client/pkg/v3 v3.7.1
go.uber.org/atomic v1.11.0
golang.org/x/sync v0.23.0
golang.org/x/sync v0.22.0
golang.org/x/tools/godoc v0.1.0-deprecated
google.golang.org/grpc/security/advancedtls v1.0.0
)
@@ -178,18 +178,16 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.39 // indirect
github.com/ProtonMail/gopenpgp/v3 v3.4.1 // indirect
github.com/RoaringBitmap/roaring/v2 v2.24.0 // 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.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/apache/arrow-go/v18 v18.7.0 // indirect
github.com/apache/thrift v0.24.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bazelbuild/rules_go v0.46.0 // indirect
github.com/biogo/store v0.0.0-20201120204734-aad293a2328f // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/buger/jsonparser v1.2.0 // indirect
@@ -209,12 +207,11 @@ require (
github.com/dave/dst v0.27.2 // indirect
github.com/diskfs/go-diskfs v1.9.4 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dromara/dongle v1.0.1 // indirect
github.com/geoarrow/geoarrow-go v0.0.0-20260403143023-f54751c3e3a1 // indirect
github.com/gin-gonic/gin v1.11.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
@@ -243,14 +240,13 @@ require (
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.3.0 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.1 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
@@ -262,8 +258,8 @@ require (
github.com/pquerna/otp v1.5.0 // indirect
github.com/pterm/pterm v0.12.83 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/rclone/Proton-API-Bridge v1.0.5 // indirect
github.com/rclone/go-proton-api v1.0.4 // indirect
github.com/rclone/Proton-API-Bridge v1.0.4 // indirect
github.com/rclone/go-proton-api v1.0.3 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
@@ -283,14 +279,14 @@ require (
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/mock v0.5.2 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/mod v0.38.0 // indirect
gonum.org/v1/gonum v0.17.0 // indirect
)
@@ -315,7 +311,7 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
github.com/IBM/go-sdk-core/v5 v5.23.1 // indirect
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // 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
@@ -326,21 +322,21 @@ require (
github.com/andybalholm/cascadia v1.3.4 // 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.18 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/aws-sdk-go-v2/service/sns v1.39.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.24 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1
github.com/aws/smithy-go v1.28.1
github.com/boltdb/bolt v1.3.1 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
@@ -373,7 +369,7 @@ require (
github.com/go-chi/chi/v5 v5.3.1 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.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.8 // indirect
@@ -438,7 +434,7 @@ require (
github.com/oracle/oci-go-sdk/v65 v65.121.0 // indirect
github.com/panjf2000/ants/v2 v2.12.1 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.4.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.29
@@ -490,13 +486,13 @@ require (
go.etcd.io/etcd/api/v3 v3.7.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/sdk v1.45.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/term v0.45.0
+158 -166
View File
@@ -485,8 +485,8 @@ cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq
cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc=
cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s=
cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
cloud.google.com/go/storage v1.67.0 h1:8xD3NvKuHE4c+b/kmSIVBdTc0BiOjplx+KecKGR9fr8=
cloud.google.com/go/storage v1.67.0/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM=
cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSPZg=
cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ=
cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w=
cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I=
cloud.google.com/go/storagetransfer v1.7.0/go.mod h1:8Giuj1QNb1kfLAiWM1bN6dHzfdlDAVC9rv9abHot2W4=
@@ -587,8 +587,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679 h1:qNT7R4qrN+5u5ajSbqSW1opHP4LA8lzA+ASyw5MQZjs=
github.com/DefangLabs/secret-detector v0.0.0-20250811234530-d4b4214cd679/go.mod h1:blbwPQh4DTlCZEfk1BLU4oMIhLda2U+A840Uag9DsZw=
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.194 h1:dtOFxSTWWRpkmvXa6ycNiw8dVDu1wkgzcXyVV1VafNc=
@@ -620,8 +620,8 @@ github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYr
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE=
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIfvESpM3GD07OCHU7fqI7lhwyZ2Td1rbNbTAhnc=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY=
github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
@@ -637,8 +637,6 @@ github.com/ProtonMail/gopenpgp/v3 v3.4.1 h1:K7uUhSHSJxORZ+RuHpilTT6S4MA2whCRlXNw
github.com/ProtonMail/gopenpgp/v3 v3.4.1/go.mod h1:bGdV9f6edhmd581wzXsQCTKdH8bXBbyhkgDKPjwPc6U=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/RoaringBitmap/roaring/v2 v2.24.0 h1:zQkkBZtG3WRP4j+P3A5DO221SvL1Br88TJkhyqEQRZo=
github.com/RoaringBitmap/roaring/v2 v2.24.0/go.mod h1:SfT3of9nYh3vis1dIbCj4Yw6KQGujTN+f345nrN/0JA=
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=
@@ -692,8 +690,8 @@ github.com/apache/arrow-go/v18 v18.7.0/go.mod h1:PM6IigLJkdMwIpeHXnymo+xZ52f42a9
github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
github.com/apache/cassandra-gocql-driver/v2 v2.1.2 h1:lu/p0Db2av18enHJvWJQoChLssI0P+AR06STq4VdvCc=
github.com/apache/cassandra-gocql-driver/v2 v2.1.2/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4=
github.com/apache/iceberg-go v0.6.1-0.20260817192109-c2105090c9e2 h1:xRULj4L2wrlAAPAYNruyuP17lz2zq2DGLgIFDgqiBjo=
github.com/apache/iceberg-go v0.6.1-0.20260817192109-c2105090c9e2/go.mod h1:u6gs2aFRl7QOL0FqfHX6DOzvxOyev6mmq3XT+3VUS9M=
github.com/apache/iceberg-go v0.6.0 h1:tOVhC5BhBOEgPTowo5AVrPnAgsDo00qEbUPprFcLd4s=
github.com/apache/iceberg-go v0.6.0/go.mod h1:kESfDlyaW/6hK0WW6TzA4EWps+0NMhhrDYE4qajVjlo=
github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
github.com/apache/thrift v0.24.0 h1:zy31L1a49QTNB2bG1BBfMXol3yJrTH975G3pPubQVLQ=
github.com/apache/thrift v0.24.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs=
@@ -710,48 +708,48 @@ 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.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ=
github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 h1:LAfOuhAH331fmOjTQpAaOlH+Ftn7RzSDJ2VFwjdMMy4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18/go.mod h1:4e5xhuXHx1e4U9EthvbPP1r/DIMp5c2823OL8karzcM=
github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E=
github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g=
github.com/aws/aws-sdk-go-v2/credentials v1.20.4 h1:hTvrJJseKbvw32kmiE0G+u/9ZqpqscjDrTigHIXP2qs=
github.com/aws/aws-sdk-go-v2/credentials v1.20.4/go.mod h1:gWp9O1ZBWwpcIrgV+mVHk4gZUurAEDkgypu/OXOlIaw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 h1:AM4hHjww+PSFtt6E+UrBrPlZkWsePCLEt9AjkfQX+yM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0/go.mod h1:3x/yXezeQjpOvBb4jEMxrS8SXvpdvJ5abv6l5c1gWM8=
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
github.com/aws/aws-sdk-go-v2/config v1.32.33 h1:M1m/Q6f0OKDEDGwhiNOqx1OjTdrewe3v+GDbHmKczWk=
github.com/aws/aws-sdk-go-v2/config v1.32.33/go.mod h1:fGj1iQj2QpIZzp7jE4aQQ+71TE8cd4z9K4+xCd6EqmE=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34 h1:Pn7OsMwBLbkZ6OnCxWHAjf0L/22H8cnhxZC0uPwtMtg=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34/go.mod h1:eToXR/Gk1uqpn04eSmdgVXwfS0WvH8aG4eBFr8ygbpU=
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.11 h1:eBXB8KZgzQ8A9QB4iJS4aw/u6+4OY3i2hQXPABeAIOg=
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.11/go.mod h1:N9+5pG27Fy61GUL5YXVLXDTLmUudMrgwsuDbgBMNLxQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A=
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 h1:w5OoDiMN6x53ROmiIImGzmVcxXv2q1GXY+aKV4WAJYM=
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3/go.mod h1:dAhgYp776bX3LuWvnSCFwQEjNs6fuFg7YXIy5PXcP3Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 h1:uZOinZb+h7lZw8IYzP1z1IuEnueB76/EFkcf/fEW4Ag=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31/go.mod h1:NRtwAM/p5VRt03TlEUs0pH3TeWamWdf4YyJpSrzPYLc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 h1:HLPAVrlLDaN2boN0xJx7MgaQDNEO3Q+c9L6kl/8m47Q=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39/go.mod h1:Pg/dVfsNkm1hsIDK/gMvCKtmyNfNTV12mrgHqVE/6Oo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 h1:IKoCZqfWfZzSBi16QFQ+QcbQ3LRQ7QgB1S5tDAyPBQQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3/go.mod h1:RBpRcXiM4s2pOInVs32GsBonnje+fiAj4mcrStRmlCA=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 h1:5C00eQYpTrgQXnp6V3P6P7zPElna3AXvlukbANE6nJI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
github.com/aws/aws-sdk-go-v2/service/sns v1.39.14 h1:p8WdWDh5AwSZdp19Haa3XMyPCICi9Z375a/Nu3IIEZY=
github.com/aws/aws-sdk-go-v2/service/sns v1.39.14/go.mod h1:NKVY7DER6VXHkt2I/ycmHakALNboi3Rqwt4eEf/1Cnk=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.24 h1:JP2wjWGmUp8lTCZb13Dv0Eciyc1jbO8pd0HZVMHFlrc=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.24/go.mod h1:Ql9ziDutk8ERAN9HMaYANCW3lop451ppebkxEJMLCTM=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0=
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 h1:khXV3+K5D3f4e8xtplaRdSFn1bEg3gj5EBHQvbCOZbQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
@@ -765,8 +763,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/biogo/store v0.0.0-20201120204734-aad293a2328f h1:+6okTAeUsUrdQr/qN7fIODzowrjjCrnJDg/gkYqcSXY=
github.com/biogo/store v0.0.0-20201120204734-aad293a2328f/go.mod h1:z52shMwD6SGwRg2iYFjjDwX5Ene4ENTw6HfXraUy/08=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
@@ -865,17 +861,17 @@ github.com/cognusion/imaging v1.0.4 h1:hrmWVa4S9fiLJJFKEHYYRNBUnHEKZD8/oxnC77xTU
github.com/cognusion/imaging v1.0.4/go.mod h1:X1mPSSZpMyiva54chCO/1NROp+b6xG4uMcud8VLii+U=
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.12.1 h1:+xBZNxcgSus4atQJwXPEdhHRgCEyZmj/BuqN5m33Ou0=
github.com/compose-spec/compose-go/v2 v2.12.1/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
github.com/compose-spec/compose-go/v2 v2.10.2 h1:USa1NUbDcl/cjb8T9iwnuFsnO79H+2ho2L5SjFKz3uI=
github.com/compose-spec/compose-go/v2 v2.10.2/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/containerd/containerd/api v1.11.1 h1:h8nfoDW9+fNsC/9TwiAHj8B1GzXKtR4eFtkhi/X5RLU=
github.com/containerd/containerd/api v1.11.1/go.mod h1:CaQFRu+N1MtbgL6JDOJLUB1hCKESU1lD6MuTJhgtdlw=
github.com/containerd/containerd/v2 v2.2.5 h1:KTFzB02LviYmmfRmz8r9UFd+n6YlddVFK+5lbgQXUTU=
github.com/containerd/containerd/v2 v2.2.5/go.mod h1:5t2+xFv2dGd/iDYp9Z8DXB4cmWrWQi1XqxGJPS2gBzU=
github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg=
github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/containerd/api v1.10.0 h1:5n0oHYVBwN4VhoX9fFykCV9dF1/BvAXeg2F8W6UYq1o=
github.com/containerd/containerd/api v1.10.0/go.mod h1:NBm1OAk8ZL+LG8R0ceObGxT5hbUYj7CzTmR3xh0DlMM=
github.com/containerd/containerd/v2 v2.2.2 h1:mjVQdtfryzT7lOqs5EYUFZm8ioPVjOpkSoG1GJPxEMY=
github.com/containerd/containerd/v2 v2.2.2/go.mod h1:5Jhevmv6/2J+Iu/A2xXAdUIdI5Ah/hfyO7okJ4AFIdY=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -886,8 +882,8 @@ github.com/containerd/platforms v1.0.0-rc.4 h1:M42JrUT4zfZTqtkUwkr0GzmUWbfyO5VO0
github.com/containerd/platforms v1.0.0-rc.4/go.mod h1:lKlMXyLybmBedS/JJm11uDofzI8L2v0J2ZbYvNsbq1A=
github.com/containerd/ttrpc v1.2.8 h1:xbVu6D4qF2jihdh9rDVOKqUMiFBQk6YctTdo1zk087Y=
github.com/containerd/ttrpc v1.2.8/go.mod h1:wyZW2K79t4Hfcxl+GUvkZqRBzJlqFFvgEeeWXa42tyE=
github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ=
github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w=
github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40=
github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
@@ -931,18 +927,18 @@ github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/docker/buildx v0.35.0 h1:5r/ZcAC0s2oZ1XOJHvYFYbj44I4rxRsIdfsRBeXClks=
github.com/docker/buildx v0.35.0/go.mod h1:kO/9ZBoZmhi1OsZbeLJqWclckB2TKu/yeW6aj6xxdt0=
github.com/docker/cli v29.6.0+incompatible h1:nw9himxMMZ7eIeherJNlKQq+acnlzGgHd+4uf10QRSc=
github.com/docker/cli v29.6.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/compose/v5 v5.2.0 h1:WuZgtAM91BjFWy7xMKcM2Huq9OksVqRgkgBKFiEXs2Q=
github.com/docker/compose/v5 v5.2.0/go.mod h1:tjYsBMooQQZ8XGtVoUnc+/T9peXtkMmTdmMakW8/ZXE=
github.com/docker/buildx v0.33.0 h1:xuZeuQe/C/2tvLDgiIA6+Ynq3FFWSfsGNWIHM3q1hD8=
github.com/docker/buildx v0.33.0/go.mod h1:7JVma62htERKE5iy5YD1q64PKiAHUzXuhSBd4oq3I74=
github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM=
github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/compose/v5 v5.1.2 h1:HxtfGZA0DESw/+hvrNJDjM8VKmCond7OkmgVJCHku48=
github.com/docker/compose/v5 v5.1.2/go.mod h1:JJ2H+lRSugOH50/zxCbkBwN8jU91qDtRCp7gEHWZdgo=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q=
github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY=
github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
@@ -1034,19 +1030,17 @@ 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/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/geoarrow/geoarrow-go v0.0.0-20260403143023-f54751c3e3a1 h1:8VcjuP5pKZ717K4zOLS9Lm4Zsn8JwD9X+CQGFCiRT2E=
github.com/geoarrow/geoarrow-go v0.0.0-20260403143023-f54751c3e3a1/go.mod h1:XMOIOA5J96jEzAOpBROPyz0n14sHsooeF+y2FtGdrCY=
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.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s=
github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI=
github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY=
github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68=
@@ -1070,14 +1064,14 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
@@ -1109,8 +1103,8 @@ github.com/go-redsync/redsync/v4 v4.17.0 h1:FFJ+uxZs44y4Sq10//IFKic9T94AYl+u3Sog
github.com/go-redsync/redsync/v4 v4.17.0/go.mod h1:CKVA6qwT07S/916i+Yd9h1/8YFQhCCpPYTQhvvYytJo=
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-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@@ -1367,8 +1361,8 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk=
github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk=
github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E=
github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM=
github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA=
github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -1453,8 +1447,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
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.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/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=
@@ -1524,8 +1518,8 @@ github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzp
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.13 h1:DC0OMEpGjm6LfNFU4ckYcvbQKyp2vE8atyFGXNtDcf4=
github.com/mattn/go-shellwords v1.0.13/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
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=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
@@ -1546,18 +1540,18 @@ github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 h1:BpfhmL
github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mmcloughlin/geohash v0.9.0 h1:FihR004p/aE1Sju6gcVq5OLDqGcMnpBY+8moBqIsVOs=
github.com/mmcloughlin/geohash v0.9.0/go.mod h1:oNZxQo5yWJh0eMQEP/8hwQuVx9Z9tjwFUqcTB1SmG0c=
github.com/moby/buildkit v0.31.0 h1:hMUAbQGgjtzJDDOZ6o7MQk5XBZkBTyzLWEvnjguHHQI=
github.com/moby/buildkit v0.31.0/go.mod h1:YM5iNEbNCc6L1Zt3YWFB/aXNLufvf4Rcu0DPlc9HwQg=
github.com/moby/buildkit v0.29.0 h1:wxLEFbCOJntEDjSNNN2YWd8zxltZxT5muDQ0LzpbtpU=
github.com/moby/buildkit v0.29.0/go.mod h1:Dmv2FeDe34t75QuzeU87rBoZpAAkcpT5zeu4hXzmASc=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.3.0 h1:nos4BtzzUIqB406BgQnWGMI4qib9BZ8XUHU+ucv/n1c=
github.com/moby/go-archive v0.3.0/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@@ -1589,8 +1583,6 @@ github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -1652,8 +1644,8 @@ github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144T
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.4.1 h1:j5OMOImsH+j2k7GJ5YO+RxfWwohNiH6t5zB/+h3bagc=
github.com/pelletier/go-toml/v2 v2.4.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 h1:XeOYlK9W1uCmhjJSsY78Mcuh7MVkNjTzmHx1yBzizSU=
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14/go.mod h1:jVblp62SafmidSkvWrXyxAme3gaTfEtWwRPGz5cpvHg=
github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw=
@@ -1726,8 +1718,8 @@ github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
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.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
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=
@@ -1760,18 +1752,18 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek=
github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rclone/Proton-API-Bridge v1.0.5 h1:K1++Qtk3PvgkiCCiv6Pahju1TMOzKY6VSwiwT7XLAVc=
github.com/rclone/Proton-API-Bridge v1.0.5/go.mod h1:vCeOPhlXzevN0AFojgh1zsjhetiShy/ArvJ/xkFUDWk=
github.com/rclone/go-proton-api v1.0.4 h1:AJW0e9pB4j0hVK4WqyGErFwaI+5MUQWPCtj5FYYxtPg=
github.com/rclone/go-proton-api v1.0.4/go.mod h1:QAlkFfswzrBuxvCORWV8rZdddg52hahMN98CFWoFW1E=
github.com/rclone/rclone v1.75.1 h1:kIxQcoDLj2Gke/gMSHK7OnxhX1Gu1cJBLP1kJZoaFp0=
github.com/rclone/rclone v1.75.1/go.mod h1:4zmMjGatCkSJPRZDpo+7y3xOl8S29EMUyKvZop5mHr4=
github.com/rclone/Proton-API-Bridge v1.0.4 h1:uGQJRjQC1hVLd5kqLsXc6CWO6oqrVeLoKQYoHapEZDg=
github.com/rclone/Proton-API-Bridge v1.0.4/go.mod h1:VTPBYZotKAeDLlAzxU2O/s14NXk9FxUt9hn1jhH2iY8=
github.com/rclone/go-proton-api v1.0.3 h1:3gBTzR+j0dYiTwtj9yKIdN/aV3W2a8KIPKp0GArojyQ=
github.com/rclone/go-proton-api v1.0.3/go.mod h1:QAlkFfswzrBuxvCORWV8rZdddg52hahMN98CFWoFW1E=
github.com/rclone/rclone v1.75.0 h1:3ARHem4jXWltvl+b0PvDAG8s6J/inHd5BRzfwMRb3W8=
github.com/rclone/rclone v1.75.0/go.mod h1:PGLJUW/WSIJCysALqUcxmaCFyfMXUevf8CbuoOwsAdU=
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.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/redis/rueidis v1.0.76 h1:RdDWuvlYBSp+bTrBvaXqJnNEL3VVzsnjo+0psPFgLc4=
github.com/redis/rueidis v1.0.76/go.mod h1:UsfHPSbomB6QAVMk4iiFkzRy0nh9o7scDGa+SitvBY4=
github.com/redis/rueidis/rueidiscompat v1.0.76 h1:7LikbiqCQqCsZXeZ+akgZMnjIV/J0VHih9PIX4gGZC4=
@@ -1809,8 +1801,8 @@ github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDc
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
github.com/schollz/progressbar/v3 v3.19.1 h1:iv8BgwOvdML/S3p84uBpy/IMigv4U9594vPZYa2EdrU=
@@ -1821,10 +1813,10 @@ github.com/seaweedfs/go-fuse/v2 v2.9.4 h1:ACyloiuopdhRSjdLLeSWbsVaemMPskORaRF01T
github.com/seaweedfs/go-fuse/v2 v2.9.4/go.mod h1:zABdmWEa6A0bwaBeEOBUeUkGIZlxUhcdv+V1Dcc/U/I=
github.com/seaweedfs/goexif v2.0.0+incompatible h1:x8pckiT12QQhifwhDQpeISgDfsqmQ6VR4LFPQ64JRps=
github.com/seaweedfs/goexif v2.0.0+incompatible/go.mod h1:Oni780Z236sXpIQzk1XoJlTwqrJ02smEin9zQeff7Fk=
github.com/seaweedfs/raft v1.2.1 h1:QgFl/aaPnagpUxYB6Bx+fFss1NyetVVcJmraMmnaQ5Q=
github.com/seaweedfs/raft v1.2.1/go.mod h1:fgs/rAVEzjQ7e04XMzG3eJhwZZRmBW+2uRtjakeCGeU=
github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs=
github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q=
github.com/seaweedfs/raft v1.2.0 h1:Ez4Hw9ifBbTT7wg54DvGHBjw1vRlTb4roH0TKl0Oj9Y=
github.com/seaweedfs/raft v1.2.0/go.mod h1:fgs/rAVEzjQ7e04XMzG3eJhwZZRmBW+2uRtjakeCGeU=
github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14=
github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
@@ -1833,10 +1825,10 @@ github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4=
github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M=
github.com/sigstore/sigstore-go v1.2.1 h1:YWP/rDbBaEBvtbkj6xtwsSj38ZCFEhTVVadNOXjVe3A=
github.com/sigstore/sigstore-go v1.2.1/go.mod h1:I8BqVwAb/SaQJ5pBu5IDFY+ksq8O/1/kCag8XUgrsko=
github.com/sigstore/sigstore v1.10.4 h1:ytOmxMgLdcUed3w1SbbZOgcxqwMG61lh1TmZLN+WeZE=
github.com/sigstore/sigstore v1.10.4/go.mod h1:tDiyrdOref3q6qJxm2G+JHghqfmvifB7hw+EReAfnbI=
github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg=
github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg=
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=
@@ -1920,10 +1912,10 @@ github.com/tarantool/go-option v1.1.0 h1:ShoOhNsdL41sRpm4hXCRDjV8H0WzPkd4UnKhLKb
github.com/tarantool/go-option v1.1.0/go.mod h1:hMr9z2JXOWlgdCBpCPSL2nwp8718GKYvNBJ+ZuzJbCo=
github.com/tarantool/go-tarantool/v3 v3.0.1 h1:vaUX4xmVmXh2dIJ/LqlX1MXK3iYqAqV6YiE54Wwl/qg=
github.com/tarantool/go-tarantool/v3 v3.0.1/go.mod h1:TXxLWhUCgdxXFfelnTSkq+goKRTRj660zxq4/WXPe8k=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/testcontainers/testcontainers-go/modules/compose v0.44.0 h1:8YcW51jhgpkkiRVe10Wj9TCBthJmoNpU2fK5WSf7TQ8=
github.com/testcontainers/testcontainers-go/modules/compose v0.44.0/go.mod h1:0du5YaH5n8Of4bDM/RwIt421Q14kvqrXZ0mB8EjuMoo=
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
github.com/testcontainers/testcontainers-go/modules/compose v0.42.0 h1:+t1ZN31TD36cwxmeLqGwe7wIdvblBm0Z+vlj4SX8Mv0=
github.com/testcontainers/testcontainers-go/modules/compose v0.42.0/go.mod h1:CfMpouDHqNTCHC8CijEURU2ZotTV3QhH6pXd48s6ofk=
github.com/the42/cartconvert v0.0.0-20131203171324-aae784c392b8 h1:I4DY8wLxJXCrMYzDM6lKCGc3IQwJX0PlTLsd3nQqI3c=
github.com/the42/cartconvert v0.0.0-20131203171324-aae784c392b8/go.mod h1:fWO/msnJVhHqN1yX6OBoxSyfj7TEj1hHiL8bJSQsK30=
github.com/tiancaiamao/gp v0.0.0-20221230034425-4025bc8a4d4a h1:J/YdBZ46WKpXsxsW93SG+q0F8KI+yFrcIDT4c/RNoc4=
@@ -1952,8 +1944,8 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYm
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 h1:r0p7fK56l8WPequOaR3i9LBqfPtEdXIQbUTzT55iqT4=
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323/go.mod h1:3Iuxbr0P7D3zUzBMAZB+ois3h/et0shEz0qApgHYGpY=
github.com/tonistiigi/fsutil v0.0.0-20260609174605-b61e79c0c046 h1:j29MScUISj0S98EHdM6/pzKgqppfswl9OZ7OVpnQdzE=
github.com/tonistiigi/fsutil v0.0.0-20260609174605-b61e79c0c046/go.mod h1:K5zrLch9UaSGNiek5XHZeqZUf1zPWJHqDfLIcnpquQ4=
github.com/tonistiigi/fsutil v0.0.0-20251211185533-a2aa163d723f h1:Z4NEQ86qFl1mHuCu9gwcE+EYCwDKfXAYXZbdIXyxmEA=
github.com/tonistiigi/fsutil v0.0.0-20251211185533-a2aa163d723f/go.mod h1:BKdcez7BiVtBvIcef90ZPc6ebqIWr4JWD7+EvLm6J98=
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 h1:2f304B10LaZdB8kkVEaoXvAMVan2tl9AiK4G0odjQtE=
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE=
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea h1:SXhTLE6pb6eld/v/cCndK0AMpt1wiVFb/YYmqB3/QG0=
@@ -1965,8 +1957,8 @@ github.com/tsuna/gohbase v0.0.0-20201125011725-348991136365/go.mod h1:zj0GJHGvyf
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/twmb/avro v1.8.0 h1:UMWLg+nH4P3yad5Om7yFSohYLy2RG1s7BcFFiOvmK9Q=
github.com/twmb/avro v1.8.0/go.mod h1:X0fT1dY2xcbV4YuCE4mYro+qljHl4kUF5uA/2z1rgSk=
github.com/twmb/avro v1.7.2 h1:cmrEBRSbELRqsg/dRkQvVWuOaR2EfGifHIt/2iJ9lfI=
github.com/twmb/avro v1.7.2/go.mod h1:X0fT1dY2xcbV4YuCE4mYro+qljHl4kUF5uA/2z1rgSk=
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=
@@ -2105,38 +2097,38 @@ 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.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 h1:MCcYL7J6Vt/X0kjqbMZkekCmwsurbQRbL69vkiye2lk=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0/go.mod h1:3jnStNwSufK+f5ktjL4EPcwtig4rtd81NS70lqHuXl8=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0/go.mod h1:rjbQTDEPQymPE0YnRQp9/NuPwwtL0sesz/fnqRW/v84=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/exporters/zipkin v1.36.0 h1:s0n95ya5tOG03exJ5JySOdJFtwGo4ZQ+KeY7Zro4CLI=
go.opentelemetry.io/otel/exporters/zipkin v1.36.0/go.mod h1:m9wRxtKA2MZ1HcnNC4BKI+9aYe434qRZTCvI7QGUN7Y=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
@@ -2165,8 +2157,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
gocloud.dev/pubsub/natspubsub v0.46.0 h1:cTmJnWOmhb3dYRzX7PvNkq2m5mQdBLFFxZbl/21pfjk=
@@ -2193,8 +2185,8 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
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=
@@ -2225,8 +2217,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.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
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=
@@ -2258,8 +2250,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
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=
@@ -2374,8 +2366,8 @@ golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -2477,8 +2469,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
@@ -2511,8 +2503,8 @@ golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
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=
@@ -2589,8 +2581,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
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.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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=
@@ -2668,8 +2660,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.297.0 h1:WktxTsnnx0yZNnsR6j0q6hR21RnnK81FHTOPy/ux4OE=
google.golang.org/api v0.297.0/go.mod h1:S4m8x0M6OkQpkOzGk1y9JG2sm4fFQrMh6dxzjCTszhE=
google.golang.org/api v0.296.0 h1:Nn5EHeKdGx70MFClaV/II0gsWUm6xhEjb0xYLylVvaA=
google.golang.org/api v0.296.0/go.mod h1:02qB8+Ox1ZFzcaKFMguy1nQLJmSIyvV6Ff4txJEXtl4=
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=
+2 -2
View File
@@ -1,6 +1,6 @@
apiVersion: v1
description: SeaweedFS
name: seaweedfs
appVersion: "4.47"
appVersion: "4.45"
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
version: 4.47.1
version: 4.45.0
+1 -22
View File
@@ -286,7 +286,7 @@ metadata:
app.kubernetes.io/component: s3
stringData:
# this key must be an inline json config file
seaweedfs_s3_config: '{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"snu8yoP6QAlY0ne4","secretKey":"PNzBcmeLNEdR0oviwm04NQAicOrDH1Km"}],"actions":["Admin","Read","Write"]},{"name":"anvReadOnly","credentials":[{"accessKey":"SCigFee6c5lbi04A","secretKey":"kgFhbT38R8WUYVtiFQ1OiSVOrYr3NKku"}],"actions":["Read","List"]}]}'
seaweedfs_s3_config: '{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"snu8yoP6QAlY0ne4","secretKey":"PNzBcmeLNEdR0oviwm04NQAicOrDH1Km"}],"actions":["Admin","Read","Write"]},{"name":"anvReadOnly","credentials":[{"accessKey":"SCigFee6c5lbi04A","secretKey":"kgFhbT38R8WUYVtiFQ1OiSVOrYr3NKku"}],"actions":["Read"]}]}'
```
#### Source S3 credentials from an existing Secret
@@ -363,27 +363,6 @@ If `adminPassword` is empty or not set, the admin interface runs without authent
As an alternative, a kubernetes Secret can be used (`admin.secret.existingSecret`).
### Admin listen address
Since SeaweedFS 4.46, `weed admin` defaults to listening on loopback (`127.0.0.1`).
The chart's httpGet readiness/liveness probes dial the pod IP, so the admin
server must bind a non-loopback address for the probes to succeed. The chart
therefore passes `-ip={{ .Values.admin.ip }}`, defaulting `admin.ip` to `0.0.0.0`
(the pre-4.46 behaviour of listening on all interfaces).
Binding a non-loopback address requires authentication: `weed admin` refuses to
start on a non-loopback address without `-adminPassword`, so the chart fails at
render time if `admin.ip` is non-loopback and authentication is not configured via
`admin.secret.adminPassword`, `admin.secret.existingSecret`, or
`WEED_ADMIN_PASSWORD` supplied through `admin.extraEnvironmentVars` /
`admin.secretExtraEnvironmentVars`. The whole `127.0.0.0/8` range and `::1` are
treated as loopback (matching `weed admin`); `localhost` is treated as
non-loopback. Set `admin.ip` to a loopback address only if you also replace the
httpGet probes (e.g. with an `exec` probe that checks `127.0.0.1`).
The `-ip` flag requires SeaweedFS 4.46 or newer; pinning `admin.imageOverride`
to an older image is not supported with this chart version.
### Admin Data Persistence
The admin component can store configuration and maintenance data. You can configure storage in several ways:
@@ -1,7 +0,0 @@
# Admin install: exercises the admin StatefulSet, which passes -ip (default
# 0.0.0.0) and therefore requires authentication to bind a non-loopback address.
admin:
enabled: true
secret:
adminUser: "admin"
adminPassword: "ci-admin-password"
@@ -6,11 +6,6 @@
{{- if and (not .Values.admin.masters) (not .Values.global.seaweedfs.masterServer) (not .Values.master.enabled) }}
{{- fail "admin.masters or global.seaweedfs.masterServer must be set if master.enabled is false" -}}
{{- end }}
{{- $adminAuthEnabled := include "seaweedfs.admin.authEnabled" . }}
{{- $adminIp := .Values.admin.ip | default "0.0.0.0" }}
{{- if and (not (include "seaweedfs.admin.isLoopbackIp" $adminIp)) (ne $adminAuthEnabled "true") }}
{{- fail (printf "admin.ip is set to %q (non-loopback) but admin authentication is not configured. Since `weed admin` 4.46 refuses to bind a non-loopback address without authentication, the admin container would exit on startup. Set admin.secret.adminPassword or admin.secret.existingSecret, or supply WEED_ADMIN_PASSWORD via admin.extraEnvironmentVars / admin.secretExtraEnvironmentVars, or set admin.ip to a loopback address such as 127.0.0.1 (note: a loopback bind makes the chart's httpGet readiness/liveness probes fail)." $adminIp) -}}
{{- end }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
@@ -167,7 +162,6 @@ spec:
-v={{ .Values.global.seaweedfs.loggingLevel }} \
{{- end }}
admin \
-ip={{ .Values.admin.ip | default "0.0.0.0" }} \
-port={{ .Values.admin.port }} \
-port.grpc={{ .Values.admin.grpcPort }} \
{{- if or (eq .Values.admin.data.type "hostPath") (eq .Values.admin.data.type "persistentVolumeClaim") (eq .Values.admin.data.type "emptyDir") (eq .Values.admin.data.type "existingClaim") }}
@@ -44,13 +44,6 @@ spec:
{{- with .Values.allInOne.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- $existingS3ConfigSecret := or .Values.allInOne.s3.existingConfigSecret .Values.s3.existingConfigSecret .Values.filer.s3.existingConfigSecret }}
{{- if $existingS3ConfigSecret }}
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace $existingS3ConfigSecret) | default dict }}
checksum/s3config: {{ $configSecret | toYaml | sha256sum }}
{{- else }}
checksum/s3config: {{ include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum }}
{{- end }}
spec:
restartPolicy: {{ default .Values.global.seaweedfs.restartPolicy .Values.allInOne.restartPolicy }}
{{- if .Values.allInOne.affinity }}
@@ -42,12 +42,6 @@ spec:
{{- with .Values.s3.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.s3.existingConfigSecret }}
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.s3.existingConfigSecret) | default dict }}
checksum/s3config: {{ $configSecret | toYaml | sha256sum }}
{{- else }}
checksum/s3config: {{ include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum }}
{{- end }}
spec:
restartPolicy: {{ default .Values.global.seaweedfs.restartPolicy .Values.s3.restartPolicy }}
{{- if .Values.s3.affinity }}
@@ -60,7 +60,7 @@ stringData:
read_access_key_id: {{ $access_key_read }}
read_secret_access_key: {{ $secret_key_read }}
{{- end }}
seaweedfs_s3_config: '{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"{{ $access_key_admin }}","secretKey":"{{ $secret_key_admin }}"}],"actions":["Admin","Read","Write"]},{"name":"anvReadOnly","credentials":[{"accessKey":"{{ $access_key_read }}","secretKey":"{{ $secret_key_read }}"}],"actions":["Read","List"]}]}'
seaweedfs_s3_config: '{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"{{ $access_key_admin }}","secretKey":"{{ $secret_key_admin }}"}],"actions":["Admin","Read","Write"]},{"name":"anvReadOnly","credentials":[{"accessKey":"{{ $access_key_read }}","secretKey":"{{ $secret_key_read }}"}],"actions":["Read"]}]}'
{{- if .Values.filer.s3.auditLogConfig }}
filer_s3_auditLogConfig.json: |
{{ toJson .Values.filer.s3.auditLogConfig | nindent 4 }}
@@ -88,43 +88,6 @@ true
{{- end -}}
{{- end -}}
{{/* Classify an admin bind address as loopback, mirroring weed admin's
isLoopbackIp (net.ParseIP + IsLoopback). Helm templates cannot call
net.ParseIP, so we approximate: valid IPv4 addresses in 127.0.0.0/8
(validated via regex to reject malformed values like "127.not-an-ip")
and the IPv6 loopback "::1" / its expanded form "0:0:0:0:0:0:0:1" are
loopback. Hostnames (e.g. "localhost") and wildcard addresses
("0.0.0.0", "::") are non-loopback, matching the binary, which
treats unparseable hostnames as non-loopback to be safe. Other IPv6
loopback representations are not matched; the binary's own runtime
validation is the authoritative guard. */}}
{{- define "seaweedfs.admin.isLoopbackIp" -}}
{{- $ip := toString . -}}
{{- if or (regexMatch "^127\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$" $ip) (eq $ip "::1") (eq $ip "0:0:0:0:0:0:0:1") -}}
true
{{- end -}}
{{- end -}}
{{/* Whether admin authentication is enabled from any supported source:
admin.secret (adminPassword or existingSecret), or WEED_ADMIN_PASSWORD
supplied via extraEnvironmentVars / secretExtraEnvironmentVars (which
weed admin picks up through viper's AutomaticEnv). A secret-backed
entry counts as enabled even though the chart cannot read its value. */}}
{{- define "seaweedfs.admin.authEnabled" -}}
{{- if or .Values.admin.secret.existingSecret .Values.admin.secret.adminPassword -}}
true
{{- else -}}
{{- $merged := dict -}}
{{- $_ := include "seaweedfs.mergeExtraEnvironmentVars" (dict "global" .Values.global.seaweedfs "component" .Values.admin "target" $merged) -}}
{{- $envPassword := index $merged "WEED_ADMIN_PASSWORD" -}}
{{- if or (kindIs "map" $envPassword) (hasKey (.Values.admin.secretExtraEnvironmentVars | default dict) "WEED_ADMIN_PASSWORD") -}}
true
{{- else if and $envPassword (ne (toString $envPassword) "") -}}
true
{{- end -}}
{{- end -}}
{{- end -}}
{{/* Return the proper filer image */}}
{{- define "seaweedfs.filer.image" -}}
{{- if .Values.filer.imageOverride -}}
@@ -97,19 +97,6 @@ data:
cert = "/usr/local/share/ca-certificates/worker/tls.crt"
key = "/usr/local/share/ca-certificates/worker/tls.key"
{{- /* S3 gRPC server identity (weed s3 -port.grpc, default httpPort+10000).
Without this section, LoadServerTLS("grpc.s3") returns nil and the S3
server serves its internal gRPC (IAM cache propagation, s3 lifecycle
delete RPCs) as plaintext, while peers dial it with mTLS credentials:
"tls: first record does not look like a TLS handshake".
Always uses the internal CA-signed client certificate (already mounted
on s3 pods by s3-deployment.yaml). Deliberately NOT s3.tlsSecret: that
secret is for the public HTTPS listener and may be issued by a public
CA which internal gRPC peers (trusting only grpc.ca) would reject. */}}
[grpc.s3]
cert = "/usr/local/share/ca-certificates/client/tls.crt"
key = "/usr/local/share/ca-certificates/client/tls.key"
# use this for any place needs a grpc client
# i.e., "weed backup|benchmark|filer.copy|filer.replicate|mount|s3|upload"
[grpc.client]
-14
View File
@@ -1328,20 +1328,6 @@ admin:
replicas: 1
port: 23646 # Default admin port
grpcPort: 33646 # Default gRPC port for worker connections
# IP address the admin server listens on. Since `weed admin` 4.46 defaults to
# loopback (127.0.0.1), the chart must bind a non-loopback address for the
# kubelet's httpGet readiness/liveness probes (which dial the pod IP) to ever
# succeed. "0.0.0.0" restores the pre-4.46 behaviour of listening on all
# interfaces. A non-loopback address requires authentication: set
# admin.secret.adminPassword or admin.secret.existingSecret, or supply
# WEED_ADMIN_PASSWORD via admin.extraEnvironmentVars /
# admin.secretExtraEnvironmentVars; otherwise the admin container will exit
# with a clear error rather than silently staying unready. The whole
# 127.0.0.0/8 range and ::1 are treated as loopback (matching weed admin).
# Set to a loopback address only if you also replace the httpGet probes.
# Note: the -ip flag requires SeaweedFS 4.46 or newer; pinning
# admin.imageOverride to an older image is not supported with this chart.
ip: "0.0.0.0"
loggingOverrideLevel: null
# Admin authentication
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 53 KiB

+3 -2
View File
@@ -3160,9 +3160,9 @@ dependencies = [
[[package]]
name = "redb"
version = "4.2.0"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36"
checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a"
dependencies = [
"libc",
]
@@ -4582,6 +4582,7 @@ dependencies = [
"image",
"jsonwebtoken",
"kamadak-exif",
"lazy_static",
"libc",
"md-5",
"memmap2",
+3 -16
View File
@@ -1,10 +1,7 @@
[package]
name = "weed-volume"
version = "0.1.0"
edition = "2024"
# The edition needs 1.85; the dependency tree needs more. Verified with
# `cargo +1.91.1 check --all-targets` (1.90 fails on the AWS SDK).
rust-version = "1.91.1"
edition = "2021"
description = "SeaweedFS Volume Server — Rust implementation"
[lib]
@@ -19,17 +16,6 @@ path = "src/main.rs"
# Disable with --no-default-features for 4-byte offsets (32GB max volume size).
default = ["5bytes"]
5bytes = []
# Unstable redb cursor bulk-load for full_rebuild. Off in production.
# Pulls redb's experimental_cursor (and therefore experimental-api-5).
redb-experimental-cursor = ["redb/experimental_cursor"]
[lints.clippy]
# Every RPC path returns tonic::Status (176 bytes). Boxing it would change
# every handler signature for no gain, so the large-Err lint is off.
result_large_err = "allow"
# Protobuf message literals keep `..Default::default()` on purpose: it is
# what lets a proto gain a field without touching every constructor.
needless_update = "allow"
[dependencies]
# Async runtime
@@ -56,6 +42,7 @@ clap = { version = "4", features = ["derive"] }
# Metrics
prometheus = { version = "0.13", default-features = false, features = ["process"] }
lazy_static = "1"
# JWT
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
@@ -70,7 +57,7 @@ rustls-pemfile = "2"
rusty-leveldb = "3"
# Disk-backed needle map (alternative to in-memory HashMap)
redb = "4"
redb = "3"
# Reed-Solomon erasure coding
reed-solomon-erasure = "6"
+1 -4
View File
@@ -4,10 +4,7 @@ A drop-in replacement for the [SeaweedFS](https://github.com/seaweedfs/seaweedfs
## Building
Requires Rust 1.91.1+ (2024 edition), matching `rust-version` in `Cargo.toml`.
The patch release matters: 1.91.0 does not build. The edition itself only needs
1.85; the higher floor comes from the dependency tree — chiefly the AWS SDK — so
it moves with those crates. CI builds on the latest stable.
Requires Rust 1.75+ (2021 edition).
```bash
cd seaweed-volume
+1 -6
View File
@@ -3,12 +3,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// one, so the build needs no package manager and always sees the same
// version. An explicit PROTOC still wins, for packagers supplying their own.
if std::env::var_os("PROTOC").is_none() {
// SAFETY: a build script's main runs single-threaded before anything
// else in this process, so no other thread can be reading the
// environment concurrently.
unsafe {
std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?);
}
std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?);
}
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR")?);
-7
View File
@@ -145,7 +145,6 @@ message VolumeInformationMessage {
uint64 delete_count = 5;
uint64 deleted_byte_count = 6;
bool read_only = 7;
bool read_only_can_delete = 17;
uint32 replica_placement = 8;
uint32 version = 9;
uint32 ttl = 10;
@@ -165,8 +164,6 @@ message VolumeShortInformationMessage {
uint32 ttl = 10;
string disk_type = 15;
uint32 disk_id = 16;
bool read_only = 17;
bool read_only_can_delete = 18;
}
message VolumeEcShardInformationMessage {
@@ -226,8 +223,6 @@ message VolumeLocation {
repeated uint32 new_ec_vids = 8;
repeated uint32 deleted_ec_vids = 9;
repeated uint32 remote_vids = 10;
repeated uint32 read_only_vids = 11;
repeated uint32 read_only_can_delete_vids = 12;
}
message ClusterNodeUpdate {
@@ -274,8 +269,6 @@ message Location {
uint32 grpc_port = 3;
string data_center = 4;
bool data_in_remote = 5;
bool read_only = 6;
bool read_only_can_delete = 7;
}
message AssignRequest {
-2
View File
@@ -75,6 +75,4 @@ message RemoteStorageLocation {
string name = 1;
string bucket = 2;
string path = 3;
int32 listing_cache_ttl_seconds = 4; // 0 = disabled; >0 enables on-demand directory listing with this TTL in seconds
optional int32 cache_wait_ms = 5; // unset = size based default; 0 = read straight from the remote without caching
}
-1
View File
@@ -236,7 +236,6 @@ message VolumeIncrementalCopyResponse {
message VolumeMountRequest {
uint32 volume_id = 1;
optional string collection = 2;
}
message VolumeMountResponse {
}
+81 -108
View File
@@ -64,8 +64,6 @@ pub struct Cli {
pub rack: String,
/// Choose [memory|redb|redbMedium|redbLarge] mode for memory~performance balance.
/// The redb tiers give each volume's on-disk index a 4, 8, or 16 MiB page
/// cache respectively; total index memory is roughly (volumes x cache).
/// `leveldb`/`leveldbMedium`/`leveldbLarge` are accepted as aliases for the
/// corresponding redb backends (Rust volume server uses redb under the hood).
#[arg(long = "index", default_value = "memory")]
@@ -371,18 +369,17 @@ fn merge_options_file(args: Vec<String>) -> Vec<String> {
if arg == "--" {
break;
}
if let Some(long) = arg.strip_prefix("--") {
let key = if let Some(eq) = long.find('=') {
long[..eq].to_string()
if arg.starts_with("--") {
let key = if let Some(eq) = arg.find('=') {
arg[2..eq].to_string()
} else {
long.to_string()
arg[2..].to_string()
};
cli_flags.insert(key);
} else if arg.len() > 2
&& let Some(without_dash) = arg.strip_prefix('-')
{
} else if arg.starts_with('-') && arg.len() > 2 {
// Single-dash long option (already normalized to -- at this point,
// but handle both for safety)
let without_dash = &arg[1..];
let key = if let Some(eq) = without_dash.find('=') {
without_dash[..eq].to_string()
} else {
@@ -402,14 +399,15 @@ fn merge_options_file(args: Vec<String>) -> Vec<String> {
}
// Split on first `=`, ` `, or `:`
let (name, value) = if let Some(pos) = trimmed.find(['=', ' ', ':']) {
(
trimmed[..pos].trim().to_string(),
trimmed[pos + 1..].trim().to_string(),
)
} else {
(trimmed.to_string(), String::new())
};
let (name, value) =
if let Some(pos) = trimmed.find(|c: char| c == '=' || c == ' ' || c == ':') {
(
trimmed[..pos].trim().to_string(),
trimmed[pos + 1..].trim().to_string(),
)
} else {
(trimmed.to_string(), String::new())
};
// Strip leading dashes from name
let name = name.trim_start_matches('-').to_string();
@@ -436,8 +434,10 @@ fn merge_options_file(args: Vec<String>) -> Vec<String> {
/// Extract the options file path from args (looks for --options or -options).
fn find_options_arg(args: &[String]) -> String {
for i in 1..args.len() {
if (args[i] == "--options" || args[i] == "-options") && i + 1 < args.len() {
return args[i + 1].clone();
if args[i] == "--options" || args[i] == "-options" {
if i + 1 < args.len() {
return args[i + 1].clone();
}
}
if let Some(rest) = args[i].strip_prefix("--options=") {
return rest.to_string();
@@ -455,22 +455,20 @@ fn parse_duration(s: &str) -> std::time::Duration {
if s.is_empty() {
return std::time::Duration::from_secs(60);
}
if let Some(secs) = s.strip_suffix('s')
&& let Ok(v) = secs.parse::<u64>()
{
return std::time::Duration::from_secs(v);
if let Some(secs) = s.strip_suffix('s') {
if let Ok(v) = secs.parse::<u64>() {
return std::time::Duration::from_secs(v);
}
}
if let Some(mins) = s.strip_suffix('m')
&& let Ok(v) = mins.parse::<u64>()
&& let Some(seconds) = v.checked_mul(60)
{
return std::time::Duration::from_secs(seconds);
if let Some(mins) = s.strip_suffix('m') {
if let Ok(v) = mins.parse::<u64>() {
return std::time::Duration::from_secs(v * 60);
}
}
if let Some(hours) = s.strip_suffix('h')
&& let Ok(v) = hours.parse::<u64>()
&& let Some(seconds) = v.checked_mul(3600)
{
return std::time::Duration::from_secs(seconds);
if let Some(hours) = s.strip_suffix('h') {
if let Ok(v) = hours.parse::<u64>() {
return std::time::Duration::from_secs(v * 3600);
}
}
// Fallback: try parsing as raw seconds
if let Ok(v) = s.parse::<u64>() {
@@ -503,40 +501,40 @@ fn parse_min_free_spaces(min_free_space: &str, min_free_space_percent: &str) ->
}
// Try parsing human-readable bytes: e.g. "10GiB", "500MiB", "1TiB"
let s_upper = s.to_uppercase();
if let Some(rest) = s_upper.strip_suffix("TIB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0 * 1024.0 * 1024.0) as u64);
if let Some(rest) = s_upper.strip_suffix("TIB") {
if let Ok(v) = rest.trim().parse::<f64>() {
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0 * 1024.0 * 1024.0) as u64);
}
}
if let Some(rest) = s_upper.strip_suffix("GIB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0 * 1024.0) as u64);
if let Some(rest) = s_upper.strip_suffix("GIB") {
if let Ok(v) = rest.trim().parse::<f64>() {
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0 * 1024.0) as u64);
}
}
if let Some(rest) = s_upper.strip_suffix("MIB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0) as u64);
if let Some(rest) = s_upper.strip_suffix("MIB") {
if let Ok(v) = rest.trim().parse::<f64>() {
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0) as u64);
}
}
if let Some(rest) = s_upper.strip_suffix("KIB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1024.0) as u64);
if let Some(rest) = s_upper.strip_suffix("KIB") {
if let Ok(v) = rest.trim().parse::<f64>() {
return MinFreeSpace::Bytes((v * 1024.0) as u64);
}
}
if let Some(rest) = s_upper.strip_suffix("TB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1_000_000_000_000.0) as u64);
if let Some(rest) = s_upper.strip_suffix("TB") {
if let Ok(v) = rest.trim().parse::<f64>() {
return MinFreeSpace::Bytes((v * 1_000_000_000_000.0) as u64);
}
}
if let Some(rest) = s_upper.strip_suffix("GB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1_000_000_000.0) as u64);
if let Some(rest) = s_upper.strip_suffix("GB") {
if let Ok(v) = rest.trim().parse::<f64>() {
return MinFreeSpace::Bytes((v * 1_000_000_000.0) as u64);
}
}
if let Some(rest) = s_upper.strip_suffix("MB")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return MinFreeSpace::Bytes((v * 1_000_000.0) as u64);
if let Some(rest) = s_upper.strip_suffix("MB") {
if let Ok(v) = rest.trim().parse::<f64>() {
return MinFreeSpace::Bytes((v * 1_000_000.0) as u64);
}
}
// Default: 1%
MinFreeSpace::Percent(1.0)
@@ -1028,20 +1026,20 @@ pub fn parse_security_config(path: &str) -> SecurityConfig {
"cipher_suites" => cfg.tls_policy.cipher_suites = value.to_string(),
_ => {}
},
Section::Guard => {
if key == "white_list" {
Section::Guard => match key {
"white_list" => {
cfg.guard_white_list = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
}
Section::Access => {
if key == "ui" {
cfg.access_ui = value.parse().unwrap_or(false)
}
}
_ => {}
},
Section::Access => match key {
"ui" => cfg.access_ui = value.parse().unwrap_or(false),
_ => {}
},
Section::None => {}
}
}
@@ -1188,11 +1186,12 @@ fn apply_env_overrides(cfg: &mut SecurityConfig) {
/// Mirrors Go's `util.DetectedHostAddress()`.
fn detect_host_address() -> String {
// Connect to a remote address to determine the local outbound IP
if let Ok(socket) = UdpSocket::bind("0.0.0.0:0")
&& socket.connect("8.8.8.8:80").is_ok()
&& let Ok(addr) = socket.local_addr()
{
return addr.ip().to_string();
if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") {
if socket.connect("8.8.8.8:80").is_ok() {
if let Ok(addr) = socket.local_addr() {
return addr.ip().to_string();
}
}
}
"localhost".to_string()
}
@@ -1208,30 +1207,21 @@ mod tests {
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
// SAFETY (all env mutation in this module): `set_var`/`remove_var` are
// unsafe as of Rust 2024 because they race with concurrent readers in
// other threads. Every test that reaches these helpers holds
// `process_state_lock()` for the duration, so only one test at a time
// touches the environment and none observes another's edit.
fn with_temp_env_var<F: FnOnce()>(key: &str, value: Option<&str>, f: F) {
let previous = std::env::var_os(key);
unsafe {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
f();
restore_env_var(key, previous);
}
fn restore_env_var(key: &str, value: Option<OsString>) {
unsafe {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
@@ -1276,10 +1266,7 @@ mod tests {
.collect();
for key in KEYS {
// SAFETY: as above — the caller holds `process_state_lock()`.
unsafe {
std::env::remove_var(key);
}
std::env::remove_var(key);
}
f();
@@ -1296,14 +1283,6 @@ mod tests {
assert_eq!(parse_duration("1h"), std::time::Duration::from_secs(3600));
assert_eq!(parse_duration("30"), std::time::Duration::from_secs(30));
assert_eq!(parse_duration(""), std::time::Duration::from_secs(60));
assert_eq!(
parse_duration("307445734561825861m"),
std::time::Duration::from_secs(60)
);
assert_eq!(
parse_duration("5124095576030432h"),
std::time::Duration::from_secs(60)
);
}
#[test]
@@ -1423,18 +1402,12 @@ mod tests {
#[test]
fn test_resolve_config_defaults_dir_to_platform_temp_dir() {
// resolve_config reads HOME/USERPROFILE and the WEED_* set, so it has to
// hold the same lock the mutation helpers take — a concurrent set_var
// during this read is exactly what makes those calls unsafe.
let _guard = process_state_lock();
let cfg = resolve_config(Cli::parse_from(["bin"]));
assert_eq!(cfg.folders, vec![default_volume_dir()]);
}
#[test]
fn test_resolve_config_index_accepts_redb_and_leveldb_aliases() {
// As above: resolve_config reads the environment.
let _guard = process_state_lock();
let pairs = [
("memory", NeedleMapKind::InMemory),
("redb", NeedleMapKind::Redb),
-1
View File
@@ -1,6 +1,5 @@
pub mod config;
pub mod images;
pub mod malloc_tuning;
pub mod metrics;
pub mod remote_storage;
pub mod security;
-18
View File
@@ -39,11 +39,6 @@ const GRPC_MAX_HEADER_LIST_SIZE: u32 = 8 * 1024 * 1024;
const GRPC_MAX_CONCURRENT_STREAMS: u32 = 1000;
fn main() {
// Before anything allocates: stop glibc from training its mmap threshold
// upward on our large EC buffers and turning them into heap it never
// returns. See seaweed_volume::malloc_tuning for the measurements.
let malloc_tuning = seaweed_volume::malloc_tuning::pin_mmap_threshold();
install_default_crypto_provider();
// Initialize tracing
@@ -70,19 +65,6 @@ fn main() {
"SeaweedFS Volume Server (Rust) v{}",
seaweed_volume::version::full_version()
);
match malloc_tuning {
seaweed_volume::malloc_tuning::MallocTuning::Pinned(bytes) => {
info!("pinned glibc M_MMAP_THRESHOLD to {} bytes", bytes)
}
seaweed_volume::malloc_tuning::MallocTuning::DeferredToEnv => info!(
"an allocator mmap-threshold override ({}) is set; leaving glibc's mmap threshold to the environment",
seaweed_volume::malloc_tuning::MMAP_THRESHOLD_ENV
),
seaweed_volume::malloc_tuning::MallocTuning::Failed => {
warn!("mallopt(M_MMAP_THRESHOLD) failed; large freed buffers may stay resident")
}
seaweed_volume::malloc_tuning::MallocTuning::NotApplicable => {}
}
// Register Prometheus metrics
metrics::register_metrics();
-467
View File
@@ -1,467 +0,0 @@
//! Keep glibc from silently converting large short-lived buffers into heap the
//! process never gives back.
//!
//! glibc serves an allocation with `mmap` when it is at least
//! `M_MMAP_THRESHOLD` (128 KiB by default), and `munmap`s it on free, so the
//! pages go straight back to the OS. That threshold is **adaptive**: whenever a
//! block that came from `mmap` is freed, glibc raises the threshold to that
//! block's size — up to 32 MiB — on the theory that a workload repeatedly
//! allocating buffers of that size is better served from the heap.
//!
//! For a volume server that theory is wrong in a specific, expensive way. EC
//! reconstruction and needle reassembly allocate large, short-lived buffers.
//! The first few are mmap'd and freed, which trains the threshold upward; every
//! later buffer of that size is then carved out of the heap instead. Heap
//! memory is only returned to the OS from the top of the arena, so those pages
//! stay resident as anonymous memory for the life of the process. They are
//! still *reusable* — this is not a leak, and a repeat workload does not grow
//! the footprint further — but under a hard cgroup `MemoryMax` they are
//! indistinguishable from a leak, because anonymous pages cannot be reclaimed
//! under pressure the way page cache can. The retained footprint eats exactly
//! the headroom that a burst of maintenance work needs, and the process is
//! OOM-killed while most of its resident memory is free-but-unreturned.
//!
//! Measured on a 17-node cluster (EC 10+4, `--index=redb`), one node, two
//! identical `ec.scrub -mode full` rounds over 10912 EC files each, comparing
//! the same unit restarted with and without a pinned threshold:
//!
//! | | baseline | round 1 | round 2 | 60s idle |
//! |---|---|---|---|---|
//! | default (adaptive) | 10 MB | 84 MB | 88 MB | **88 MB** |
//! | pinned threshold | 10 MB | 13 MB | 14 MB | **14 MB** |
//!
//! 78 MB retained versus 4 MB for identical work. On that cluster's heavier
//! mixed scrub workloads the same effect reached ~600 MB of retained anonymous
//! memory per volume server, against a 3 GiB cap.
//!
//! Calling `mallopt(M_MMAP_THRESHOLD, ...)` sets the threshold *and* disables
//! the dynamic adjustment, which is the documented behaviour of setting it
//! explicitly. We pin it to glibc's own default rather than inventing a value:
//! the goal is to stop the adaptation, not to second-guess the default.
/// glibc's own default `M_MMAP_THRESHOLD`. Pinning to this value changes
/// nothing about which allocations use `mmap` on a freshly started process; it
/// only prevents the threshold from drifting upward later.
#[cfg(all(target_os = "linux", target_env = "gnu"))]
const DEFAULT_MMAP_THRESHOLD: libc::c_int = 128 * 1024;
/// Legacy environment variable glibc reads for the same setting. If an operator
/// has set it, honour their value and do not override it.
pub const MMAP_THRESHOLD_ENV: &str = "MALLOC_MMAP_THRESHOLD_";
/// Modern glibc tunables environment variable. Operators may set the threshold
/// via `GLIBC_TUNABLES=glibc.malloc.mmap_threshold=...` instead of the legacy
/// variable; that override is honoured too.
pub const GLIBC_TUNABLES_ENV: &str = "GLIBC_TUNABLES";
/// The tunable name within `GLIBC_TUNABLES` that maps to `M_MMAP_THRESHOLD`.
#[cfg(all(target_os = "linux", target_env = "gnu"))]
const MMAP_THRESHOLD_TUNABLE: &str = "glibc.malloc.mmap_threshold";
/// Outcome of the tuning attempt, so the caller can log it and tests can assert
/// on it without inspecting global allocator state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MallocTuning {
/// Threshold pinned to `DEFAULT_MMAP_THRESHOLD`; dynamic adjustment is off.
Pinned(i32),
/// An allocator override (`MALLOC_MMAP_THRESHOLD_` or
/// `GLIBC_TUNABLES=glibc.malloc.mmap_threshold=...`) was set, so the
/// operator's value wins.
DeferredToEnv,
/// `mallopt` reported failure. Not fatal — the server runs, it just keeps
/// glibc's adaptive behaviour.
Failed,
/// Not glibc, so there is no adaptive threshold to pin.
NotApplicable,
}
/// Pin glibc's mmap threshold unless the operator has set an allocator override.
/// Safe to call more than once; call it before serving traffic, since the point
/// is to prevent the threshold from being trained upward by early allocations.
pub fn pin_mmap_threshold() -> MallocTuning {
pin_mmap_threshold_inner()
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
fn pin_mmap_threshold_inner() -> MallocTuning {
if operator_mmap_threshold_override_active() {
return MallocTuning::DeferredToEnv;
}
// SAFETY: `mallopt` is a libc entry point that takes two ints and mutates
// only allocator-internal tunables. It has no preconditions and no effect
// on memory this process already owns.
let rc = unsafe { libc::mallopt(libc::M_MMAP_THRESHOLD, DEFAULT_MMAP_THRESHOLD) };
if rc == 1 {
MallocTuning::Pinned(DEFAULT_MMAP_THRESHOLD)
} else {
MallocTuning::Failed
}
}
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
fn pin_mmap_threshold_inner() -> MallocTuning {
MallocTuning::NotApplicable
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
fn operator_mmap_threshold_override_active() -> bool {
// MALLOC_MMAP_THRESHOLD_: glibc calls atoi(value) then mallopt, which
// always sets the threshold and disables dynamic adjustment — even for
// empty, negative, or non-numeric values (atoi returns 0). So any presence
// of the variable means the operator's override is in effect.
std::env::var_os(MMAP_THRESHOLD_ENV).is_some()
|| usable_glibc_tunable_threshold(std::env::var_os(GLIBC_TUNABLES_ENV))
}
/// Look for `glibc.malloc.mmap_threshold=<value>` among the colon-separated
/// tunables in `GLIBC_TUNABLES`. glibc's `parse_tunables_string` (elf/dl-tunables.c)
/// rejects the **entire** string (returns -1) if it reaches `\0` before finding
/// `=` in a name (last entry has no `=`), or if any entry's value contains a
/// duplicate `=`. When `parse_tunables_string` returns -1, `parse_tunables`
/// prints a warning and returns immediately without applying ANY tunable —
/// including ones already parsed into the tunables array. We match that by
/// returning `false` for the entire string on any of those conditions.
///
/// glibc parses tunable values with `_dl_strtoul`, which accepts decimal,
/// `0x` hex, `0` octal, an optional sign (negatives wrap to `unsigned long`),
/// and requires the entire value to be consumed; we match that with
/// `dl_strtoul_consumes_all`.
#[cfg(all(target_os = "linux", target_env = "gnu"))]
fn usable_glibc_tunable_threshold(tunables: Option<std::ffi::OsString>) -> bool {
let s = match tunables.and_then(|v| v.into_string().ok()) {
Some(s) => s,
None => return false,
};
if s.is_empty() {
return false;
}
// Parse the string character-by-character, matching glibc's
// parse_tunables_string logic exactly. Using split(':') would lose the
// distinction between an entry terminated by ':' (skip) and one terminated
// by '\0' with no '=' (reject entire string).
let bytes = s.as_bytes();
let mut pos = 0;
let mut found_threshold = false;
loop {
// Find where the name ends ('=', ':', or end of string).
let name_start = pos;
while pos < bytes.len() && bytes[pos] != b'=' && bytes[pos] != b':' {
pos += 1;
}
// End of string before '=' → glibc returns -1 (reject entire string).
if pos >= bytes.len() {
return false;
}
// ':' before '=' → glibc skips this entry and continues.
if bytes[pos] == b':' {
pos += 1;
continue;
}
// Skip the '='.
let name_end = pos;
pos += 1;
// Find where the value ends ('=', ':', or end of string).
let val_start = pos;
while pos < bytes.len() && bytes[pos] != b'=' && bytes[pos] != b':' {
pos += 1;
}
// '=' in value → glibc returns -1 (reject entire string).
if pos < bytes.len() && bytes[pos] == b'=' {
return false;
}
let key = &s[name_start..name_end];
let val = &s[val_start..pos];
if key == MMAP_THRESHOLD_TUNABLE && dl_strtoul_consumes_all(val) {
found_threshold = true;
}
// End of string → done.
if pos >= bytes.len() {
break;
}
// Skip the ':'.
pos += 1;
}
found_threshold
}
/// Replicate glibc's `_dl_strtoul` (elf/dl-misc.c) just enough to determine
/// whether it would consume the entire string — which is what
/// `tunable_parse_num` checks (`endptr == strval + len`). Returns `true` if
/// glibc would accept the value and apply it.
///
/// `_dl_strtoul` skips leading spaces/tabs, accepts an optional `+`/`-` sign,
/// and parses `0x`-prefixed hex, `0`-prefixed octal, or plain decimal. A
/// negative result wraps to `unsigned long` (`-1` → `SIZE_MAX`). If no digit is
/// found after the sign, the end pointer stays at the current position — which
/// still counts as "consumed" when the string is empty or whitespace-only
/// (value 0). On overflow, `_dl_strtoul` stops at the overflowing digit (endptr
/// does not reach the end), so `tunable_parse_num` rejects the value.
#[cfg(all(target_os = "linux", target_env = "gnu"))]
fn dl_strtoul_consumes_all(s: &str) -> bool {
let bytes = s.as_bytes();
let mut pos = 0;
// Skip leading whitespace (spaces and tabs, matching _dl_strtoul).
while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
pos += 1;
}
// Optional sign.
if pos < bytes.len() && (bytes[pos] == b'-' || bytes[pos] == b'+') {
pos += 1;
}
// Must have at least one digit (0-9) to start parsing, unless we're already
// at the end (empty / whitespace-only / sign-only → value 0, consumed).
if pos >= bytes.len() {
return true;
}
if bytes[pos] < b'0' || bytes[pos] > b'9' {
return false;
}
// Determine base: 0x → hex, 0 → octal, else decimal. _dl_strtoul unconditionally
// advances past "0x"/"0X" when the first char is '0' and the next is 'x'/'X',
// even if no hex digit follows — in that case the digit loop breaks immediately,
// endptr reaches the end, and the value is 0.
let base: u32 = if bytes[pos] == b'0'
&& pos + 1 < bytes.len()
&& (bytes[pos + 1] == b'x' || bytes[pos + 1] == b'X')
{
pos += 2; // skip "0x"
16
} else if bytes[pos] == b'0' {
8
} else {
10
};
// Parse digits with overflow detection, matching _dl_strtoul's cutoff/cutlim
// logic. On overflow, _dl_strtoul sets endptr to the overflowing digit and
// returns UINT64_MAX — so the value is NOT fully consumed and
// tunable_parse_num rejects it.
let mut result: u64 = 0;
let cutoff = u64::MAX / base as u64;
let cutlim = u64::MAX % base as u64;
while pos < bytes.len() {
let b = bytes[pos];
let digval: u32 = match digit_value(b, base) {
Some(v) => v,
None => break,
};
if result > cutoff || (result == cutoff && digval as u64 > cutlim) {
// Overflow: _dl_strtoul stops here, endptr points at this digit.
return false;
}
result *= base as u64;
result += digval as u64;
pos += 1;
}
// The entire string must be consumed (matching tunable_parse_num's check).
pos == bytes.len()
}
/// Returns the numeric value of a digit byte in the given base, or `None` if
/// the byte is not a valid digit in that base.
#[cfg(all(target_os = "linux", target_env = "gnu"))]
fn digit_value(b: u8, base: u32) -> Option<u32> {
if (b'0'..=b'0' + (base - 1).min(9) as u8).contains(&b) {
return Some((b - b'0') as u32);
}
if base == 16 {
if (b'a'..=b'f').contains(&b) {
return Some((b - b'a' + 10) as u32);
}
if (b'A'..=b'F').contains(&b) {
return Some((b - b'A' + 10) as u32);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_override_constants_match_glibc_names() {
// Verified against the real accessor rather than a copy of the name, so
// renaming the constant cannot silently break the override contract.
assert_eq!(MMAP_THRESHOLD_ENV, "MALLOC_MMAP_THRESHOLD_");
assert_eq!(GLIBC_TUNABLES_ENV, "GLIBC_TUNABLES");
}
#[test]
fn calling_twice_is_stable() {
// Startup paths get re-entered in tests and in `weed mini`; the second
// call must not report a different outcome from the first.
let first = pin_mmap_threshold();
let second = pin_mmap_threshold();
assert_eq!(first, second);
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
#[test]
fn pins_threshold_on_glibc_when_no_override_is_set() {
// The env override is not set in the test process, so this exercises the
// mallopt path. If an override happens to be present, defer to it.
if operator_mmap_threshold_override_active() {
assert_eq!(pin_mmap_threshold(), MallocTuning::DeferredToEnv);
return;
}
assert_eq!(
pin_mmap_threshold(),
MallocTuning::Pinned(DEFAULT_MMAP_THRESHOLD),
"mallopt(M_MMAP_THRESHOLD) should succeed on glibc"
);
}
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
#[test]
fn is_a_noop_off_glibc() {
// No glibc adaptive threshold exists off glibc, so there is nothing to
// pin regardless of any environment variables that happen to be set.
assert_eq!(pin_mmap_threshold(), MallocTuning::NotApplicable);
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
#[test]
fn dl_strtoul_consumes_all_matches_glibc_parser() {
// Decimal — any non-empty decimal integer is accepted, including
// negative (wraps to unsigned) and zero.
assert!(dl_strtoul_consumes_all("131072"));
assert!(dl_strtoul_consumes_all("0"));
assert!(dl_strtoul_consumes_all("-1"));
assert!(dl_strtoul_consumes_all("-131072"));
// Values above i64::MAX are valid for glibc's unsigned parser.
assert!(dl_strtoul_consumes_all("9223372036854775808"));
// Hex with 0x prefix.
assert!(dl_strtoul_consumes_all("0x20000"));
assert!(dl_strtoul_consumes_all("0X20000"));
assert!(dl_strtoul_consumes_all("0x0"));
// Octal with leading 0.
assert!(dl_strtoul_consumes_all("010"));
// Leading whitespace (spaces and tabs) is skipped.
assert!(dl_strtoul_consumes_all(" 131072"));
assert!(dl_strtoul_consumes_all("\t0x20000"));
// Empty and whitespace-only strings are accepted (value 0).
assert!(dl_strtoul_consumes_all(""));
assert!(dl_strtoul_consumes_all(" "));
assert!(dl_strtoul_consumes_all("\t"));
// Sign-only strings are accepted: _dl_strtoul skips the sign, finds no
// digit, sets endptr to the position after the sign (== end of string),
// and returns 0. tunable_parse_num sees endptr == strval + len → true.
assert!(dl_strtoul_consumes_all("-"));
assert!(dl_strtoul_consumes_all("+"));
// Trailing garbage is rejected — _dl_strtoul stops at the first
// non-digit and tunable_parse_num requires the entire string consumed.
assert!(!dl_strtoul_consumes_all("131072abc"));
// In hex mode, a-f are digits, so "0x20000abc" is a valid hex number.
// Use a non-hex character like 'g' to test trailing garbage in hex.
assert!(!dl_strtoul_consumes_all("0x20000g"));
assert!(!dl_strtoul_consumes_all("128K"));
// Non-numeric strings are rejected.
assert!(!dl_strtoul_consumes_all("abc"));
// "0x" with no hex digits: _dl_strtoul advances past "0x", the digit loop
// breaks immediately (no hex digit), endptr reaches the end, value is 0.
// tunable_parse_num accepts it.
assert!(dl_strtoul_consumes_all("0x"));
assert!(dl_strtoul_consumes_all("0X"));
// Overflow: _dl_strtoul stops at the overflowing digit (endptr points
// there, not at the end), so tunable_parse_num rejects the value.
assert!(!dl_strtoul_consumes_all("18446744073709551616")); // u64::MAX + 1
assert!(!dl_strtoul_consumes_all("99999999999999999999")); // 20 nines
assert!(!dl_strtoul_consumes_all("0x10000000000000000")); // 2^64
// u64::MAX itself is accepted: the last digit (5) equals cutlim (=5),
// so the overflow check (digval > cutlim) is false.
assert!(dl_strtoul_consumes_all("18446744073709551615")); // u64::MAX
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
#[test]
fn usable_glibc_tunable_threshold_detects_mmap_threshold() {
// Decimal, hex, octal, negative, and zero values are all accepted by
// glibc's _dl_strtoul and cause the threshold to be pinned.
assert!(usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=131072".into()
)));
assert!(usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=0x20000".into()
)));
assert!(usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=0".into()
)));
assert!(usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=-1".into()
)));
assert!(usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=9223372036854775808".into()
)));
// u64::MAX is accepted by _dl_strtoul (last digit == cutlim, no overflow).
assert!(usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=18446744073709551615".into()
)));
// Appears alongside other tunables.
assert!(usable_glibc_tunable_threshold(Some(
"glibc.cpu.x=1:glibc.malloc.mmap_threshold=131072".into()
)));
// Leading ':' is accepted — glibc skips the empty entry and continues.
assert!(usable_glibc_tunable_threshold(Some(
":glibc.malloc.mmap_threshold=131072".into()
)));
// Empty value is accepted by _dl_strtoul (value 0).
assert!(usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=".into()
)));
// Non-numeric values are rejected by _dl_strtoul.
assert!(!usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=abc".into()
)));
assert!(!usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=128K".into()
)));
// A malformed sibling entry (duplicate '=') makes glibc reject the
// entire string, so we must not accept the threshold entry either.
// This applies regardless of whether the threshold is before or after
// the malformed entry — parse_tunables_string returns -1, and
// parse_tunables discards all tunables without applying any.
assert!(!usable_glibc_tunable_threshold(Some(
"glibc.malloc.check=2=2:glibc.malloc.mmap_threshold=131072".into()
)));
assert!(!usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=262144:glibc.malloc.check=2=2".into()
)));
// A trailing entry with no '=' makes glibc reject the entire string
// (parse_tunables_string hits '\0' before '=' and returns -1).
assert!(!usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=262144:glibc.cpu.x".into()
)));
// A trailing ':' makes glibc reject the entire string (the empty entry
// after ':' hits '\0' before '=' and returns -1).
assert!(!usable_glibc_tunable_threshold(Some(
"glibc.malloc.mmap_threshold=262144:".into()
)));
// Unrelated tunables do not count.
assert!(!usable_glibc_tunable_threshold(Some(
"glibc.cpu.x=1".into()
)));
assert!(!usable_glibc_tunable_threshold(None));
}
}
+116 -233
View File
@@ -3,10 +3,10 @@
//! Mirrors the Go SeaweedFS volume server metrics.
use prometheus::{
self, Encoder, GaugeVec, HistogramOpts, HistogramVec, IntCounter, IntCounterVec, IntGauge,
IntGaugeVec, Opts, Registry, TextEncoder,
self, Encoder, GaugeVec, HistogramOpts, HistogramVec, IntCounterVec, IntGauge, IntGaugeVec,
Opts, Registry, TextEncoder,
};
use std::sync::{LazyLock, Once};
use std::sync::Once;
use crate::version;
@@ -16,320 +16,203 @@ pub struct PushGatewayConfig {
pub interval_seconds: u32,
}
pub static REGISTRY: LazyLock<Registry> = LazyLock::new(Registry::new);
lazy_static::lazy_static! {
pub static ref REGISTRY: Registry = Registry::new();
// ---- Request metrics (Go: VolumeServerRequestCounter, VolumeServerRequestHistogram) ----
// ---- Request metrics (Go: VolumeServerRequestCounter, VolumeServerRequestHistogram) ----
/// Request counter with labels `type` (HTTP method) and `code` (HTTP status).
pub static REQUEST_COUNTER: LazyLock<IntCounterVec> = LazyLock::new(|| {
IntCounterVec::new(
Opts::new(
"SeaweedFS_volumeServer_request_total",
"Volume server requests",
),
/// Request counter with labels `type` (HTTP method) and `code` (HTTP status).
pub static ref REQUEST_COUNTER: IntCounterVec = IntCounterVec::new(
Opts::new("SeaweedFS_volumeServer_request_total", "Volume server requests"),
&["type", "code"],
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Request duration histogram with label `type` (HTTP method).
pub static REQUEST_DURATION: LazyLock<HistogramVec> = LazyLock::new(|| {
HistogramVec::new(
/// Request duration histogram with label `type` (HTTP method).
pub static ref REQUEST_DURATION: HistogramVec = HistogramVec::new(
HistogramOpts::new(
"SeaweedFS_volumeServer_request_seconds",
"Volume server request duration in seconds",
)
.buckets(exponential_buckets(0.0001, 2.0, 24)),
).buckets(exponential_buckets(0.0001, 2.0, 24)),
&["type"],
)
.expect("metric can be created")
});
).expect("metric can be created");
// ---- Handler counters (Go: VolumeServerHandlerCounter) ----
// ---- Handler counters (Go: VolumeServerHandlerCounter) ----
/// Handler-level operation counter with label `type`.
pub static HANDLER_COUNTER: LazyLock<IntCounterVec> = LazyLock::new(|| {
IntCounterVec::new(
Opts::new(
"SeaweedFS_volumeServer_handler_total",
"Volume server handler counters",
),
/// Handler-level operation counter with label `type`.
pub static ref HANDLER_COUNTER: IntCounterVec = IntCounterVec::new(
Opts::new("SeaweedFS_volumeServer_handler_total", "Volume server handler counters"),
&["type"],
)
.expect("metric can be created")
});
).expect("metric can be created");
// ---- Vacuuming metrics (Go: VolumeServerVacuuming*) ----
// ---- Vacuuming metrics (Go: VolumeServerVacuuming*) ----
/// Vacuuming compact counter with label `success` (true/false).
pub static VACUUMING_COMPACT_COUNTER: LazyLock<IntCounterVec> = LazyLock::new(|| {
IntCounterVec::new(
Opts::new(
"SeaweedFS_volumeServer_vacuuming_compact_count",
"Counter of volume vacuuming Compact counter",
),
/// Vacuuming compact counter with label `success` (true/false).
pub static ref VACUUMING_COMPACT_COUNTER: IntCounterVec = IntCounterVec::new(
Opts::new("SeaweedFS_volumeServer_vacuuming_compact_count", "Counter of volume vacuuming Compact counter"),
&["success"],
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Vacuuming commit counter with label `success` (true/false).
pub static VACUUMING_COMMIT_COUNTER: LazyLock<IntCounterVec> = LazyLock::new(|| {
IntCounterVec::new(
Opts::new(
"SeaweedFS_volumeServer_vacuuming_commit_count",
"Counter of volume vacuuming commit counter",
),
/// Vacuuming commit counter with label `success` (true/false).
pub static ref VACUUMING_COMMIT_COUNTER: IntCounterVec = IntCounterVec::new(
Opts::new("SeaweedFS_volumeServer_vacuuming_commit_count", "Counter of volume vacuuming commit counter"),
&["success"],
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Vacuuming duration histogram with label `type` (compact/commit).
pub static VACUUMING_HISTOGRAM: LazyLock<HistogramVec> = LazyLock::new(|| {
HistogramVec::new(
/// Vacuuming duration histogram with label `type` (compact/commit).
pub static ref VACUUMING_HISTOGRAM: HistogramVec = HistogramVec::new(
HistogramOpts::new(
"SeaweedFS_volumeServer_vacuuming_seconds",
"Volume vacuuming duration in seconds",
)
.buckets(exponential_buckets(0.0001, 2.0, 24)),
).buckets(exponential_buckets(0.0001, 2.0, 24)),
&["type"],
)
.expect("metric can be created")
});
).expect("metric can be created");
// ---- Volume gauges (Go: VolumeServerVolumeGauge, VolumeServerReadOnlyVolumeGauge) ----
// ---- Volume gauges (Go: VolumeServerVolumeGauge, VolumeServerReadOnlyVolumeGauge) ----
/// Volumes per collection and type (volume/ec_shards).
pub static VOLUME_GAUGE: LazyLock<GaugeVec> = LazyLock::new(|| {
GaugeVec::new(
/// Volumes per collection and type (volume/ec_shards).
pub static ref VOLUME_GAUGE: GaugeVec = GaugeVec::new(
Opts::new("SeaweedFS_volumeServer_volumes", "Number of volumes"),
&["collection", "type"],
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Read-only volumes per collection and type.
pub static READ_ONLY_VOLUME_GAUGE: LazyLock<GaugeVec> = LazyLock::new(|| {
GaugeVec::new(
Opts::new(
"SeaweedFS_volumeServer_read_only_volumes",
"Number of read-only volumes.",
),
/// Read-only volumes per collection and type.
pub static ref READ_ONLY_VOLUME_GAUGE: GaugeVec = GaugeVec::new(
Opts::new("SeaweedFS_volumeServer_read_only_volumes", "Number of read-only volumes."),
&["collection", "type"],
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Maximum number of volumes this server can hold.
pub static MAX_VOLUMES: LazyLock<IntGauge> = LazyLock::new(|| {
IntGauge::new(
/// Maximum number of volumes this server can hold.
pub static ref MAX_VOLUMES: IntGauge = IntGauge::new(
"SeaweedFS_volumeServer_max_volumes",
"Maximum number of volumes",
)
.expect("metric can be created")
});
).expect("metric can be created");
// ---- Disk size gauges (Go: VolumeServerDiskSizeGauge) ----
// ---- Disk size gauges (Go: VolumeServerDiskSizeGauge) ----
/// Actual disk size used by volumes per collection and type (normal/deleted_bytes/ec).
pub static DISK_SIZE_GAUGE: LazyLock<GaugeVec> = LazyLock::new(|| {
GaugeVec::new(
Opts::new(
"SeaweedFS_volumeServer_total_disk_size",
"Actual disk size used by volumes",
),
/// Actual disk size used by volumes per collection and type (normal/deleted_bytes/ec).
pub static ref DISK_SIZE_GAUGE: GaugeVec = GaugeVec::new(
Opts::new("SeaweedFS_volumeServer_total_disk_size", "Actual disk size used by volumes"),
&["collection", "type"],
)
.expect("metric can be created")
});
).expect("metric can be created");
// ---- Resource gauges (Go: VolumeServerResourceGauge) ----
// ---- Resource gauges (Go: VolumeServerResourceGauge) ----
/// Disk resource usage per directory and type (all/used/free/avail).
pub static RESOURCE_GAUGE: LazyLock<GaugeVec> = LazyLock::new(|| {
GaugeVec::new(
/// Disk resource usage per directory and type (all/used/free/avail).
pub static ref RESOURCE_GAUGE: GaugeVec = GaugeVec::new(
Opts::new("SeaweedFS_volumeServer_resource", "Server resource usage"),
&["name", "type"],
)
.expect("metric can be created")
});
).expect("metric can be created");
// ---- In-flight gauges (Go: VolumeServerInFlightRequestsGauge, InFlightDownload/UploadSize) ----
// ---- In-flight gauges (Go: VolumeServerInFlightRequestsGauge, InFlightDownload/UploadSize) ----
/// In-flight requests per HTTP method.
pub static INFLIGHT_REQUESTS_GAUGE: LazyLock<IntGaugeVec> = LazyLock::new(|| {
IntGaugeVec::new(
Opts::new(
"SeaweedFS_volumeServer_in_flight_requests",
"Current number of in-flight requests being handled by volume server.",
),
/// In-flight requests per HTTP method.
pub static ref INFLIGHT_REQUESTS_GAUGE: IntGaugeVec = IntGaugeVec::new(
Opts::new("SeaweedFS_volumeServer_in_flight_requests", "Current number of in-flight requests being handled by volume server."),
&["type"],
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Concurrent download limit in bytes.
pub static CONCURRENT_DOWNLOAD_LIMIT: LazyLock<IntGauge> = LazyLock::new(|| {
IntGauge::new(
/// Concurrent download limit in bytes.
pub static ref CONCURRENT_DOWNLOAD_LIMIT: IntGauge = IntGauge::new(
"SeaweedFS_volumeServer_concurrent_download_limit",
"Limit for total concurrent download size in bytes",
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Concurrent upload limit in bytes.
pub static CONCURRENT_UPLOAD_LIMIT: LazyLock<IntGauge> = LazyLock::new(|| {
IntGauge::new(
/// Concurrent upload limit in bytes.
pub static ref CONCURRENT_UPLOAD_LIMIT: IntGauge = IntGauge::new(
"SeaweedFS_volumeServer_concurrent_upload_limit",
"Limit for total concurrent upload size in bytes",
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Current in-flight download bytes.
pub static INFLIGHT_DOWNLOAD_SIZE: LazyLock<IntGauge> = LazyLock::new(|| {
IntGauge::new(
/// Current in-flight download bytes.
pub static ref INFLIGHT_DOWNLOAD_SIZE: IntGauge = IntGauge::new(
"SeaweedFS_volumeServer_in_flight_download_size",
"In flight total download size.",
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Current in-flight upload bytes.
pub static INFLIGHT_UPLOAD_SIZE: LazyLock<IntGauge> = LazyLock::new(|| {
IntGauge::new(
/// Current in-flight upload bytes.
pub static ref INFLIGHT_UPLOAD_SIZE: IntGauge = IntGauge::new(
"SeaweedFS_volumeServer_in_flight_upload_size",
"In flight total upload size.",
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Upload error counter by HTTP status code. Code "0" = transport error (no response).
pub static UPLOAD_ERROR_COUNTER: LazyLock<IntCounterVec> = LazyLock::new(|| {
IntCounterVec::new(
Opts::new("SeaweedFS_upload_error_total",
"Counter of upload errors by HTTP status code. Code 0 means transport error (no response received)."),
&["code"],
).expect("metric can be created")
});
/// Upload error counter by HTTP status code. Code "0" = transport error (no response).
pub static ref UPLOAD_ERROR_COUNTER: IntCounterVec = IntCounterVec::new(
Opts::new("SeaweedFS_upload_error_total",
"Counter of upload errors by HTTP status code. Code 0 means transport error (no response received)."),
&["code"],
).expect("metric can be created");
// ---- Scrubbing metrics (Go: VolumeServerScrub*) ----
// ---- Scrubbing metrics (Go: VolumeServerScrub*) ----
/// Last scrub execution time, as seconds since UNIX epoch, with label `mode`.
pub static SCRUB_LAST_TIME_SECONDS: LazyLock<GaugeVec> = LazyLock::new(|| {
GaugeVec::new(
/// Last scrub execution time, as seconds since UNIX epoch, with label `mode`.
pub static ref SCRUB_LAST_TIME_SECONDS: GaugeVec = GaugeVec::new(
Opts::new(
"SeaweedFS_volumeServer_scrub_last_time_seconds",
"Last scrub execution time, as seconds since UNIX epoch.",
),
&["mode"],
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Counter of overall volumes with issues detected during scrubbing, with label `mode`.
pub static SCRUB_VOLUME_FAILURES: LazyLock<IntCounterVec> = LazyLock::new(|| {
IntCounterVec::new(
/// Counter of overall volumes with issues detected during scrubbing, with label `mode`.
pub static ref SCRUB_VOLUME_FAILURES: IntCounterVec = IntCounterVec::new(
Opts::new(
"SeaweedFS_volumeServer_scrub_volume_failures",
"Counter of overall volumes with issues detected during scrubbing.",
),
&["mode"],
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Counter of overall EC shards with issues detected during scrubbing, with label `mode`.
pub static SCRUB_SHARD_FAILURES: LazyLock<IntCounterVec> = LazyLock::new(|| {
IntCounterVec::new(
/// Counter of overall EC shards with issues detected during scrubbing, with label `mode`.
pub static ref SCRUB_SHARD_FAILURES: IntCounterVec = IntCounterVec::new(
Opts::new(
"SeaweedFS_volumeServer_scrub_shard_failures",
"Counter of overall EC shards with issues detected during scrubbing.",
),
&["mode"],
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Counter of storage read/write EIO errors on volumes and EC shards.
/// Mirrors Go's VolumeServerStorageIoErrorCounter.
pub static STORAGE_IO_ERROR_COUNTER: LazyLock<IntCounter> = LazyLock::new(|| {
IntCounter::new(
"SeaweedFS_volumeServer_storage_io_error_total",
"Counter of storage read/write EIO errors on volumes and EC shards.",
)
.expect("metric can be created")
});
// ---- Legacy aliases for backward compat with existing code ----
/// Number of volumes quarantined due to storage IO errors.
/// Mirrors Go's VolumeServerIoQuarantineGauge.
pub static IO_QUARANTINE_GAUGE: LazyLock<IntGaugeVec> = LazyLock::new(|| {
IntGaugeVec::new(
Opts::new(
"SeaweedFS_volumeServer_io_quarantine",
"Number of volumes or EC shards quarantined due to storage IO errors.",
),
&["kind"],
)
.expect("metric can be created")
});
/// Total number of volumes on this server (flat gauge).
pub static ref VOLUMES_TOTAL: IntGauge = IntGauge::new(
"volume_server_volumes_total",
"Total number of volumes",
).expect("metric can be created");
// ---- Legacy aliases for backward compat with existing code ----
/// Total number of volumes on this server (flat gauge).
pub static VOLUMES_TOTAL: LazyLock<IntGauge> = LazyLock::new(|| {
IntGauge::new("volume_server_volumes_total", "Total number of volumes")
.expect("metric can be created")
});
/// Disk size in bytes per directory.
pub static DISK_SIZE_BYTES: LazyLock<IntGaugeVec> = LazyLock::new(|| {
IntGaugeVec::new(
/// Disk size in bytes per directory.
pub static ref DISK_SIZE_BYTES: IntGaugeVec = IntGaugeVec::new(
Opts::new("volume_server_disk_size_bytes", "Disk size in bytes"),
&["dir"],
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Disk free bytes per directory.
pub static DISK_FREE_BYTES: LazyLock<IntGaugeVec> = LazyLock::new(|| {
IntGaugeVec::new(
/// Disk free bytes per directory.
pub static ref DISK_FREE_BYTES: IntGaugeVec = IntGaugeVec::new(
Opts::new("volume_server_disk_free_bytes", "Disk free space in bytes"),
&["dir"],
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Current number of in-flight requests (flat gauge).
pub static INFLIGHT_REQUESTS: LazyLock<IntGauge> = LazyLock::new(|| {
IntGauge::new(
/// Current number of in-flight requests (flat gauge).
pub static ref INFLIGHT_REQUESTS: IntGauge = IntGauge::new(
"volume_server_inflight_requests",
"Current number of in-flight requests",
)
.expect("metric can be created")
});
).expect("metric can be created");
/// Total number of files stored across all volumes.
pub static VOLUME_FILE_COUNT: LazyLock<IntGauge> = LazyLock::new(|| {
IntGauge::new(
/// Total number of files stored across all volumes.
pub static ref VOLUME_FILE_COUNT: IntGauge = IntGauge::new(
"volume_server_volume_file_count",
"Total number of files stored across all volumes",
)
.expect("metric can be created")
});
).expect("metric can be created");
// ---- Build info (Go: BuildInfo) ----
// ---- Build info (Go: BuildInfo) ----
/// Build information gauge, always set to 1. Matches Go:
/// Namespace="SeaweedFS", Subsystem="build", Name="info",
/// labels: version, commit, sizelimit, goos, goarch.
pub static BUILD_INFO: LazyLock<GaugeVec> = LazyLock::new(|| {
GaugeVec::new(
Opts::new("SeaweedFS_build_info", "A metric with a constant '1' value labeled by version, commit, sizelimit, goos, and goarch from which SeaweedFS was built."),
&["version", "commit", "sizelimit", "goos", "goarch"],
).expect("metric can be created")
});
/// Build information gauge, always set to 1. Matches Go:
/// Namespace="SeaweedFS", Subsystem="build", Name="info",
/// labels: version, commit, sizelimit, goos, goarch.
pub static ref BUILD_INFO: GaugeVec = GaugeVec::new(
Opts::new("SeaweedFS_build_info", "A metric with a constant '1' value labeled by version, commit, sizelimit, goos, and goarch from which SeaweedFS was built."),
&["version", "commit", "sizelimit", "goos", "goarch"],
).expect("metric can be created");
}
/// Generate exponential bucket boundaries for histograms.
fn exponential_buckets(start: f64, factor: f64, count: usize) -> Vec<f64> {
@@ -400,8 +283,6 @@ pub fn register_metrics() {
Box::new(SCRUB_LAST_TIME_SECONDS.clone()),
Box::new(SCRUB_VOLUME_FAILURES.clone()),
Box::new(SCRUB_SHARD_FAILURES.clone()),
Box::new(STORAGE_IO_ERROR_COUNTER.clone()),
Box::new(IO_QUARANTINE_GAUGE.clone()),
// Legacy metrics
Box::new(VOLUMES_TOTAL.clone()),
Box::new(DISK_SIZE_BYTES.clone()),
@@ -477,8 +358,10 @@ fn delete_partial_match_collection(gauge: &GaugeVec, collection: &str) {
type_value = Some(label.get_value().to_string());
}
}
if matches_collection && let Some(ref tv) = type_value {
let _ = gauge.remove_label_values(&[collection, tv]);
if matches_collection {
if let Some(ref tv) = type_value {
let _ = gauge.remove_label_values(&[collection, tv]);
}
}
}
}
@@ -173,10 +173,10 @@ pub fn check_blocked_ip_policy(endpoint: &str, ip: IpAddr, allow_private: bool)
// same host wherever the matching relay exists (common in IPv6-only cloud).
// to_ipv4_mapped above only covers ::ffff: mapped addresses, so pull the
// embedded IPv4 out of the other forms and re-check it against the rules.
if let IpAddr::V6(v6) = ip
&& let Some(v4) = embedded_transition_ipv4(v6)
{
return check_blocked_ip_policy(endpoint, IpAddr::V4(v4), allow_private);
if let IpAddr::V6(v6) = ip {
if let Some(v4) = embedded_transition_ipv4(v6) {
return check_blocked_ip_policy(endpoint, IpAddr::V4(v4), allow_private);
}
}
Ok(())
}
@@ -214,7 +214,9 @@ fn precheck_endpoint(endpoint: &str) -> Result<HostCheck, String> {
// Authority is everything up to the first '/', '?', or '#'.
let after = &trimmed[scheme_end + 3..];
let authority_end = after.find(['/', '?', '#']).unwrap_or(after.len());
let authority_end = after
.find(|c| c == '/' || c == '?' || c == '#')
.unwrap_or(after.len());
let authority = &after[..authority_end];
// Strip optional userinfo ("user:pass@").
+30 -71
View File
@@ -81,17 +81,13 @@ impl S3TierBackend {
/// Returns (s3_key, file_size) on success.
/// The progress callback receives (bytes_uploaded, percentage).
/// Uses 64MB part size and 5 concurrent uploads (matches Go s3manager).
/// `progress_fn` returning `Err` aborts the upload, mirroring Go's
/// `fn(progressed, percentage) error` in `s3_upload.go`, where the error
/// surfaces out of `ReadAt` and fails the transfer. It is what lets a
/// caller that has hung up stop the work it is no longer waiting for.
pub async fn upload_file<F>(
&self,
file_path: &str,
progress_fn: F,
) -> Result<(String, u64), String>
where
F: FnMut(i64, f32) -> Result<(), String> + Send + Sync + 'static,
F: FnMut(i64, f32) + Send + Sync + 'static,
{
let key = uuid::Uuid::new_v4().to_string();
@@ -188,10 +184,8 @@ impl S3TierBackend {
let e_tag = upload_part_resp.e_tag().unwrap_or_default().to_string();
// Report progress. The lock is released before the result is
// propagated so an aborting callback cannot poison the mutex
// for the other parts still in flight.
let progress_result = {
// Report progress
{
let mut guard = progress.lock().unwrap();
guard.0 += size as u64;
let uploaded = guard.0;
@@ -200,9 +194,8 @@ impl S3TierBackend {
} else {
100.0
};
(guard.1)(uploaded as i64, pct)
};
progress_result?;
(guard.1)(uploaded as i64, pct);
}
Ok::<_, String>(
CompletedPart::builder()
@@ -213,58 +206,29 @@ impl S3TierBackend {
}));
}
let finish = async {
// Collect results, preserving part order
let mut completed_parts = Vec::with_capacity(handles.len());
for handle in handles {
let part = handle
.await
.map_err(|e| format!("upload task panicked: {}", e))??;
completed_parts.push(part);
}
// Complete multipart upload
let completed_upload = CompletedMultipartUpload::builder()
.set_parts(Some(completed_parts))
.build();
self.client
.complete_multipart_upload()
.bucket(&self.bucket)
.key(&key)
.upload_id(&upload_id)
.multipart_upload(completed_upload)
.send()
// Collect results, preserving part order
let mut completed_parts = Vec::with_capacity(handles.len());
for handle in handles {
let part = handle
.await
.map_err(|e| format!("failed to complete multipart upload: {}", e))?;
Ok::<(), String>(())
.map_err(|e| format!("upload task panicked: {}", e))??;
completed_parts.push(part);
}
.await;
if let Err(e) = finish {
// An abandoned multipart upload does not appear in an ordinary
// object listing but still accrues storage charges until a
// lifecycle rule reaps it. Now that a departing caller aborts the
// transfer this is a routine path, not a rare one.
if let Err(abort_err) = self
.client
.abort_multipart_upload()
.bucket(&self.bucket)
.key(&key)
.upload_id(&upload_id)
.send()
.await
{
tracing::warn!(
"failed to abort multipart upload {} for key {}: {}",
upload_id,
key,
abort_err
);
}
return Err(e);
}
// Complete multipart upload
let completed_upload = CompletedMultipartUpload::builder()
.set_parts(Some(completed_parts))
.build();
self.client
.complete_multipart_upload()
.bucket(&self.bucket)
.key(&key)
.upload_id(&upload_id)
.multipart_upload(completed_upload)
.send()
.await
.map_err(|e| format!("failed to complete multipart upload: {}", e))?;
Ok((key, file_size))
}
@@ -274,8 +238,6 @@ impl S3TierBackend {
///
/// Returns the file size on success.
/// Uses 64MB part size and 5 concurrent downloads (matches Go s3manager).
/// `progress_fn` returning `Err` aborts the download, mirroring Go's
/// `fn(progressed, percentage) error` in `s3_download.go`.
pub async fn download_file<F>(
&self,
dest_path: &str,
@@ -283,7 +245,7 @@ impl S3TierBackend {
progress_fn: F,
) -> Result<u64, String>
where
F: FnMut(i64, f32) -> Result<(), String> + Send + Sync + 'static,
F: FnMut(i64, f32) + Send + Sync + 'static,
{
// Get file size first
let head_resp = self
@@ -378,10 +340,8 @@ impl S3TierBackend {
.await
.map_err(|e| format!("failed to write to {}: {}", dp, e))?;
// Report progress. The lock is released before the result is
// propagated so an aborting callback cannot poison the mutex
// for the other parts still in flight.
let progress_result = {
// Report progress
{
let mut guard = progress.lock().unwrap();
guard.0 += bytes.len() as u64;
let downloaded = guard.0;
@@ -390,9 +350,8 @@ impl S3TierBackend {
} else {
100.0
};
(guard.1)(downloaded as i64, pct)
};
progress_result?;
(guard.1)(downloaded as i64, pct);
}
Ok::<_, String>(())
}));
+4 -4
View File
@@ -297,10 +297,10 @@ impl Guard {
/// Extract host from "host:port" or "[::1]:port" format.
fn extract_host(addr: &str) -> String {
// Handle IPv6 with brackets
if addr.starts_with('[')
&& let Some(end) = addr.find(']')
{
return addr[1..end].to_string();
if addr.starts_with('[') {
if let Some(end) = addr.find(']') {
return addr[1..end].to_string();
}
}
// Handle host:port
if let Some(pos) = addr.rfind(':') {
File diff suppressed because it is too large Load Diff
+307 -308
View File
@@ -193,7 +193,10 @@ impl http_body::Body for StreamingBody {
}
Ok(Err(e)) => return std::task::Poll::Ready(Some(Err(e))),
Err(e) => {
return std::task::Poll::Ready(Some(Err(std::io::Error::other(e))));
return std::task::Poll::Ready(Some(Err(std::io::Error::new(
std::io::ErrorKind::Other,
e,
))))
}
}
}
@@ -319,9 +322,10 @@ fn parse_url_path(path: &str) -> Option<(VolumeId, NeedleId, Cookie)> {
// Try "vid,fid" or "vid/fid" or "vid/fid/filename" formats
let (vid_str, fid_part) = if let Some(pos) = path.find(',') {
(&path[..pos], &path[pos + 1..])
} else {
let pos = path.find('/')?;
} else if let Some(pos) = path.find('/') {
(&path[..pos], &path[pos + 1..])
} else {
return None;
};
// For fid part, strip extension from the fid (not from filename)
@@ -352,10 +356,6 @@ fn parse_url_path(path: &str) -> Option<(VolumeId, NeedleId, Cookie)> {
#[derive(Clone, Debug, Deserialize)]
struct VolumeLocation {
url: String,
#[serde(rename = "readOnly", default)]
read_only: bool,
#[serde(rename = "readOnlyCanDelete", default)]
read_only_can_delete: bool,
// Master often omits publicUrl when it matches url (Go json omitempty).
#[serde(rename = "publicUrl", default)]
public_url: String,
@@ -405,10 +405,10 @@ async fn lookup_volume(
.json()
.await
.map_err(|e| format!("lookup parse failed: {}", e))?;
if let Some(err) = result.error
&& !err.is_empty()
{
return Err(err);
if let Some(err) = result.error {
if !err.is_empty() {
return Err(err);
}
}
Ok(result.locations.unwrap_or_default())
}
@@ -546,39 +546,27 @@ async fn do_replicated_request(
.await
.map_err(|e| format!("lookup volume failed: {}", e))?;
// Mirror Go's GetWritableRemoteReplications: reject when the master reports fewer replicas than
// the copy count. lookup_volume is uncached, so recovery is immediate once the replica re-registers.
let copy_count = {
let store = state.store.read().unwrap();
store
.find_volume(VolumeId(vid))
.map_or(1, |(_, v)| v.super_block.replica_placement.get_copy_count())
};
let allow_delete = method == axum::http::Method::DELETE;
let eligible_locations: Vec<_> = locations
.into_iter()
.filter(|loc| {
(!loc.read_only && allow_delete)
|| (!loc.read_only && !allow_delete)
|| (allow_delete && loc.read_only_can_delete)
store.find_volume(VolumeId(vid)).map_or(1, |(_, v)| {
v.super_block.replica_placement.get_copy_count()
})
.collect();
if eligible_locations.len() < copy_count as usize {
};
if locations.len() < copy_count as usize {
return Err(format!(
"replicating operations [{}] is less than volume {} replication copy count [{}]",
eligible_locations.len(),
locations.len(),
vid,
copy_count
));
}
let self_http = to_http_address(&state.self_url);
let remote_locations: Vec<_> = eligible_locations
let remote_locations: Vec<_> = locations
.into_iter()
.filter(|loc| {
if (!allow_delete && loc.read_only)
|| (allow_delete && loc.read_only && !loc.read_only_can_delete)
{
return false;
}
to_http_address(&loc.url) != self_http
&& to_http_address(loc.public_or_url()) != self_http
})
@@ -684,8 +672,7 @@ fn build_proxy_request_info(
raw_fid
};
(trimmed[..pos].to_string(), fid.to_string())
} else {
let pos = trimmed.find('/')?;
} else if let Some(pos) = trimmed.find('/') {
let after = &trimmed[pos + 1..];
let fid_part = if let Some(slash) = after.find('/') {
&after[..slash]
@@ -693,6 +680,8 @@ fn build_proxy_request_info(
after
};
(trimmed[..pos].to_string(), fid_part.to_string())
} else {
return None;
};
Some(ProxyRequestInfo {
@@ -832,12 +821,12 @@ fn redirect_request(info: &ProxyRequestInfo, target: &VolumeLocation, scheme: &s
let mut query_params = Vec::new();
if !info.original_query.is_empty() {
for param in info.original_query.split('&') {
if let Some((key, value)) = param.split_once('=')
&& key == "collection"
{
query_params.push(format!("collection={}", value));
if let Some((key, value)) = param.split_once('=') {
if key == "collection" {
query_params.push(format!("collection={}", value));
}
// Intentionally drop readDeleted and other params (Go parity)
}
// Intentionally drop readDeleted and other params (Go parity)
}
}
query_params.push("proxied=true".to_string());
@@ -847,7 +836,7 @@ fn redirect_request(info: &ProxyRequestInfo, target: &VolumeLocation, scheme: &s
let target_http = to_http_address(&target.url);
let raw_target = format!(
"{}/{},{}?{}",
target_http, info.vid_str, info.fid_str, query
target_http, &info.vid_str, &info.fid_str, query
);
let location = match normalize_outgoing_http_url(scheme, &raw_target) {
Ok(url) => url,
@@ -949,12 +938,12 @@ async fn get_or_head_handler_inner(
// so invalid paths with JWT enabled return 401, not 400.
let file_id = extract_file_id(&path);
let token = extract_jwt(&headers, request.uri());
if state
.guard
.read()
.unwrap()
.check_jwt_for_file(token.as_deref(), &file_id, false)
.is_err()
if let Err(_) =
state
.guard
.read()
.unwrap()
.check_jwt_for_file(token.as_deref(), &file_id, false)
{
let body = serde_json::json!({"error": "wrong jwt"});
return Response::builder()
@@ -1010,15 +999,16 @@ async fn get_or_head_handler_inner(
let should_try_replica =
!query_string.contains("proxied=true") && !state.master_url.is_empty() && {
let store = state.store.read().unwrap();
store.find_volume(vid).is_some_and(|(_, vol)| {
store.find_volume(vid).map_or(false, |(_, vol)| {
vol.super_block.replica_placement.get_copy_count() > 1
})
};
if should_try_replica
&& let Some(info) =
if should_try_replica {
if let Some(info) =
build_proxy_request_info(&path, request.headers(), &query_string)
{
return proxy_or_redirect_to_target(&state, info, vid, true).await;
{
return proxy_or_redirect_to_target(&state, info, vid, true).await;
}
}
// Blocking wait loop (Go's waitForDownloadSlot)
@@ -1057,8 +1047,8 @@ async fn get_or_head_handler_inner(
let has_range = headers.contains_key(header::RANGE);
let ext = extract_extension_from_path(&path);
// Go checks resize and crop extensions separately: resize supports .webp, crop does not.
let has_resize_ops = is_image_resize_ext(&ext)
&& (query.width.unwrap_or(0) > 0 || query.height.unwrap_or(0) > 0);
let has_resize_ops =
is_image_resize_ext(&ext) && (query.width.unwrap_or(0) > 0 || query.height.unwrap_or(0) > 0);
// Go's shouldCropImages (L410) requires x2 > x1 && y2 > y1 (x1/y1 default 0).
// Only disable streaming when a real crop will actually happen.
let has_crop_ops = is_image_crop_ext(&ext) && {
@@ -1087,8 +1077,10 @@ async fn get_or_head_handler_inner(
// 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
match crate::server::store_ec::read_ec_shard_needle_distributed(
&state, vid, needle_id,
)
.await
{
Ok(Some(ec_needle)) => {
n = ec_needle;
@@ -1109,7 +1101,10 @@ async fn get_or_head_handler_inner(
if e.kind() == std::io::ErrorKind::NotFound {
return StatusCode::NOT_FOUND.into_response();
}
return (StatusCode::INTERNAL_SERVER_ERROR, format!("ec read: {}", e))
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("ec read: {}", e),
)
.into_response();
}
}
@@ -1225,53 +1220,57 @@ async fn get_or_head_handler_inner(
// Build Last-Modified header (RFC 1123 format) — must be done before conditional checks
let last_modified_str = if n.last_modified > 0 {
use chrono::{TimeZone, Utc};
Utc.timestamp_opt(n.last_modified as i64, 0)
.single()
.map(|dt| dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
if let Some(dt) = Utc.timestamp_opt(n.last_modified as i64, 0).single() {
Some(dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
} else {
None
}
} else {
None
};
// Check If-Modified-Since FIRST (Go checks this before If-None-Match)
if n.last_modified > 0
&& let Some(ims_header) = headers.get(header::IF_MODIFIED_SINCE)
&& let Ok(ims_str) = ims_header.to_str()
{
// Parse HTTP date format: "Mon, 02 Jan 2006 15:04:05 GMT"
if let Ok(ims_time) =
chrono::NaiveDateTime::parse_from_str(ims_str, "%a, %d %b %Y %H:%M:%S GMT")
&& (n.last_modified as i64) <= ims_time.and_utc().timestamp()
{
let mut resp = StatusCode::NOT_MODIFIED.into_response();
if let Some(ref lm) = last_modified_str {
resp.headers_mut()
.insert(header::LAST_MODIFIED, lm.parse().unwrap());
if n.last_modified > 0 {
if let Some(ims_header) = headers.get(header::IF_MODIFIED_SINCE) {
if let Ok(ims_str) = ims_header.to_str() {
// Parse HTTP date format: "Mon, 02 Jan 2006 15:04:05 GMT"
if let Ok(ims_time) =
chrono::NaiveDateTime::parse_from_str(ims_str, "%a, %d %b %Y %H:%M:%S GMT")
{
if (n.last_modified as i64) <= ims_time.and_utc().timestamp() {
let mut resp = StatusCode::NOT_MODIFIED.into_response();
if let Some(ref lm) = last_modified_str {
resp.headers_mut()
.insert(header::LAST_MODIFIED, lm.parse().unwrap());
}
// Go sets ETag AFTER the 304 return paths (L235), so 304 does NOT include ETag
return resp;
}
}
}
// Go sets ETag AFTER the 304 return paths (L235), so 304 does NOT include ETag
return resp;
}
}
// Check If-None-Match SECOND
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH)
&& let Ok(inm) = if_none_match.to_str()
&& inm == etag
{
let mut resp = StatusCode::NOT_MODIFIED.into_response();
if let Some(ref lm) = last_modified_str {
resp.headers_mut()
.insert(header::LAST_MODIFIED, lm.parse().unwrap());
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
if let Ok(inm) = if_none_match.to_str() {
if inm == etag {
let mut resp = StatusCode::NOT_MODIFIED.into_response();
if let Some(ref lm) = last_modified_str {
resp.headers_mut()
.insert(header::LAST_MODIFIED, lm.parse().unwrap());
}
// Go sets ETag AFTER the 304 return paths (L235), so 304 does NOT include ETag
return resp;
}
}
// Go sets ETag AFTER the 304 return paths (L235), so 304 does NOT include ETag
return resp;
}
// Chunk manifest expansion (needs full data) — after conditional checks, before response
// Pass ETag so chunk manifest responses include it (matches Go: ETag is set on the
// response writer before tryHandleChunkedFile runs).
if n.is_chunk_manifest()
&& !bypass_cm
&& let Some(resp) = try_expand_chunk_manifest(
if n.is_chunk_manifest() && !bypass_cm {
if let Some(resp) = try_expand_chunk_manifest(
&state,
&n,
&headers,
@@ -1282,26 +1281,27 @@ async fn get_or_head_handler_inner(
&last_modified_str,
)
.await
{
return resp;
{
return resp;
}
// If manifest expansion fails (invalid JSON etc.), fall through to raw data
}
// If manifest expansion fails (invalid JSON etc.), fall through to raw data
let mut response_headers = HeaderMap::new();
response_headers.insert(header::ETAG, etag.parse().unwrap());
// H1: Emit pairs as response headers
if n.has_pairs()
&& !n.pairs.is_empty()
&& let Ok(pair_map) =
if n.has_pairs() && !n.pairs.is_empty() {
if let Ok(pair_map) =
serde_json::from_slice::<std::collections::HashMap<String, String>>(&n.pairs)
{
for (k, v) in &pair_map {
if let (Ok(hname), Ok(hval)) = (
axum::http::HeaderName::from_bytes(k.as_bytes()),
axum::http::HeaderValue::from_str(v),
) {
response_headers.insert(hname, hval);
{
for (k, v) in &pair_map {
if let (Ok(hname), Ok(hval)) = (
axum::http::HeaderName::from_bytes(k.as_bytes()),
axum::http::HeaderValue::from_str(v),
) {
response_headers.insert(hname, hval);
}
}
}
}
@@ -1311,10 +1311,10 @@ async fn get_or_head_handler_inner(
let mut ext = ext;
if n.name_size > 0 && filename.is_empty() {
filename = String::from_utf8_lossy(&n.name).to_string();
if ext.is_empty()
&& let Some(dot_pos) = filename.rfind('.')
{
ext = filename[dot_pos..].to_lowercase();
if ext.is_empty() {
if let Some(dot_pos) = filename.rfind('.') {
ext = filename[dot_pos..].to_lowercase();
}
}
}
@@ -1418,72 +1418,80 @@ async fn get_or_head_handler_inner(
}
// ---- Streaming path: large uncompressed files ----
if can_stream && let Some(info) = stream_info {
response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
response_headers.insert(
header::CONTENT_LENGTH,
info.data_size.to_string().parse().unwrap(),
);
if can_stream {
if let Some(info) = stream_info {
response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
response_headers.insert(
header::CONTENT_LENGTH,
info.data_size.to_string().parse().unwrap(),
);
let tracked_bytes = info.data_size as i64;
let tracking_state = if download_guard.is_some() {
let new_val = state
.inflight_download_bytes
.fetch_add(tracked_bytes, Ordering::Relaxed)
+ tracked_bytes;
metrics::INFLIGHT_DOWNLOAD_SIZE.set(new_val);
Some(state.clone())
} else {
None
};
let streaming = StreamingBody {
source: info.source,
data_offset: info.data_file_offset,
data_size: info.data_size,
pos: 0,
chunk_size: streaming_chunk_size(state.read_buffer_size_bytes, info.data_size as usize),
_held_read_lease: if state.has_slow_read {
None
let tracked_bytes = info.data_size as i64;
let tracking_state = if download_guard.is_some() {
let new_val = state
.inflight_download_bytes
.fetch_add(tracked_bytes, Ordering::Relaxed)
+ tracked_bytes;
metrics::INFLIGHT_DOWNLOAD_SIZE.set(new_val);
Some(state.clone())
} else {
Some(info.data_file_access_control.read_lock())
},
data_file_access_control: info.data_file_access_control,
hold_read_lock_for_stream: !state.has_slow_read,
pending: None,
state: tracking_state,
tracked_bytes,
server_state: state.clone(),
volume_id: info.volume_id,
needle_id: info.needle_id,
compaction_revision: info.compaction_revision,
};
None
};
let body = Body::new(streaming);
let mut resp = Response::new(body);
*resp.status_mut() = StatusCode::OK;
*resp.headers_mut() = response_headers;
return resp;
let streaming = StreamingBody {
source: info.source,
data_offset: info.data_file_offset,
data_size: info.data_size,
pos: 0,
chunk_size: streaming_chunk_size(
state.read_buffer_size_bytes,
info.data_size as usize,
),
_held_read_lease: if state.has_slow_read {
None
} else {
Some(info.data_file_access_control.read_lock())
},
data_file_access_control: info.data_file_access_control,
hold_read_lock_for_stream: !state.has_slow_read,
pending: None,
state: tracking_state,
tracked_bytes,
server_state: state.clone(),
volume_id: info.volume_id,
needle_id: info.needle_id,
compaction_revision: info.compaction_revision,
};
let body = Body::new(streaming);
let mut resp = Response::new(body);
*resp.status_mut() = StatusCode::OK;
*resp.headers_mut() = response_headers;
return resp;
}
}
if can_handle_head_from_meta && let Some(info) = stream_info {
response_headers.insert(
header::CONTENT_LENGTH,
info.data_size.to_string().parse().unwrap(),
);
return (StatusCode::OK, response_headers).into_response();
if can_handle_head_from_meta {
if let Some(info) = stream_info {
response_headers.insert(
header::CONTENT_LENGTH,
info.data_size.to_string().parse().unwrap(),
);
return (StatusCode::OK, response_headers).into_response();
}
}
if can_handle_range_from_source
&& let (Some(range_header), Some(info)) = (headers.get(header::RANGE), stream_info)
&& let Ok(range_str) = range_header.to_str()
{
return handle_range_request_from_source(
range_str,
info,
response_headers,
track_download.then(|| state.clone()),
);
if can_handle_range_from_source {
if let (Some(range_header), Some(info)) = (headers.get(header::RANGE), stream_info) {
if let Ok(range_str) = range_header.to_str() {
return handle_range_request_from_source(
range_str,
info,
response_headers,
track_download.then(|| state.clone()),
);
}
}
}
// ---- Buffered path: small files, compressed, images, range requests ----
@@ -1553,15 +1561,15 @@ async fn get_or_head_handler_inner(
response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
// Check Range header
if let Some(range_header) = headers.get(header::RANGE)
&& let Ok(range_str) = range_header.to_str()
{
return handle_range_request(
range_str,
&data,
response_headers,
track_download.then(|| state.clone()),
);
if let Some(range_header) = headers.get(header::RANGE) {
if let Ok(range_str) = range_header.to_str() {
return handle_range_request(
range_str,
&data,
response_headers,
track_download.then(|| state.clone()),
);
}
}
if method == Method::HEAD {
@@ -1987,7 +1995,7 @@ fn extract_extension_from_path(path: &str) -> String {
if let Some(dot_pos) = filename.rfind('.') {
return filename[dot_pos..].to_lowercase();
}
} else if !parts.is_empty() {
} else if parts.len() >= 1 {
// 2-segment path: /vid,fid.ext or /vid/fid.ext
// Go's parseURLPath extracts ext from the full path for all formats
let last = parts[parts.len() - 1];
@@ -2110,7 +2118,7 @@ pub async fn post_handler(
// Go's r.ParseForm() returns 400 on malformed query strings
return json_error_with_query(
StatusCode::BAD_REQUEST,
format!("form parse error: {}", e),
&format!("form parse error: {}", e),
Some(&query),
);
}
@@ -2126,12 +2134,11 @@ pub async fn post_handler(
// JWT check for writes
let file_id = extract_file_id(&path);
let token = extract_jwt(&headers, request.uri());
if state
if let Err(_) = state
.guard
.read()
.unwrap()
.check_jwt_for_file(token.as_deref(), &file_id, true)
.is_err()
{
return json_error_with_query(StatusCode::UNAUTHORIZED, "wrong jwt", Some(&query));
}
@@ -2236,10 +2243,7 @@ pub async fn post_handler(
// With a limit configured, an error here means the body exceeded it
// before we buffered the whole thing; report it like the size check.
let msg = if state.file_size_limit_bytes > 0 {
format!(
"file over the limited {} bytes",
state.file_size_limit_bytes
)
format!("file over the limited {} bytes", state.file_size_limit_bytes)
} else {
format!("read body: {}", e)
};
@@ -2261,8 +2265,11 @@ pub async fn post_handler(
.split(';')
.find_map(|part| {
let part = part.trim();
part.strip_prefix("boundary=")
.map(|val| val.trim_matches('"').to_string())
if let Some(val) = part.strip_prefix("boundary=") {
Some(val.trim_matches('"').to_string())
} else {
None
}
})
.unwrap_or_default();
@@ -2404,17 +2411,17 @@ pub async fn post_handler(
} else {
None
};
if let (Some(expected_md5), Some(actual_md5)) = (&content_md5, &original_content_md5)
&& expected_md5 != actual_md5
{
return json_error_with_query(
StatusCode::BAD_REQUEST,
format!(
"Content-MD5 did not match md5 of file data expected [{}] received [{}] size {}",
expected_md5, actual_md5, original_data_size
),
Some(&query),
);
if let (Some(ref expected_md5), Some(ref actual_md5)) = (&content_md5, &original_content_md5) {
if expected_md5 != actual_md5 {
return json_error_with_query(
StatusCode::BAD_REQUEST,
format!(
"Content-MD5 did not match md5 of file data expected [{}] received [{}] size {}",
expected_md5, actual_md5, original_data_size
),
Some(&query),
);
}
}
let now = std::time::SystemTime::now()
@@ -2556,7 +2563,7 @@ pub async fn post_handler(
cookie,
data_size: final_data.len() as u32,
data: final_data,
last_modified,
last_modified: last_modified,
..Needle::default()
};
n.set_has_last_modified_date();
@@ -2574,21 +2581,22 @@ pub async fn post_handler(
}
// Set TTL on needle
if let Some(ref t) = ttl
&& !t.is_empty()
{
n.ttl = Some(*t);
n.set_has_ttl();
if let Some(ref t) = ttl {
if !t.is_empty() {
n.ttl = Some(*t);
n.set_has_ttl();
}
}
// Set pairs on needle
if !pair_map.is_empty()
&& let Ok(pairs_json) = serde_json::to_vec(&pair_map)
&& pairs_json.len() < 65536
{
n.pairs_size = pairs_json.len() as u16;
n.pairs = pairs_json;
n.set_has_pairs();
if !pair_map.is_empty() {
if let Ok(pairs_json) = serde_json::to_vec(&pair_map) {
if pairs_json.len() < 65536 {
n.pairs_size = pairs_json.len() as u16;
n.pairs = pairs_json;
n.set_has_pairs();
}
}
}
// Set filename on needle (matches Go: if len(pu.FileName) < 256)
@@ -2617,9 +2625,9 @@ pub async fn post_handler(
if !is_replicate && write_result.is_ok() && !state.master_url.is_empty() {
let needs_replication = {
let store = state.store.read().unwrap();
store
.find_volume(vid)
.is_some_and(|(_, v)| v.super_block.replica_placement.get_copy_count() > 1)
store.find_volume(vid).map_or(false, |(_, v)| {
v.super_block.replica_placement.get_copy_count() > 1
})
};
if needs_replication {
let state_clone = state.clone();
@@ -2642,7 +2650,7 @@ pub async fn post_handler(
let replication_result = replication
.await
.map_err(|e| format!("replication task failed: {}", e))
.flatten();
.and_then(|result| result);
if let Err(e) = replication_result {
tracing::error!("replicated write failed: {}", e);
return json_error_with_query(
@@ -2736,12 +2744,11 @@ pub async fn delete_handler(
// JWT check for writes (deletes use write key)
let file_id = extract_file_id(&path);
let token = extract_jwt(&headers, request.uri());
if state
if let Err(_) = state
.guard
.read()
.unwrap()
.check_jwt_for_file(token.as_deref(), &file_id, true)
.is_err()
{
return json_error_with_query(StatusCode::UNAUTHORIZED, "wrong jwt", Some(&del_query));
}
@@ -2772,14 +2779,14 @@ pub async fn delete_handler(
let count = ec_needle.data_size as i64;
// Step 3: Journal the delete
let mut store = state.store.write().unwrap();
if let Some(ecv) = store.find_ec_volume_mut(vid)
&& let Err(e) = ecv.journal_delete(needle_id)
{
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Deletion Failed: {}", e),
Some(&del_query),
);
if let Some(ecv) = store.find_ec_volume_mut(vid) {
if let Err(e) = ecv.journal_delete(needle_id) {
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Deletion Failed: {}", e),
Some(&del_query),
);
}
}
let result = DeleteResult { size: count };
return json_response_with_params(
@@ -2925,12 +2932,12 @@ pub async fn delete_handler(
if !is_replicate && delete_result.is_ok() && !state.master_url.is_empty() {
let needs_replication = {
let store = state.store.read().unwrap();
store
.find_volume(vid)
.is_some_and(|(_, v)| v.super_block.replica_placement.get_copy_count() > 1)
store.find_volume(vid).map_or(false, |(_, v)| {
v.super_block.replica_placement.get_copy_count() > 1
})
};
if needs_replication
&& let Err(e) = do_replicated_request(
if needs_replication {
if let Err(e) = do_replicated_request(
&state,
vid.0,
Method::DELETE,
@@ -2940,13 +2947,14 @@ pub async fn delete_handler(
None,
)
.await
{
tracing::error!("replicated delete failed: {}", e);
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("replication failed: {}", e),
Some(&del_query),
);
{
tracing::error!("replicated delete failed: {}", e);
return json_error_with_query(
StatusCode::INTERNAL_SERVER_ERROR,
format!("replication failed: {}", e),
Some(&del_query),
);
}
}
}
@@ -3105,11 +3113,6 @@ pub async fn healthz_handler(State(state): State<Arc<VolumeServerState>>) -> Res
if !state.is_heartbeating.load(Ordering::Relaxed) {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
// A server with quarantined local replicas has faulty storage media;
// report degraded so a load balancer can drain it.
if state.store.read().unwrap().has_io_quarantine() {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
StatusCode::OK.into_response()
}
@@ -3212,6 +3215,7 @@ pub async fn ui_handler(State(state): State<Arc<VolumeServerState>>) -> Response
// ============================================================================
#[derive(Deserialize)]
#[allow(dead_code)]
struct ChunkManifest {
#[serde(default)]
name: String,
@@ -3231,7 +3235,6 @@ struct ChunkInfo {
}
/// Try to expand a chunk manifest needle. Returns None if manifest can't be parsed.
#[expect(clippy::too_many_arguments)]
async fn try_expand_chunk_manifest(
state: &Arc<VolumeServerState>,
n: &Needle,
@@ -3360,53 +3363,53 @@ async fn try_expand_chunk_manifest(
response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
// Last-Modified — Go sets this on the response writer before tryHandleChunkedFile
if let Some(lm) = last_modified_str
&& let Ok(hval) = lm.parse()
{
response_headers.insert(header::LAST_MODIFIED, hval);
if let Some(ref lm) = last_modified_str {
if let Ok(hval) = lm.parse() {
response_headers.insert(header::LAST_MODIFIED, hval);
}
}
// Pairs — Go sets needle pairs on the response writer before tryHandleChunkedFile
if n.has_pairs()
&& !n.pairs.is_empty()
&& let Ok(pair_map) =
if n.has_pairs() && !n.pairs.is_empty() {
if let Ok(pair_map) =
serde_json::from_slice::<std::collections::HashMap<String, String>>(&n.pairs)
{
for (k, v) in &pair_map {
if let (Ok(hname), Ok(hval)) = (
axum::http::HeaderName::from_bytes(k.as_bytes()),
axum::http::HeaderValue::from_str(v),
) {
response_headers.insert(hname, hval);
{
for (k, v) in &pair_map {
if let (Ok(hname), Ok(hval)) = (
axum::http::HeaderName::from_bytes(k.as_bytes()),
axum::http::HeaderValue::from_str(v),
) {
response_headers.insert(hname, hval);
}
}
}
}
// S3 response passthrough headers — Go sets these via AdjustPassthroughHeaders
if let Some(ref cc) = query.response_cache_control
&& let Ok(hval) = cc.parse()
{
response_headers.insert(header::CACHE_CONTROL, hval);
if let Some(ref cc) = query.response_cache_control {
if let Ok(hval) = cc.parse() {
response_headers.insert(header::CACHE_CONTROL, hval);
}
}
if let Some(ref ce) = query.response_content_encoding
&& let Ok(hval) = ce.parse()
{
response_headers.insert(header::CONTENT_ENCODING, hval);
if let Some(ref ce) = query.response_content_encoding {
if let Ok(hval) = ce.parse() {
response_headers.insert(header::CONTENT_ENCODING, hval);
}
}
if let Some(ref exp) = query.response_expires
&& let Ok(hval) = exp.parse()
{
response_headers.insert(header::EXPIRES, hval);
if let Some(ref exp) = query.response_expires {
if let Ok(hval) = exp.parse() {
response_headers.insert(header::EXPIRES, hval);
}
}
if let Some(ref cl) = query.response_content_language
&& let Ok(hval) = cl.parse()
{
response_headers.insert("Content-Language", hval);
if let Some(ref cl) = query.response_content_language {
if let Ok(hval) = cl.parse() {
response_headers.insert("Content-Language", hval);
}
}
if let Some(ref cd) = query.response_content_disposition
&& let Ok(hval) = cd.parse()
{
response_headers.insert(header::CONTENT_DISPOSITION, hval);
if let Some(ref cd) = query.response_content_disposition {
if let Ok(hval) = cd.parse() {
response_headers.insert(header::CONTENT_DISPOSITION, hval);
}
}
// Content-Disposition
@@ -3437,6 +3440,7 @@ async fn try_expand_chunk_manifest(
} else {
String::new()
};
let mut result = result;
if is_image_crop_ext(&cm_ext) {
result = maybe_crop_image(&result, &cm_ext, query);
}
@@ -3460,7 +3464,10 @@ async fn try_expand_chunk_manifest(
/// (reconstruct-on-read from surviving shards), or a peer resolved via the
/// master. Mirrors Go's ChunkedFileReader, which looks every chunk up through
/// the master instead of assuming a local regular needle.
async fn read_chunk_needle(state: &Arc<VolumeServerState>, fid: &str) -> Result<Vec<u8>, String> {
async fn read_chunk_needle(
state: &Arc<VolumeServerState>,
fid: &str,
) -> Result<Vec<u8>, String> {
let (vid, nid, cookie) =
parse_url_path(fid).ok_or_else(|| format!("invalid chunk fid: {}", fid))?;
@@ -3712,33 +3719,33 @@ fn extract_jwt(headers: &HeaderMap, uri: &axum::http::Uri) -> Option<String> {
// 1. Check ?jwt= query parameter
if let Some(query) = uri.query() {
for pair in query.split('&') {
if let Some(value) = pair.strip_prefix("jwt=")
&& !value.is_empty()
{
return Some(value.to_string());
if let Some(value) = pair.strip_prefix("jwt=") {
if !value.is_empty() {
return Some(value.to_string());
}
}
}
}
// 2. Check Authorization: Bearer <token> (case-insensitive prefix)
if let Some(auth) = headers.get(header::AUTHORIZATION)
&& let Ok(auth_str) = auth.to_str()
&& auth_str.len() > 7
&& auth_str[..7].eq_ignore_ascii_case("bearer ")
{
return Some(auth_str[7..].to_string());
if let Some(auth) = headers.get(header::AUTHORIZATION) {
if let Ok(auth_str) = auth.to_str() {
if auth_str.len() > 7 && auth_str[..7].eq_ignore_ascii_case("bearer ") {
return Some(auth_str[7..].to_string());
}
}
}
// 3. Check Cookie
if let Some(cookie_header) = headers.get(header::COOKIE)
&& let Ok(cookie_str) = cookie_header.to_str()
{
for cookie in cookie_str.split(';') {
let cookie = cookie.trim();
if let Some(value) = cookie.strip_prefix("AT=")
&& !value.is_empty()
{
return Some(value.to_string());
if let Some(cookie_header) = headers.get(header::COOKIE) {
if let Ok(cookie_str) = cookie_header.to_str() {
for cookie in cookie_str.split(';') {
let cookie = cookie.trim();
if let Some(value) = cookie.strip_prefix("AT=") {
if !value.is_empty() {
return Some(value.to_string());
}
}
}
}
}
@@ -4171,8 +4178,6 @@ mod tests {
url: "volume.internal:8080".to_string(),
public_url: "volume.public:8080".to_string(),
grpc_port: 18080,
read_only: false,
read_only_can_delete: false,
};
let response = redirect_request(&info, &target, "https");
@@ -4199,8 +4204,6 @@ mod tests {
url: "volume.internal:8080.18080".to_string(),
public_url: "volume.public:8080.18080".to_string(),
grpc_port: 18080,
read_only: false,
read_only_can_delete: false,
};
let response = redirect_request(&info, &target, "http");
@@ -4247,19 +4250,15 @@ mod tests {
let app = Router::new().route(
"/dir/lookup",
get(
|axum::extract::Query(params): axum::extract::Query<
std::collections::HashMap<String, String>,
>| async move {
assert_eq!(params.get("volumeId").map(String::as_str), Some("31"));
axum::Json(serde_json::json!({
"volumeOrFileId": "31",
"locations": [
{"url": "10.0.0.2:5301", "publicUrl": "10.0.0.2:5301", "grpcPort": 5311}
]
}))
},
),
get(|axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>| async move {
assert_eq!(params.get("volumeId").map(String::as_str), Some("31"));
axum::Json(serde_json::json!({
"volumeOrFileId": "31",
"locations": [
{"url": "10.0.0.2:5301", "publicUrl": "10.0.0.2:5301", "grpcPort": 5311}
]
}))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+49 -138
View File
@@ -19,12 +19,11 @@ use crate::pb::master_pb::seaweed_client::SeaweedClient;
use crate::pb::volume_server_pb;
use crate::remote_storage::s3_tier::{S3TierBackend, S3TierConfig};
use crate::storage::store::Store;
use crate::storage::types::{NeedleId, VolumeId};
use crate::storage::volume_report::VolumeReportKey;
use crate::storage::volume_report_hash::report_hash;
use crate::storage::types::NeedleId;
const DUPLICATE_UUID_RETRY_MESSAGE: &str = "duplicate UUIDs detected, retrying connection";
const VOLUME_IO_ERROR_TOLERANCE: i32 = 3;
const MAX_DUPLICATE_UUID_RETRIES: u32 = 3;
/// Configuration for the heartbeat client.
@@ -93,15 +92,8 @@ pub async fn run_heartbeat_with_state(
SleepDuplicate(Duration),
SleepPulse,
}
let action = match do_heartbeat(
&config,
&state,
&grpc_addr,
&target_addr,
pulse,
&mut shutdown_rx,
)
.await
let action = match do_heartbeat(&config, &state, &grpc_addr, &target_addr, pulse, &mut shutdown_rx)
.await
{
Ok(Some(leader)) => {
info!("Master leader changed to {}", leader);
@@ -189,10 +181,10 @@ pub async fn run_heartbeat_with_state(
pub fn to_grpc_address(master_addr: &str) -> String {
if let Some((host, port_str)) = master_addr.rsplit_once(':') {
// "host:port.grpcPort" — the part after the last '.' is the gRPC port.
if let Some((_, grpc_port)) = port_str.rsplit_once('.')
&& grpc_port.parse::<u16>().is_ok()
{
return format!("{}:{}", host, grpc_port);
if let Some((_, grpc_port)) = port_str.rsplit_once('.') {
if grpc_port.parse::<u16>().is_ok() {
return format!("{}:{}", host, grpc_port);
}
}
if let Ok(port) = port_str.parse::<u16>() {
let grpc_port = port + 10000;
@@ -316,10 +308,6 @@ fn collect_ec_shard_delta_messages(
for (disk_id, loc) in store.locations.iter().enumerate() {
for (_, ec_vol) in loc.ec_volumes() {
let (_, _, quarantined) = ec_vol.get_io_error_state();
if quarantined {
continue;
}
for shard in ec_vol.shards.iter().flatten() {
messages.insert(
(
@@ -430,7 +418,8 @@ async fn do_heartbeat(
// 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 normalised =
super::volume_server::to_http_address(current_master).into_owned();
let mut guard = state.current_master_url.write().await;
*guard = normalised;
}
@@ -549,12 +538,7 @@ async fn do_heartbeat(
let mut del_vols = Vec::new();
for (id, vol) in &current_volumes {
if let Some(previous) = last_volumes.get(id) {
if previous != vol {
del_vols.push(previous.to_short_message(*id));
new_vols.push(vol.to_short_message(*id));
}
} else {
if !last_volumes.contains_key(id) {
new_vols.push(vol.to_short_message(*id));
}
}
@@ -746,7 +730,7 @@ fn parse_bool_property(value: Option<&String>) -> bool {
/// information message the heartbeat carries. A server holding millions of
/// volumes cannot keep a whole message for each just to notice one leave; the
/// Go report state keeps the same fields for the same reason.
#[derive(Clone, PartialEq)]
#[derive(Clone)]
struct VolumeIdentity {
collection: String,
disk_type: String,
@@ -754,8 +738,6 @@ struct VolumeIdentity {
replica_placement: u32,
ttl: u32,
disk_id: u32,
read_only: bool,
read_only_can_delete: bool,
}
impl VolumeIdentity {
@@ -767,8 +749,6 @@ impl VolumeIdentity {
replica_placement: v.replica_placement,
ttl: v.ttl,
disk_id: v.disk_id,
read_only: v.read_only,
read_only_can_delete: v.read_only_can_delete,
}
}
@@ -781,8 +761,6 @@ impl VolumeIdentity {
ttl: self.ttl,
disk_type: self.disk_type.clone(),
disk_id: self.disk_id,
read_only: self.read_only,
read_only_can_delete: self.read_only_can_delete,
}
}
}
@@ -800,10 +778,7 @@ fn volume_identities(
fn collect_heartbeat_with_snapshot(
config: &HeartbeatConfig,
state: &Arc<VolumeServerState>,
) -> (
master_pb::Heartbeat,
Vec<master_pb::VolumeInformationMessage>,
) {
) -> (master_pb::Heartbeat, Vec<master_pb::VolumeInformationMessage>) {
let mut store = state.store.write().unwrap();
let (ec_shards, deleted_ec_shards) = store.delete_expired_ec_volumes();
build_heartbeat_with_ec_status(
@@ -881,10 +856,7 @@ fn build_heartbeat_with_ec_status(
deleted_ec_shards: Vec<master_pb::VolumeEcShardInformationMessage>,
has_no_ec_shards: bool,
commit_report: bool,
) -> (
master_pb::Heartbeat,
Vec<master_pb::VolumeInformationMessage>,
) {
) -> (master_pb::Heartbeat, Vec<master_pb::VolumeInformationMessage>) {
const MAX_TTL_VOLUME_REMOVAL_DELAY: u32 = 10;
#[derive(Default)]
@@ -900,7 +872,6 @@ fn build_heartbeat_with_ec_status(
// master can tell whether applying what it was sent leaves it current.
// Volumes skipped below -- quarantined, phantom, expired -- are in neither.
let mut volume_digest: u64 = 0;
let mut quarantined_volumes: u32 = 0;
let (send_full_list, report_generation, report_pass) = store.volume_report.begin();
let mut changed_volumes = Vec::new();
let mut max_file_key = NeedleId(0);
@@ -922,9 +893,10 @@ fn build_heartbeat_with_ec_status(
let mut effective_max_count = loc.max_volume_count.load(Ordering::Relaxed);
if loc.is_disk_space_low.load(Ordering::Relaxed) {
let used_slots = loc.volumes_len() as i32
+ loc
.ec_shard_count()
.div_ceil(crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT)
+ ((loc.ec_shard_count()
+ crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT
- 1)
/ crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT)
as i32;
effective_max_count = used_slots;
}
@@ -942,7 +914,6 @@ fn build_heartbeat_with_ec_status(
loc.disk_free_bytes.load(Ordering::Relaxed);
let mut delete_vids = Vec::new();
let mut quarantine_vids: Vec<VolumeId> = Vec::new();
for (_, vol) in loc.iter_volumes() {
let cur_max = vol.max_file_key();
if cur_max > max_file_key {
@@ -952,18 +923,9 @@ fn build_heartbeat_with_ec_status(
let volume_size = vol.dat_file_size().unwrap_or(0);
let mut should_delete_volume = false;
let (_, io_count, io_quarantined) = vol.get_io_error_state();
if io_quarantined || io_count >= VOLUME_IO_ERROR_TOLERANCE {
if !io_quarantined {
vol.mark_io_quarantined();
warn!(
"Volume {} quarantined after {} consecutive IO errors",
vol.id.0, io_count
);
}
quarantined_volumes += 1;
quarantine_vids.push(vol.id);
continue;
if vol.last_io_error().is_some() {
delete_vids.push(vol.id);
should_delete_volume = true;
} else if !vol.is_expired(volume_size, volume_size_limit) {
// Detect phantom volumes: the .dat was unlinked from disk but is still
// held open as a deleted FD, so the volume keeps serving and heartbeating
@@ -977,9 +939,7 @@ fn build_heartbeat_with_ec_status(
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_nanos() as i64;
if now_ns - vol.last_disk_check_ns.load(Ordering::Relaxed)
> DISK_CHECK_INTERVAL_NS
{
if now_ns - vol.last_disk_check_ns.load(Ordering::Relaxed) > DISK_CHECK_INTERVAL_NS {
if !Path::new(&vol.file_name(".dat")).exists() {
warn!("Volume {}: data file {} missing (held open as deleted FD) - not reporting to master", vol.id.0, vol.file_name(".dat"));
continue;
@@ -997,15 +957,11 @@ fn build_heartbeat_with_ec_status(
delete_count: vol.deleted_count() as u64,
deleted_byte_count: vol.deleted_size(),
read_only: vol.is_read_only(),
read_only_can_delete: vol.is_no_write_can_delete(),
replica_placement: vol.super_block.replica_placement.to_byte() as u32,
version: vol.super_block.version.0 as u32,
ttl: vol.super_block.ttl.to_u32(),
compact_revision: vol.super_block.compaction_revision as u32,
// The .dat mtime, as Go reports: the shell's quiet-period
// gates read this as "last touched", which a delete has to
// count towards even though the TTL clock ignores it.
modified_at_second: vol.dat_file_mod_time() as i64,
modified_at_second: vol.last_modified_ts() as i64,
disk_type: loc.disk_type.to_string(),
disk_id: disk_id as u32,
remote_storage_name,
@@ -1056,17 +1012,12 @@ fn build_heartbeat_with_ec_status(
}
}
}
}
for vid in delete_vids {
let _ = loc.delete_volume(vid, false, false);
}
for vid in quarantine_vids {
if let Some(vol) = loc.find_volume_mut(vid) {
vol.set_no_write_or_delete(true);
}
}
}
// Update disk size and read-only gauges
@@ -1123,23 +1074,6 @@ fn build_heartbeat_with_ec_status(
};
let (location_uuids, disk_tags) = collect_location_metadata(store, &disk_max_by_id);
let mut quarantined_ec_shards: u32 = 0;
for loc in &store.locations {
for (_, ec_vol) in loc.ec_volumes() {
let (_, _, quarantined) = ec_vol.get_io_error_state();
if quarantined {
quarantined_ec_shards += ec_vol.shard_count() as u32;
}
}
}
crate::metrics::IO_QUARANTINE_GAUGE
.with_label_values(&["volume"])
.set(quarantined_volumes as i64);
crate::metrics::IO_QUARANTINE_GAUGE
.with_label_values(&["ec_shard"])
.set(quarantined_ec_shards as i64);
let heartbeat = master_pb::Heartbeat {
id: store.id.clone(),
ip: config.ip.clone(),
@@ -1175,10 +1109,6 @@ fn collect_live_ec_shards(
for (disk_id, loc) in store.locations.iter().enumerate() {
for (_, ec_vol) in loc.ec_volumes() {
let (_, _, quarantined) = ec_vol.get_io_error_state();
if quarantined {
continue;
}
for message in ec_vol.to_volume_ec_shard_information_messages(disk_id as u32) {
if update_metrics {
let total_size: u64 = message
@@ -1213,10 +1143,7 @@ fn collect_live_ec_shards(
}
/// Collect EC shard information into a Heartbeat message.
fn collect_ec_heartbeat(
config: &HeartbeatConfig,
state: &Arc<VolumeServerState>,
) -> master_pb::Heartbeat {
fn collect_ec_heartbeat(config: &HeartbeatConfig, state: &Arc<VolumeServerState>) -> master_pb::Heartbeat {
let store = state.store.read().unwrap();
let ec_shards = collect_live_ec_shards(&store, true);
@@ -1239,10 +1166,10 @@ mod tests {
use crate::config::MinFreeSpace;
use crate::config::ReadMode;
use crate::metrics::{
DISK_SIZE_GAUGE, DISK_SIZE_LABEL_DELETED_BYTES, DISK_SIZE_LABEL_EC, DISK_SIZE_LABEL_NORMAL,
READ_ONLY_LABEL_IS_DISK_SPACE_LOW, READ_ONLY_LABEL_IS_READ_ONLY,
READ_ONLY_LABEL_NO_WRITE_CAN_DELETE, READ_ONLY_LABEL_NO_WRITE_OR_DELETE,
READ_ONLY_VOLUME_GAUGE,
DISK_SIZE_GAUGE, DISK_SIZE_LABEL_DELETED_BYTES, DISK_SIZE_LABEL_EC,
DISK_SIZE_LABEL_NORMAL, READ_ONLY_LABEL_IS_DISK_SPACE_LOW,
READ_ONLY_LABEL_IS_READ_ONLY, READ_ONLY_LABEL_NO_WRITE_CAN_DELETE,
READ_ONLY_LABEL_NO_WRITE_OR_DELETE, READ_ONLY_VOLUME_GAUGE,
};
use crate::remote_storage::s3_tier::S3TierRegistry;
use crate::security::{Guard, SigningKey};
@@ -1327,10 +1254,7 @@ mod tests {
fn test_to_grpc_address_explicit_grpc_port() {
// host:port.grpcPort form — gRPC port is what's after the dot.
assert_eq!(to_grpc_address("10.85.183.6:5300.6300"), "10.85.183.6:6300");
assert_eq!(
to_grpc_address("master.local:9333.19333"),
"master.local:19333"
);
assert_eq!(to_grpc_address("master.local:9333.19333"), "master.local:19333");
}
#[test]
@@ -1384,10 +1308,7 @@ mod tests {
heartbeat.disk_tags[0].tags,
vec!["fast".to_string(), "ssd".to_string()]
);
assert_eq!(
heartbeat.disk_tags[0].r#type,
DiskType::HardDrive.to_string()
);
assert_eq!(heartbeat.disk_tags[0].r#type, DiskType::HardDrive.to_string());
assert_eq!(heartbeat.disk_tags[0].max_volume_count, 3);
}
@@ -1426,10 +1347,7 @@ mod tests {
let heartbeat = build_heartbeat(&test_config(), &mut store);
assert_eq!(heartbeat.disk_tags[0].max_volume_count, 1);
assert_eq!(
heartbeat.max_volume_counts[&DiskType::HardDrive.to_string()],
1
);
assert_eq!(heartbeat.max_volume_counts[&DiskType::HardDrive.to_string()], 1);
}
#[test]
@@ -1647,9 +1565,10 @@ mod tests {
let heartbeat = build_heartbeat(&test_config(), &mut store);
assert_eq!(heartbeat.volumes.len(), 2);
let expected = heartbeat.volumes.iter().fold(0u64, |acc, m| {
acc ^ crate::storage::volume_report_hash::report_hash(m)
});
let expected = heartbeat
.volumes
.iter()
.fold(0u64, |acc, m| acc ^ crate::storage::volume_report_hash::report_hash(m));
assert_eq!(heartbeat.volume_digest, Some(expected));
assert_ne!(heartbeat.volume_digest, Some(0));
}
@@ -1744,9 +1663,10 @@ mod tests {
.iter()
.flat_map(|family| family.get_metric().to_vec())
.filter(|metric| {
metric.get_label().iter().any(|label| {
label.get_name() == "collection" && label.get_value() == collection
})
metric
.get_label()
.iter()
.any(|label| label.get_name() == "collection" && label.get_value() == collection)
})
.count()
}
@@ -1880,9 +1800,7 @@ mod tests {
assert_eq!(heartbeat.ec_shards[0].disk_id, 0);
assert_eq!(
heartbeat.ec_shards[0].disk_type,
state.store.read().unwrap().locations[0]
.disk_type
.to_string()
state.store.read().unwrap().locations[0].disk_type.to_string()
);
assert_eq!(heartbeat.ec_shards[0].ec_index_bits, 1);
assert_eq!(heartbeat.ec_shards[0].shard_sizes, vec![8]);
@@ -2018,13 +1936,8 @@ mod tests {
let heartbeat = build_heartbeat(&test_config(), &mut store);
// A sustained IO error quarantines the volume: it stays mounted
// (so healthz can observe the quarantine state) but is not
// advertised to the master.
assert!(heartbeat.volumes.is_empty());
assert!(store.has_volume(VolumeId(51)));
let (_, volume) = store.find_volume_mut(VolumeId(51)).unwrap();
assert!(volume.is_no_write_or_delete());
assert!(!store.has_volume(VolumeId(51)));
}
#[test]
@@ -2055,15 +1968,12 @@ mod tests {
)
.unwrap();
let (_, volume) = store.find_volume_mut(VolumeId(71)).unwrap();
volume
.volume_info
.files
.push(crate::storage::volume::PbRemoteFile {
backend_type: "s3".to_string(),
backend_id: "archive".to_string(),
key: "volumes/71.dat".to_string(),
..Default::default()
});
volume.volume_info.files.push(crate::storage::volume::PbRemoteFile {
backend_type: "s3".to_string(),
backend_id: "archive".to_string(),
key: "volumes/71.dat".to_string(),
..Default::default()
});
volume.refresh_remote_write_mode().unwrap();
let heartbeat = build_heartbeat(&test_config(), &mut store);
@@ -2227,7 +2137,8 @@ mod tests {
.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) = diff_ec_shard_delta_messages(&previous, &current);
let (new_ec_shards, deleted_ec_shards) =
diff_ec_shard_delta_messages(&previous, &current);
assert_eq!(new_ec_shards.len(), 1);
assert!(deleted_ec_shards.is_empty());
+180 -323
View File
@@ -134,13 +134,8 @@ pub async fn read_ec_shard_needle_distributed(
Ok(fresh) => {
// A complete reply merges into the cache; an incomplete one
// (< data_shards) is left unwritten — keep the prior cache.
match write_back_shard_locations(
state,
vid,
fresh,
snapshot.data_shards as usize,
snapshot.encode_ts_ns,
) {
match write_back_shard_locations(state, vid, fresh, snapshot.data_shards as usize)
{
Some(merged) => shard_locations = merged,
// An incomplete reply leaves the cache unwritten and its refresh
// time unadvanced, so the mark this refresh consumed goes back.
@@ -173,37 +168,36 @@ pub async fn read_ec_shard_needle_distributed(
let parity_shards = snapshot.parity_shards as usize;
let encode_ts_ns = snapshot.encode_ts_ns;
let intervals = std::mem::take(&mut snapshot.intervals);
let fetched: Vec<io::Result<(Vec<u8>, bool)>> =
stream::iter(intervals.into_iter().map(|res| {
let shard_locations = &shard_locations;
async move {
match res {
IntervalResult::Local(buf) => Ok((buf, false)),
IntervalResult::NeedRemote {
let fetched: Vec<io::Result<(Vec<u8>, bool)>> = stream::iter(intervals.into_iter().map(|res| {
let shard_locations = &shard_locations;
async move {
match res {
IntervalResult::Local(buf) => Ok((buf, false)),
IntervalResult::NeedRemote {
shard_id,
shard_offset,
size,
} => {
fetch_one_interval(
state,
vid,
needle_id,
shard_id,
shard_offset,
size,
} => {
fetch_one_interval(
state,
vid,
needle_id,
shard_id,
shard_offset,
size,
shard_locations,
data_shards,
parity_shards,
encode_ts_ns,
)
.await
}
shard_locations,
data_shards,
parity_shards,
encode_ts_ns,
)
.await
}
}
}))
.buffered(INTERVAL_READ_CONCURRENCY)
.collect()
.await;
}
}))
.buffered(INTERVAL_READ_CONCURRENCY)
.collect()
.await;
let mut assembled: Vec<Vec<u8>> = Vec::with_capacity(fetched.len());
for res in fetched {
@@ -236,10 +230,8 @@ pub async fn read_ec_shard_needle_distributed(
));
}
let mut n = Needle {
id: needle_id,
..Needle::default()
};
let mut n = Needle::default();
n.id = needle_id;
n.read_bytes(
&bytes,
snapshot.offset.to_actual_offset(),
@@ -264,32 +256,22 @@ pub async fn read_ec_shard_needle_distributed(
pub async fn scrub_ec_volume_distributed(
state: &Arc<VolumeServerState>,
vid: VolumeId,
expected_encode_ts_ns: i64,
force_deleted_needles_check: bool,
recover_unreadable: bool,
) -> (
i64,
Vec<crate::pb::volume_server_pb::EcShardInfo>,
Vec<String>,
) {
// Phase A — under the Store read lock, snapshot the index scrub and grab the
) -> (i64, Vec<crate::pb::volume_server_pb::EcShardInfo>, Vec<String>) {
// Phase A — under the Store read lock, run the index scrub and grab the
// paths/scalars + shard-location staleness; release the lock before any await.
let (
ecx_path,
collection,
index_plan,
ecx_walk,
encode_ts_ns,
seed_errs,
cached_locations,
cache_refreshed_at,
data_shards,
total_shards,
) = {
let store = state.store.read().unwrap();
// Resolve the runtime matching the anchor's encode generation, not the
// first-match find_ec_volume — otherwise the needle walk can scan an
// older run while the parity half scans the newest.
let ecv = match find_ec_volume_for_scrub(&store, vid, expected_encode_ts_ns) {
let ecv = match store.find_ec_volume(vid) {
Some(v) => v,
None => {
return (
@@ -300,26 +282,7 @@ pub async fn scrub_ec_volume_distributed(
}
};
// full scan means verifying the index as well
let index_plan = ecv.scrub_index_plan();
// A SECOND .ecx descriptor, opened under the guard for the needle walk
// below. The index plan's handle is consumed by its own structural walk,
// and both seek, so a `dup` would race the cursor. Reopening by PATH
// after the guard is dropped would let a teardown that legitimately
// unlinks or replaces the .ecx (the heartbeat's
// delete_expired_ec_volumes, volume_ec_shards_delete) surface an
// intentional removal as a scrub error or mix index generations within
// one scrub — the same race the vanished-volume policy exists to hide.
// The descriptor outlives the name, the same way the checksum plan's
// shard handles do.
let ecx_walk = fs::File::open(ecv.ecx_file_name());
// Encode-run identity of the volume this scrub started against. The
// per-needle `scrub_snapshot_under_lock` re-resolves the volume by id
// under a fresh guard, so a teardown-and-remount of the same vid between
// two rows would otherwise apply the captured .ecx's offsets to a
// replacement volume's shards. Bind the walk to this generation: if the
// mounted volume's encode_ts_ns no longer matches, abort like a
// mid-scan unmount rather than mixing generations.
let encode_ts_ns = ecv.encode_ts_ns;
let (_, errs) = ecv.scrub_index();
// Bind to locals so the inner RwLock/Mutex guards drop before the block ends.
let cached_locations = ecv.shard_locations.read().unwrap().clone();
let cache_refreshed_at = *ecv.shard_locations_refresh_time.lock().unwrap();
@@ -328,47 +291,13 @@ pub async fn scrub_ec_volume_distributed(
(
ecv.ecx_file_name(),
ecv.collection.clone(),
index_plan,
ecx_walk,
encode_ts_ns,
errs,
cached_locations,
cache_refreshed_at,
data_shards,
total_shards,
)
};
// Lock released: walk the index now, before anything else appends to errs,
// so the seeded errors keep their position in the reported details.
//
// `index_plan.run()` reads the whole .ecx synchronously, so run it in the
// blocking pool rather than on this async worker — a large index scan would
// otherwise block unrelated RPC work handled on the same executor. Same
// treatment as the CHECKSUM/LOCAL plans in the gRPC handler.
let (_, seed_errs) = match tokio::task::spawn_blocking(move || index_plan.run()).await {
Ok(v) => v,
Err(e) => {
// A panic is evidence about the volume and counts as broken; a
// cancellation is not — spawn_blocking only reports it when the
// runtime is going down, the volume was never scanned, and the
// caller (FULL/READS) would put a false corruption into
// broken_volume_ids if it reached the errs path. Match the
// record_scrub_join_failure distinction used by the handler arms.
if e.is_panic() {
return (
0,
Vec::new(),
vec![format!(
"EC volume {} index scrub task panicked: {}",
vid.0, e
)],
);
}
// Cancellation: the runtime is shutting down, so this response is
// unlikely to reach anyone. Return clean rather than inventing a
// corruption for a volume that was never scanned.
return (0, Vec::new(), Vec::new());
}
};
let mut errs = seed_errs;
// Refresh the shard-location cache once up front (mirrors Go's
@@ -385,9 +314,7 @@ pub async fn scrub_ec_volume_distributed(
) {
match cached_lookup_ec_shard_locations(state, vid).await {
Ok(fresh) => {
if write_back_shard_locations(state, vid, fresh, data_shards, expected_encode_ts_ns)
.is_none()
{
if write_back_shard_locations(state, vid, fresh, data_shards).is_none() {
mark_shard_locations_stale(state, vid);
return (
0,
@@ -414,88 +341,53 @@ pub async fn scrub_ec_volume_distributed(
// walk, so per-needle snapshots no longer clone it.
let locations: HashMap<ShardId, Vec<String>> = {
let store = state.store.read().unwrap();
let ecv = match find_ec_volume_for_scrub(&store, vid, expected_encode_ts_ns) {
let ecv = match store.find_ec_volume(vid) {
Some(v) => v,
None => {
return (
0,
Vec::new(),
vec![format!("EC volume id {} not found", vid.0)],
);
)
}
};
ecv.shard_locations.read().unwrap().clone()
let map = ecv.shard_locations.read().unwrap().clone();
map
};
// Walk the .ecx (private fd captured under the lock, no lock held) for the
// row count + live (id, offset, size). Reading through the captured
// descriptor — not a pathname reopen — keeps a concurrent teardown from
// surfacing an intentional removal as a scrub error or mixing index
// generations, the same invariant the vanished-volume policy enforces.
//
// `walk_index_file` reads the full .ecx synchronously, so run it in the
// blocking pool rather than on this async worker — same reason as
// `index_plan.run()` above.
let (count, needles, walk_errs) = match tokio::task::spawn_blocking(
move || -> (i64, Vec<(NeedleId, Offset, Size)>, Vec<String>) {
let mut count: i64 = 0;
let mut needles: Vec<(NeedleId, Offset, Size)> = Vec::new();
let mut walk_errs: Vec<String> = Vec::new();
match ecx_walk {
Ok(mut f) => {
if let Err(e) =
crate::storage::idx::walk_index_file(&mut f, 0, |id, offset, size| {
count += 1;
// Skip ALL deleted entries: -1 tombstones (runtime delete folded
// into .ecx) and -originalSize entries (a needle deleted on the
// regular volume before EC encode). get_actual_size uses the raw
// signed size, so a negative would yield empty intervals
// (false-positive) or an under-16-byte buffer (parse panic).
if !size.is_deleted() {
needles.push((id, offset, size));
}
Ok(())
})
{
walk_errs.push(format!("walk ECX file {}: {}", ecx_path, e));
}
// Walk the .ecx (private fd, no lock) for the row count + live (id, offset, size).
let mut count: i64 = 0;
let mut needles: Vec<(NeedleId, Offset, Size)> = Vec::new();
match fs::File::open(&ecx_path) {
Ok(mut f) => {
if let Err(e) = crate::storage::idx::walk_index_file(&mut f, 0, |id, offset, size| {
count += 1;
// Skip ALL deleted entries: -1 tombstones (runtime delete folded
// into .ecx) and -originalSize entries (a needle deleted on the
// regular volume before EC encode). get_actual_size uses the raw
// signed size, so a negative would yield empty intervals
// (false-positive) or an under-16-byte buffer (parse panic).
if !size.is_deleted() {
needles.push((id, offset, size));
}
Err(e) => walk_errs.push(format!("open ECX file {}: {}", ecx_path, e)),
Ok(())
}) {
errs.push(format!("walk ECX file {}: {}", ecx_path, e));
}
(count, needles, walk_errs)
},
)
.await
{
Ok(v) => v,
Err(e) => {
// A panic is evidence about the volume and counts as broken; a
// cancellation is not — see the index_plan join above for the
// same reasoning.
if e.is_panic() {
return (
0,
Vec::new(),
vec![format!("EC volume {} ecx walk task panicked: {}", vid.0, e)],
);
}
return (0, Vec::new(), Vec::new());
}
};
errs.extend(walk_errs);
Err(e) => errs.push(format!("open ECX file {}: {}", ecx_path, e)),
}
// reads for EC chunks can hit the same shard repeatedly, so dedupe broken shards
let mut broken_shards: HashMap<ShardId, crate::pb::volume_server_pb::EcShardInfo> =
HashMap::new();
let mut broken_shards: HashMap<ShardId, crate::pb::volume_server_pb::EcShardInfo> = HashMap::new();
for (id, offset, size) in needles {
// Per-needle snapshot under the lock from the RAW .ecx (offset, size) so
// logically-deleted needles are still verified; lock dropped before await.
let snapshot = match scrub_snapshot_under_lock(state, vid, offset, size, encode_ts_ns) {
let snapshot = match scrub_snapshot_under_lock(state, vid, offset, size) {
Ok(s) => s,
// Volume unmounted (or remounted as a different encode run) mid-scan:
// abort with an error rather than skipping every remaining needle,
// which would report a false-CLEAN result.
// Volume unmounted mid-scan: abort with an error rather than skipping
// every remaining needle, which would report a false-CLEAN result.
Err(e) if e.kind() == io::ErrorKind::NotFound => {
errs.push(format!("EC volume {} unmounted during scrub: {}", vid.0, e));
break;
@@ -621,11 +513,7 @@ pub async fn scrub_ec_volume_distributed(
// Mirror Go CmpEcShardInfo: sort by (volume_id, shard_id).
let mut broken: Vec<crate::pb::volume_server_pb::EcShardInfo> =
broken_shards.into_values().collect();
broken.sort_by(|a, b| {
a.volume_id
.cmp(&b.volume_id)
.then(a.shard_id.cmp(&b.shard_id))
});
broken.sort_by(|a, b| a.volume_id.cmp(&b.volume_id).then(a.shard_id.cmp(&b.shard_id)));
(count, broken, errs)
}
@@ -663,10 +551,9 @@ fn scrub_snapshot_under_lock(
vid: VolumeId,
offset: Offset,
size: Size,
expected_encode_ts: i64,
) -> io::Result<ScrubSnapshot> {
let store = state.store.read().unwrap();
let ecv = match find_ec_volume_for_scrub(&store, vid, expected_encode_ts) {
let ecv = match store.find_ec_volume(vid) {
Some(v) => v,
// Volume unmounted mid-scan: a distinct NotFound so the caller aborts
// with an error rather than silently skipping (which would false-CLEAN).
@@ -677,28 +564,6 @@ fn scrub_snapshot_under_lock(
))
}
};
// The volume was torn down and remounted as a DIFFERENT encode run between
// two rows. The .ecx offsets captured at the start of the walk belong to
// the old generation; applying them to the replacement's shards would
// falsely report corruption. Abort like a mid-scan unmount instead of
// mixing generations within one scrub.
//
// `encode_ts_ns == 0` means the .vif carried no encode-run identity (a
// legacy or pre-feature volume). Two such volumes are NOT the same mount
// by this check alone — 0 == 0 would accept a teardown-and-remount and
// apply the old .ecx's offsets to the replacement's shards. Only treat a
// match as verified when the identity is non-zero; when it is zero, fall
// back to the pre-check behavior (no generation binding) rather than
// aborting a scrub that was already running without the guard.
if expected_encode_ts != 0 && ecv.encode_ts_ns != expected_encode_ts {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"EC volume {} remounted as a different encode run during scrub (was {}, now {})",
vid.0, expected_encode_ts, ecv.encode_ts_ns
),
));
}
let intervals = ecv.locate_ec_shard_needle_interval(offset.to_actual_offset(), size);
if intervals.is_empty() {
return Err(io::Error::new(
@@ -813,9 +678,9 @@ fn needs_refresh(
let ttl = if stale || shard_count < data_shards {
Duration::from_secs(11)
} else if shard_count == total_shards {
Duration::from_mins(37)
Duration::from_secs(37 * 60)
} else {
Duration::from_mins(7)
Duration::from_secs(7 * 60)
};
age >= ttl
}
@@ -869,19 +734,22 @@ async fn cached_lookup_ec_shard_locations(
}
};
if master.is_empty() {
return Err(io::Error::other("no master configured for ec shard lookup"));
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 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::other(e.to_string()))?;
.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::other(format!("master connect: {}", e)))?;
.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)
@@ -890,7 +758,7 @@ async fn cached_lookup_ec_shard_locations(
let resp = client
.lookup_ec_volume(Request::new(LookupEcVolumeRequest { volume_id: vid.0 }))
.await
.map_err(|e| io::Error::other(format!("lookup_ec_volume: {}", e)))?;
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("lookup_ec_volume: {}", e)))?;
let resp = resp.into_inner();
let mut out = HashMap::new();
@@ -921,34 +789,15 @@ fn write_back_shard_locations(
vid: VolumeId,
locations: HashMap<ShardId, Vec<String>>,
data_shards: usize,
expected_encode_ts_ns: i64,
) -> Option<HashMap<ShardId, Vec<String>>> {
if locations.len() < data_shards {
return None;
}
let store = state.store.read().unwrap();
let ecv = find_ec_volume_for_scrub(&store, vid, expected_encode_ts_ns)?;
let ecv = store.find_ec_volume(vid)?;
Some(ecv.merge_shard_locations(locations))
}
/// Resolve the runtime matching the scrub's anchor encode generation, not the
/// first-match `find_ec_volume`. When `expected_encode_ts_ns` is 0 (legacy or
/// pre-feature), falls back to first-match so existing behavior is preserved.
fn find_ec_volume_for_scrub(
store: &crate::storage::store::Store,
vid: VolumeId,
expected_encode_ts_ns: i64,
) -> Option<&crate::storage::erasure_coding::EcVolume> {
if expected_encode_ts_ns != 0 {
store
.find_all_ec_volumes(vid)
.into_iter()
.find(|v| v.encode_ts_ns == expected_encode_ts_ns)
} else {
store.find_ec_volume(vid)
}
}
/// 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.
@@ -957,17 +806,16 @@ fn format_location_as_server_address(loc: &master_pb::Location) -> String {
.url
.trim_start_matches("http://")
.trim_start_matches("https://");
if loc.grpc_port > 0
&& let Some((host, http_port)) = raw.rsplit_once(':')
{
return format!("{}:{}.{}", host, http_port, loc.grpc_port);
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.
#[expect(clippy::too_many_arguments)]
async fn fetch_one_interval(
state: &Arc<VolumeServerState>,
vid: VolumeId,
@@ -981,35 +829,35 @@ async fn fetch_one_interval(
expected_encode_ts_ns: i64,
) -> io::Result<(Vec<u8>, bool)> {
// Direct peer read against the cached locations for this shard.
if let Some(sources) = shard_locations.get(&shard_id)
&& !sources.is_empty()
{
match read_remote_ec_shard_interval(
state,
sources,
vid,
needle_id,
shard_id,
shard_offset,
size,
expected_encode_ts_ns,
)
.await
{
// A deleted needle short-circuits: don't reconstruct (every shard
// would report deleted), let the caller return "deleted".
Ok((buf, is_deleted)) => return Ok((buf, is_deleted)),
Err(e) => {
tracing::debug!(
"direct read ec shard {}.{} from {:?} failed: {} — will reconstruct",
vid.0,
shard_id,
sources,
e
);
// Reconstruction below skips this very shard, so nothing else
// invalidates the location that just failed.
mark_shard_locations_stale(state, vid);
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,
expected_encode_ts_ns,
)
.await
{
// A deleted needle short-circuits: don't reconstruct (every shard
// would report deleted), let the caller return "deleted".
Ok((buf, is_deleted)) => return Ok((buf, is_deleted)),
Err(e) => {
tracing::debug!(
"direct read ec shard {}.{} from {:?} failed: {} — will reconstruct",
vid.0,
shard_id,
sources,
e
);
// Reconstruction below skips this very shard, so nothing else
// invalidates the location that just failed.
mark_shard_locations_stale(state, vid);
}
}
}
}
@@ -1031,7 +879,6 @@ async fn fetch_one_interval(
.await
}
#[expect(clippy::too_many_arguments)]
async fn read_remote_ec_shard_interval(
state: &Arc<VolumeServerState>,
sources: &[String],
@@ -1068,7 +915,6 @@ async fn read_remote_ec_shard_interval(
}))
}
#[expect(clippy::too_many_arguments)]
async fn do_read_remote_ec_shard_interval(
state: &Arc<VolumeServerState>,
source: &str,
@@ -1082,13 +928,18 @@ async fn do_read_remote_ec_shard_interval(
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::other(e.to_string()))?;
.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::other(format!("connect to {}: {}", source, e)))?;
.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
@@ -1114,10 +965,10 @@ async fn do_read_remote_ec_shard_interval(
.volume_ec_shard_read(Request::new(req))
.await
.map_err(|e| {
io::Error::other(format!(
"volume_ec_shard_read {}.{} from {}: {}",
vid.0, shard_id, source, 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();
@@ -1126,16 +977,19 @@ async fn do_read_remote_ec_shard_interval(
while let Some(msg) = stream
.message()
.await
.map_err(|e| io::Error::other(format!("recv: {}", e)))?
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("recv: {}", e)))?
{
// Validate the served shard's identity client-side, so the guard holds even
// against a pre-upgrade server that ignored the request field (returns 0).
// A mismatch fails the read; the caller recovers from parity.
if expected_encode_ts_ns != 0 && msg.encode_ts_ns != expected_encode_ts_ns {
return Err(io::Error::other(format!(
"ec shard {}.{} from {} belongs to a different encode run (want {} got {})",
vid.0, shard_id, source, expected_encode_ts_ns, msg.encode_ts_ns
)));
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"ec shard {}.{} from {} belongs to a different encode run (want {} got {})",
vid.0, shard_id, source, expected_encode_ts_ns, msg.encode_ts_ns
),
));
}
if msg.is_deleted {
is_deleted = true;
@@ -1171,7 +1025,6 @@ async fn do_read_remote_ec_shard_interval(
Ok((out, false))
}
#[expect(clippy::too_many_arguments)]
async fn recover_one_remote_ec_shard_interval(
state: &Arc<VolumeServerState>,
vid: VolumeId,
@@ -1185,8 +1038,12 @@ async fn recover_one_remote_ec_shard_interval(
expected_encode_ts_ns: i64,
) -> io::Result<(Vec<u8>, bool)> {
let total_shards = data_shards + parity_shards;
let rs = ReedSolomon::new(data_shards, parity_shards)
.map_err(|e| io::Error::other(format!("reed-solomon init: {:?}", e)))?;
let rs = ReedSolomon::new(data_shards, parity_shards).map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("reed-solomon init: {:?}", e),
)
})?;
// Charge the buffers this recovery is about to hold against the budget, so a
// burst of them queues here rather than on the heap. An interval whose
@@ -1196,10 +1053,13 @@ async fn recover_one_remote_ec_shard_interval(
.acquire_many((size * data_shards).min(EC_RECOVER_BUDGET) as u32)
.await
.map_err(|e| {
io::Error::other(format!(
"ec recover budget for shard {}.{}: {}",
vid.0, shard_id_to_recover, e
))
io::Error::new(
io::ErrorKind::Other,
format!(
"ec recover budget for shard {}.{}: {}",
vid.0, shard_id_to_recover, e
),
)
})?;
let mut bufs: Vec<Option<Vec<u8>>> = vec![None; total_shards];
@@ -1212,7 +1072,7 @@ async fn recover_one_remote_ec_shard_interval(
let mut available = 0usize;
{
let store = state.store.read().unwrap();
for (sid, slot) in bufs.iter_mut().enumerate() {
for sid in 0..total_shards {
if available >= data_shards {
break;
}
@@ -1225,21 +1085,13 @@ async fn recover_one_remote_ec_shard_interval(
// lenient only when the caller carries no identity (pre-upgrade).
// Mirrors Go's `readLocalEcShardInterval`.
let owner = match store.find_ec_volume_with_shard(vid, sid as u32) {
Some(ecv)
if expected_encode_ts_ns == 0 || ecv.encode_ts_ns == expected_encode_ts_ns =>
{
ecv
}
Some(ecv) if expected_encode_ts_ns == 0 || ecv.encode_ts_ns == expected_encode_ts_ns => ecv,
_ => continue,
};
if let Some(Some(shard)) = owner.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)
{
*slot = Some(buf);
if shard.read_at(&mut buf, shard_offset as u64).map(|n| n == size).unwrap_or(false) {
bufs[sid] = Some(buf);
available += 1;
}
}
@@ -1323,25 +1175,34 @@ async fn recover_one_remote_ec_shard_interval(
if any_deleted {
return Ok((Vec::new(), true));
}
return Err(io::Error::other(format!(
"cannot recover ec shard {}.{}: only {} shards available, need at least {}",
vid.0, shard_id_to_recover, 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::other(format!(
"reed-solomon reconstruct ec shard {}.{}: {:?}",
vid.0, shard_id_to_recover, 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, any_deleted)),
None => Err(io::Error::other(format!(
"reconstructed buffer for shard {}.{} missing after RS reconstruct",
vid.0, shard_id_to_recover
))),
None => Err(io::Error::new(
io::ErrorKind::Other,
format!(
"reconstructed buffer for shard {}.{} missing after RS reconstruct",
vid.0, shard_id_to_recover
),
)),
}
}
@@ -1483,12 +1344,12 @@ async fn fetch_ec_index_from_one_peer(
let grpc_addr =
parse_grpc_address(peer).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let channel = build_grpc_endpoint(&grpc_addr, state.outgoing_grpc_tls.as_ref())
.map_err(|e| io::Error::other(e.to_string()))?
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.connect()
.await
.map_err(|e| io::Error::other(format!("connect {}: {}", peer, e)))?;
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("connect {}: {}", peer, e)))?;
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);
@@ -1509,19 +1370,18 @@ async fn fetch_ec_index_from_one_peer(
let stream = client
.copy_file(copy_req(".ecx", false))
.await
.map_err(|e| io::Error::other(format!("copy .ecx: {}", e)))?
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("copy .ecx: {}", e)))?
.into_inner();
drain_copy_stream(stream, ecx_path, false).await?;
let meta =
fs::metadata(ecx_path).map_err(|e| io::Error::other(format!("stat copied .ecx: {}", e)))?;
let meta = fs::metadata(ecx_path)
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("stat copied .ecx: {}", e)))?;
if meta.is_dir() || meta.len() == 0 {
let _ = fs::remove_file(ecx_path);
return Err(io::Error::other(format!(
"peer {} served an unusable .ecx (size {})",
peer,
meta.len()
)));
return Err(io::Error::new(
io::ErrorKind::Other,
format!("peer {} served an unusable .ecx (size {})", peer, meta.len()),
));
}
// .ecj is the source peer's deletion journal (appended); .vif carries EC
@@ -1558,21 +1418,18 @@ async fn drain_copy_stream(
) -> io::Result<()> {
use std::io::Write;
let mut file = if append {
fs::OpenOptions::new()
.create(true)
.append(true)
.open(dest_path)
fs::OpenOptions::new().create(true).append(true).open(dest_path)
} else {
fs::File::create(dest_path)
}
.map_err(|e| io::Error::other(format!("create {}: {}", dest_path, e)))?;
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("create {}: {}", dest_path, e)))?;
while let Some(chunk) = stream
.message()
.await
.map_err(|e| io::Error::other(format!("recv {}: {}", dest_path, e)))?
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("recv {}: {}", dest_path, e)))?
{
file.write_all(&chunk.file_content)
.map_err(|e| io::Error::other(format!("write {}: {}", dest_path, e)))?;
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("write {}: {}", dest_path, e)))?;
}
Ok(())
}
+7 -6
View File
@@ -423,12 +423,13 @@ fn collect_ui_data(
shard_id: shard.shard_id,
size: shard_size,
});
if created_at == "-"
&& let Ok(metadata) = std::fs::metadata(shard.file_name())
&& let Ok(modified) = metadata.modified()
{
let ts: chrono::DateTime<chrono::Local> = modified.into();
created_at = ts.format("%Y-%m-%d %H:%M").to_string();
if created_at == "-" {
if let Ok(metadata) = std::fs::metadata(shard.file_name()) {
if let Ok(modified) = metadata.modified() {
let ts: chrono::DateTime<chrono::Local> = modified.into();
created_at = ts.format("%Y-%m-%d %H:%M").to_string();
}
}
}
}
let preferred_size = ec_volume.dat_file_size.max(0) as u64;
+10 -8
View File
@@ -312,15 +312,16 @@ async fn admin_store_handler(state: State<Arc<VolumeServerState>>, request: Requ
)
}
};
if method == Method::GET
&& let Some(response_bytes) = response
if method == Method::GET {
if let Some(response_bytes) = response
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0)
{
super::server_stats::record_bytes_out(response_bytes);
{
super::server_stats::record_bytes_out(response_bytes);
}
}
super::server_stats::record_request_close();
crate::metrics::INFLIGHT_REQUESTS_GAUGE
@@ -357,15 +358,16 @@ async fn public_store_handler(state: State<Arc<VolumeServerState>>, request: Req
}
_ => StatusCode::OK.into_response(),
};
if method == Method::GET
&& let Some(response_bytes) = response
if method == Method::GET {
if let Some(response_bytes) = response
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0)
{
super::server_stats::record_bytes_out(response_bytes);
{
super::server_stats::record_bytes_out(response_bytes);
}
}
super::server_stats::record_request_close();
crate::metrics::INFLIGHT_REQUESTS_GAUGE
+30 -35
View File
@@ -131,10 +131,10 @@ impl DiskLocation {
for entry in entries {
let entry = entry?;
let name = entry.file_name().into_string().unwrap_or_default();
if let Some((collection, vid)) = parse_volume_filename(&name)
&& seen.insert((collection.clone(), vid))
{
dat_files.push((collection, vid));
if let Some((collection, vid)) = parse_volume_filename(&name) {
if seen.insert((collection.clone(), vid)) {
dat_files.push((collection, vid));
}
}
}
@@ -327,10 +327,10 @@ impl DiskLocation {
.strip_suffix(".cpc")
.or_else(|| name.strip_suffix(".cpd"))
.or_else(|| name.strip_suffix(".cpx"));
if let Some(stem) = stem
&& let Some(key) = parse_collection_volume_id(stem)
{
pending.insert(key);
if let Some(stem) = stem {
if let Some(key) = parse_collection_volume_id(stem) {
pending.insert(key);
}
}
}
}
@@ -426,16 +426,11 @@ impl DiskLocation {
if shard_count == 0 {
return false;
}
if let (Some(actual), Some(expected)) = (actual_shard_size, expected_shard_size)
&& actual < expected
{
warn!(
volume_id = vid.0,
actual,
expected,
"shards smaller than the .dat's full encode; reclaiming the complete .dat"
);
return false;
if let (Some(actual), Some(expected)) = (actual_shard_size, expected_shard_size) {
if actual < expected {
warn!(volume_id = vid.0, actual, expected, "shards smaller than the .dat's full encode; reclaiming the complete .dat");
return false;
}
}
true
}
@@ -515,10 +510,10 @@ impl DiskLocation {
pub(crate) fn ec_generation_ts_ns(&self, collection: &str, vid: VolumeId) -> Option<i64> {
for dir in [&self.directory, &self.idx_directory] {
let vif = format!("{}.vif", volume_file_name(dir, collection, vid));
if let Ok(s) = fs::read_to_string(&vif)
&& let Ok(vi) = serde_json::from_str::<VifVolumeInfo>(&s)
{
return Some(vi.ec_shard_config.map(|c| c.encode_ts_ns).unwrap_or(0));
if let Ok(s) = fs::read_to_string(&vif) {
if let Ok(vi) = serde_json::from_str::<VifVolumeInfo>(&s) {
return Some(vi.ec_shard_config.map(|c| c.encode_ts_ns).unwrap_or(0));
}
}
if self.directory == self.idx_directory {
break;
@@ -547,7 +542,6 @@ impl DiskLocation {
}
/// Create a new volume in this location.
#[expect(clippy::too_many_arguments)]
pub fn create_volume(
&mut self,
vid: VolumeId,
@@ -783,18 +777,18 @@ impl DiskLocation {
pub fn has_ecx_file_on_disk(&self, collection: &str, vid: VolumeId) -> bool {
let idx_base = volume_file_name(&self.idx_directory, collection, vid);
let idx_path = format!("{}.ecx", idx_base);
if let Ok(meta) = fs::metadata(&idx_path)
&& !meta.is_dir()
{
return true;
if let Ok(meta) = fs::metadata(&idx_path) {
if !meta.is_dir() {
return true;
}
}
if self.idx_directory != self.directory {
let data_base = volume_file_name(&self.directory, collection, vid);
let data_path = format!("{}.ecx", data_base);
if let Ok(meta) = fs::metadata(&data_path)
&& !meta.is_dir()
{
return true;
if let Ok(meta) = fs::metadata(&data_path) {
if !meta.is_dir() {
return true;
}
}
}
false
@@ -1113,7 +1107,7 @@ impl DiskLocation {
/// Close all volumes.
pub fn close(&mut self) {
for v in self.volumes.values_mut() {
for (_, v) in self.volumes.iter_mut() {
v.close();
}
self.volumes.clear();
@@ -1190,9 +1184,10 @@ fn ec_data_shards_from_vif(directory: &str, idx_directory: &str, collection: &st
.and_then(|s| serde_json::from_str::<VifVolumeInfo>(&s).ok())
.and_then(|vi| vi.ec_shard_config)
.map(|c| c.data_shards as usize)
&& ds > 0
{
return ds;
if ds > 0 {
return ds;
}
}
if directory == idx_directory {
break;
@@ -1270,7 +1265,7 @@ fn check_dat_file_exists(path: &str) -> bool {
/// True when a `.vif` references remote-tier files: a remote-only volume
/// that has no local `.dat` but must still load via the remote path,
/// rather than be skipped as a lone EC sidecar.
pub(crate) fn vif_references_remote_file(vif_path: &str) -> bool {
fn vif_references_remote_file(vif_path: &str) -> bool {
fs::read_to_string(vif_path)
.ok()
.and_then(|s| serde_json::from_str::<VifVolumeInfo>(&s).ok())
@@ -164,16 +164,16 @@ pub fn remove_bitrot_sidecars(base: &str) -> io::Result<()> {
};
let mut first_err: Option<io::Error> = None;
let mut record = |res: io::Result<()>| {
if let Err(e) = res
&& first_err.is_none()
{
first_err = Some(e);
if let Err(e) = res {
if first_err.is_none() {
first_err = Some(e);
}
}
};
record(rm(format!("{}{}", base, BITROT_SIDECAR_EXT).into()));
let path = Path::new(base);
if let (Some(parent), Some(fname)) = (path.parent(), path.file_name()) {
let prefix = format!("{}{}.v", fname.display(), BITROT_SIDECAR_EXT);
let prefix = format!("{}{}.v", fname.to_string_lossy(), BITROT_SIDECAR_EXT);
match fs::read_dir(parent) {
Ok(entries) => {
for entry in entries.flatten() {
@@ -203,7 +203,7 @@ pub fn new_encode_uuid() -> Vec<u8> {
/// Reports whether `block_size` is a power of two in [1 MiB, MAX_BITROT_BLOCK_SIZE].
pub fn is_pow2_multiple_of_1mib(block_size: u32) -> bool {
((1 << 20)..=MAX_BITROT_BLOCK_SIZE).contains(&block_size) && block_size.count_ones() == 1
block_size >= (1 << 20) && block_size <= MAX_BITROT_BLOCK_SIZE && block_size.count_ones() == 1
}
/// Returns ceil(covered_size / block_size).
@@ -402,7 +402,7 @@ pub fn validate_manifest(
total
));
}
let mut seen = [false; MAX_SHARD_COUNT];
let mut seen = vec![false; MAX_SHARD_COUNT];
for s in &prot.shards {
if s.shard_id >= total as u32 {
return Err(format!(
@@ -505,21 +505,7 @@ pub fn verify_shard_file_blocks(
entry: &EcShardChecksums,
block_size: i64,
) -> io::Result<Vec<usize>> {
verify_shard_blocks(&File::open(path)?, entry, block_size)
}
/// Same verification against an ALREADY-OPEN shard handle.
///
/// Go's `ChecksumScrub` reads through `shard.ReadAt`, i.e. the handle the
/// EcVolumeShard already holds, so a concurrent teardown that unlinks the shard
/// cannot turn an intentional removal into a scrub read error. A scrub that
/// runs with the store lock released has to read the same way — see
/// `EcChecksumScrubPlan`.
pub fn verify_shard_blocks(
f: &File,
entry: &EcShardChecksums,
block_size: i64,
) -> io::Result<Vec<usize>> {
let f = File::open(path)?;
let file_size = f.metadata()?.len() as i64;
let want = unpack_u32_le(&entry.block_crc32c);
@@ -537,7 +523,7 @@ pub fn verify_shard_blocks(
break;
}
let to_read = to_read as usize;
read_full_at(f, &mut buf[..to_read], offset as u64)?;
read_full_at(&f, &mut buf[..to_read], offset as u64)?;
if CRC::new(&buf[..to_read]).0 != *want_crc {
mismatched.push(i);
}
@@ -73,7 +73,6 @@ pub fn find_dat_file_size_with_dirs(
/// must live in `dir`. For the cross-disk reconciled layout where
/// shards are split across multiple data dirs of the same node, use
/// [`write_dat_file_from_shards_with_dirs`] instead.
#[expect(clippy::too_many_arguments)]
pub fn write_dat_file_from_shards(
dir: &str,
collection: &str,
@@ -121,7 +120,7 @@ pub fn write_dat_file_from_shards(
/// size. `large_block_size`/`small_block_size` are the volume's shard
/// block layout, e.g. `EcVolume::large_block_size()` /
/// `small_block_size()` from its .vif EC config.
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub fn write_dat_file_from_shards_with_dirs(
dat_dir: &str,
collection: &str,
@@ -146,7 +145,7 @@ pub fn write_dat_file_from_shards_with_dirs(
)
}
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
fn write_dat_file(
dat_dir: &str,
collection: &str,
@@ -234,10 +233,10 @@ fn write_dat_file(
// Read large blocks
while encoded_remaining >= large_row_size && remaining > 0 {
for (i, shard) in shards[..data_shards].iter().enumerate() {
for i in 0..data_shards {
let to_write = large_block_size.min(remaining as usize);
let mut buf = vec![0u8; to_write];
let n = shard.read_at(&mut buf, shard_offset)?;
let n = shards[i].read_at(&mut buf, shard_offset)?;
if n != to_write {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
@@ -256,10 +255,10 @@ fn write_dat_file(
// Read small blocks
while remaining > 0 {
for (i, shard) in shards[..data_shards].iter().enumerate() {
for i in 0..data_shards {
let to_write = small_block_size.min(remaining as usize);
let mut buf = vec![0u8; to_write];
let n = shard.read_at(&mut buf, shard_offset)?;
let n = shards[i].read_at(&mut buf, shard_offset)?;
if n != to_write {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
@@ -325,7 +324,10 @@ pub fn write_idx_file_from_ec_index(
// and treat only NotFound as "no journal": Path::exists would also
// swallow a permission/IO error and silently skip deletions, which
// would resurrect deleted needles as live.
let mut idx_file = std::fs::OpenOptions::new().append(true).open(&tmp_path)?;
let mut idx_file = std::fs::OpenOptions::new()
.write(true)
.append(true)
.open(&tmp_path)?;
match std::fs::read(&ecj_path) {
Ok(ecj_data) => {
let count = ecj_data.len() / NEEDLE_ID_SIZE;
@@ -50,7 +50,7 @@ pub fn write_ec_files(
let dat_size = dat_file.metadata()?.len() as i64;
let rs = ReedSolomon::new(data_shards, parity_shards)
.map_err(|e| io::Error::other(format!("reed-solomon init: {:?}", e)))?;
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("reed-solomon init: {:?}", e)))?;
// Create shard files
let total_shards = data_shards + parity_shards;
@@ -162,7 +162,7 @@ pub fn rebuild_ec_files(
}
let rs = ReedSolomon::new(data_shards, parity_shards)
.map_err(|e| io::Error::other(format!("reed-solomon init: {:?}", e)))?;
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("reed-solomon init: {:?}", e)))?;
let total_shards = data_shards + parity_shards;
let mut shards: Vec<EcVolumeShard> = (0..total_shards as u8)
@@ -175,7 +175,7 @@ pub fn rebuild_ec_files(
let mut shard_size = 0;
for (i, shard) in shards.iter_mut().enumerate() {
if !missing_shard_ids.contains(&(i as u32)) {
if shard.open().is_ok() {
if let Ok(_) = shard.open() {
let size = shard.file_size();
if size > shard_size {
shard_size = size;
@@ -185,7 +185,7 @@ pub fn rebuild_ec_files(
let mut found = false;
for &other_dir in additional_dirs {
let mut alt = EcVolumeShard::new(other_dir, collection, volume_id, i as u8);
if alt.open().is_ok() {
if let Ok(_) = alt.open() {
let size = alt.file_size();
if size > shard_size {
shard_size = size;
@@ -251,8 +251,12 @@ pub fn rebuild_ec_files(
}
// Reconstruct missing shards
rs.reconstruct(&mut buffers)
.map_err(|e| io::Error::other(format!("reed-solomon reconstruct: {:?}", e)))?;
rs.reconstruct(&mut buffers).map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("reed-solomon reconstruct: {:?}", e),
)
})?;
// Write recovered data into the missing shards
for i in missing_shard_ids {
@@ -280,63 +284,40 @@ pub fn rebuild_ec_files(
/// FULL walk only reads live data-shard intervals, so on its own it can't catch
/// bitrot in a parity shard or an unwalked region. Move to mode 4 (CHECKSUM) and
/// drop it from mode 2 once the `.ecsum` subsystem lands.
///
/// `dirs` is indexed BY SHARD ID: each entry is the directory holding that
/// shard, or `None` when no disk mounts it. A reconciled volume's shards can be
/// split across disks, so a single directory cannot address them all.
pub fn verify_ec_shards(
dirs: &[Option<String>],
dir: &str,
collection: &str,
volume_id: VolumeId,
data_shards: usize,
parity_shards: usize,
) -> io::Result<(Vec<u32>, Vec<String>)> {
let rs = ReedSolomon::new(data_shards, parity_shards)
.map_err(|e| io::Error::other(format!("reed-solomon init: {:?}", e)))?;
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("reed-solomon init: {:?}", e)))?;
let total_shards = data_shards + parity_shards;
let mut shards: Vec<Option<EcVolumeShard>> = (0..total_shards)
.map(|i| {
dirs.get(i)
.and_then(|d| d.as_ref())
.map(|d| EcVolumeShard::new(d, collection, volume_id, i as u8))
})
let mut shards: Vec<EcVolumeShard> = (0..total_shards as u8)
.map(|i| EcVolumeShard::new(dir, collection, volume_id, i))
.collect();
let mut shard_size = 0;
let mut broken_shards = std::collections::HashSet::new();
let mut details = Vec::new();
for (i, slot) in shards.iter_mut().enumerate() {
match slot.as_mut() {
// Not a match guard: a binding is immutable until the guard ends,
// and `open()` needs `&mut self`.
Some(shard) => {
if shard.open().is_ok() {
let size = shard.file_size();
if size > shard_size {
shard_size = size;
}
} else {
broken_shards.insert(i as u32);
details.push(format!("failed to open or missing shard {}", i));
}
}
None => {
broken_shards.insert(i as u32);
details.push(format!("shard {} is not mounted on any disk", i));
for (i, shard) in shards.iter_mut().enumerate() {
if let Ok(_) = shard.open() {
let size = shard.file_size();
if size > shard_size {
shard_size = size;
}
} else {
broken_shards.insert(i as u32);
details.push(format!("failed to open or missing shard {}", i));
}
}
if shard_size == 0 || broken_shards.len() >= parity_shards {
// Can't do much if we don't know the size or have too many missing.
// Sort like the normal path below: a `HashSet` iteration order would
// make this return shard ids in an arbitrary order, and enough `None`
// entries in `dirs` now reach this branch for a caller to notice.
let mut broken_vec: Vec<u32> = broken_shards.into_iter().collect();
broken_vec.sort_unstable();
return Ok((broken_vec, details));
// Can't do much if we don't know the size or have too many missing
return Ok((broken_shards.into_iter().collect(), details));
}
let block_size = ERASURE_CODING_SMALL_BLOCK_SIZE;
@@ -350,17 +331,7 @@ pub fn verify_ec_shards(
let mut read_failed = false;
for i in 0..total_shards {
if !broken_shards.contains(&(i as u32)) {
// The `None` arm is defensive and unreachable: the open loop
// put every unmounted slot in `broken_shards`, which this
// branch already skipped. Kept because the `Option` forces
// some handling here, and an error is the only shape that
// cannot quietly feed an unread buffer into the parity
// comparison below. Nothing needs to cover it.
let read = match shards[i].as_mut() {
Some(shard) => shard.read_at(&mut buffers[i], offset),
None => Err(io::Error::new(io::ErrorKind::NotFound, "shard not mounted")),
};
if let Err(e) = read {
if let Err(e) = shards[i].read_at(&mut buffers[i], offset) {
broken_shards.insert(i as u32);
details.push(format!("read error shard {}: {}", i, e));
read_failed = true;
@@ -374,27 +345,27 @@ pub fn verify_ec_shards(
if !read_failed {
// Need to convert Vec<Vec<u8>> to &[&[u8]] for rs.verify
let slice_ptrs: Vec<&[u8]> = buffers.iter().map(|v| v.as_slice()).collect();
if let Ok(is_valid) = rs.verify(&slice_ptrs)
&& !is_valid
{
// Reed-Solomon verification failed. We cannot easily pinpoint which shard
// is corrupted without recalculating parities or syndromes, so we just
// log that this batch has corruption. Wait, we can test each parity shard!
// Let's re-encode from the first `data_shards` and compare to the actual `parity_shards`.
if let Ok(is_valid) = rs.verify(&slice_ptrs) {
if !is_valid {
// Reed-Solomon verification failed. We cannot easily pinpoint which shard
// is corrupted without recalculating parities or syndromes, so we just
// log that this batch has corruption. Wait, we can test each parity shard!
// Let's re-encode from the first `data_shards` and compare to the actual `parity_shards`.
let mut verify_buffers = buffers.clone();
// Clear the parity parts
for buf in &mut verify_buffers[data_shards..total_shards] {
buf.fill(0);
}
if rs.encode(&mut verify_buffers).is_ok() {
for i in 0..total_shards {
if buffers[i] != verify_buffers[i] {
broken_shards.insert(i as u32);
details.push(format!(
"parity mismatch on shard {} at offset {}",
i, offset
));
let mut verify_buffers = buffers.clone();
// Clear the parity parts
for i in data_shards..total_shards {
verify_buffers[i].fill(0);
}
if rs.encode(&mut verify_buffers).is_ok() {
for i in 0..total_shards {
if buffers[i] != verify_buffers[i] {
broken_shards.insert(i as u32);
details.push(format!(
"parity mismatch on shard {} at offset {}",
i, offset
));
}
}
}
}
@@ -406,7 +377,7 @@ pub fn verify_ec_shards(
}
// Close all shards
for shard in shards.iter_mut().flatten() {
for shard in &mut shards {
shard.close();
}
@@ -486,7 +457,7 @@ pub fn rebuild_ecx_file(
.collect();
for (i, shard) in shards.iter_mut().enumerate() {
if shard.open().is_err() {
if let Err(_) = shard.open() {
let mut found = false;
for &other_dir in additional_dirs {
let mut alt = EcVolumeShard::new(other_dir, collection, volume_id, i as u8);
@@ -503,7 +474,7 @@ pub fn rebuild_ecx_file(
}
return Err(io::Error::new(
io::ErrorKind::NotFound,
"cannot open data shard for ecx rebuild".to_string(),
format!("cannot open data shard for ecx rebuild"),
));
}
}
@@ -511,7 +482,7 @@ pub fn rebuild_ecx_file(
// Determine total logical data size from shard sizes
let shard_size = shards.iter().map(|s| s.file_size()).max().unwrap_or(0);
let total_data_size = shard_size * data_shards as i64;
let total_data_size = shard_size as i64 * data_shards as i64;
// The volume's shard block layout: the .vif-recorded uniform block size,
// or the legacy two-tier sizes when 0. The row count comes from the shard
// length; -1 disambiguates a legacy shard that is an exact large-block
@@ -534,7 +505,7 @@ pub fn rebuild_ecx_file(
let locate_shard_size = if dat_file_size > 0 {
dat_file_size / data_shards as i64
} else {
(shard_size - 1).max(0)
(shard_size as i64 - 1).max(0)
};
// Read version from superblock (first byte of logical data)
@@ -636,6 +607,7 @@ pub fn rebuild_ecx_file(
/// Read bytes from EC data shards at a logical offset in the .dat file,
/// resolving the shard/offset through the volume's block layout via
/// locate_data — the same mapping the read path uses.
#[allow(clippy::too_many_arguments)]
fn read_from_data_shards(
shards: &[EcVolumeShard],
buf: &mut [u8],
@@ -709,7 +681,7 @@ const ENCODE_BUFFER_SIZE: usize = 256 * 1024;
/// 2. Process remaining data with small blocks
///
/// `buffer_size` must divide both block sizes.
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn encode_dat_file(
dat_file: &File,
dat_size: i64,
@@ -773,7 +745,7 @@ pub(crate) fn encode_dat_file(
/// Encode one row of blocks, streaming it in ENCODE_BUFFER_SIZE sub-batches so
/// arbitrarily large blocks never require block-sized allocations. Mirrors
/// Go's encodeData.
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
fn encode_data(
dat_file: &File,
row_offset: u64,
@@ -785,7 +757,7 @@ fn encode_data(
data_shards: usize,
) -> io::Result<()> {
let buffer_size = buffers[0].len();
if !block_size.is_multiple_of(buffer_size) {
if block_size % buffer_size != 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
@@ -812,7 +784,7 @@ fn encode_data(
/// Encode one sub-batch: the same buffer-sized slice of every shard's block in
/// this row. Mirrors Go's encodeDataOneBatch.
#[expect(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
fn encode_one_batch(
dat_file: &File,
offset: u64,
@@ -825,15 +797,21 @@ fn encode_one_batch(
) -> io::Result<()> {
// Read data shards from the .dat file, zero-filling past EOF — the buffers
// are reused across batches, so the tail must be cleared explicitly.
for (i, buf) in buffers[..data_shards].iter_mut().enumerate() {
for i in 0..data_shards {
let read_offset = offset + (i * block_size) as u64;
let n = read_at_most(dat_file, buf, read_offset)?;
buf[n..].fill(0);
let n = read_at_most(dat_file, &mut buffers[i], read_offset)?;
for b in buffers[i][n..].iter_mut() {
*b = 0;
}
}
// Encode parity shards
rs.encode(&mut *buffers)
.map_err(|e| io::Error::other(format!("reed-solomon encode: {:?}", e)))?;
rs.encode(&mut *buffers).map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("reed-solomon encode: {:?}", e),
)
})?;
// Write all shard buffers to files and feed the same bytes to each
// shard's bitrot checksum builder, keeping covered_size == on-disk length.
@@ -1479,111 +1457,4 @@ mod tests {
"should fail when idx_dir doesn't contain .idx"
);
}
/// Write a real 10+4 encoded volume into `dir`.
///
/// Unlike `make_volume_with_needles` and `encode_sample_volume` this seeds
/// a caller-chosen directory, which is what a split-disk test needs: the
/// shards have to be scattered out of the directory they were encoded into.
fn seed_encoded_volume(dir: &str, vid: VolumeId) {
let mut v = Volume::new(
dir,
dir,
"",
vid,
NeedleMapKind::InMemory,
None,
None,
0,
Version::current(),
)
.unwrap();
for i in 1..=8 {
let data = format!("test data for needle {} with a bit more length", 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, false).unwrap();
}
v.sync_to_disk().unwrap();
v.close();
write_ec_files(dir, dir, "", vid, 10, 4).unwrap();
}
/// Shards split across two directories must all be found. Passing one dir
/// per shard is what lets a reconciled volume's parity be checked at all.
#[test]
fn test_verify_ec_shards_reads_shards_from_multiple_dirs() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src");
let d0 = tmp.path().join("d0");
let d1 = tmp.path().join("d1");
for d in [&src, &d0, &d1] {
std::fs::create_dir_all(d).unwrap();
}
let src_s = src.to_str().unwrap();
seed_encoded_volume(src_s, VolumeId(1));
// Move shards 0..=6 to d0 and 7..=13 to d1.
let mut dirs: Vec<Option<String>> = Vec::new();
for id in 0..14u8 {
let target = if id < 7 { &d0 } else { &d1 };
std::fs::rename(
format!("{}/1.ec{:02}", src_s, id),
format!("{}/1.ec{:02}", target.to_str().unwrap(), id),
)
.unwrap();
dirs.push(Some(target.to_str().unwrap().to_string()));
}
let (broken, details) = verify_ec_shards(&dirs, "", VolumeId(1), 10, 4).unwrap();
assert!(
broken.is_empty(),
"split-dir shards reported broken: {:?}",
details
);
}
/// A shard no disk holds is a missing shard, not a panic and not a silent
/// pass: it is REPORTED, by id, with a message that distinguishes "no disk
/// holds this shard" from "the disk holds it but it won't open".
///
/// Read the scope literally. This does NOT show that the mounted shards
/// verify clean. `dirs[5] = None` puts shard 5 in `broken_shards` before
/// the block loop starts, so every iteration takes the
/// `else { read_failed = true; }` arm and the Reed-Solomon comparison never
/// runs at all. `broken == vec![5]` therefore holds because the other 13
/// were never verified, not because they verified clean -- a parity check
/// over intact shards is what
/// `test_verify_ec_shards_reads_shards_from_multiple_dirs` and the
/// end-to-end split-disk FULL scrub establish.
#[test]
fn test_verify_ec_shards_treats_a_none_dir_as_missing() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
seed_encoded_volume(dir, VolumeId(1));
let mut dirs: Vec<Option<String>> = (0..14).map(|_| Some(dir.to_string())).collect();
dirs[5] = None;
let (broken, details) = verify_ec_shards(&dirs, "", VolumeId(1), 10, 4).unwrap();
assert_eq!(
broken,
vec![5],
"an unmounted shard must be reported, and only it: {:?}",
details
);
// "no disk holds this shard" and "the disk holds it but it won't open"
// are different operator problems, which is why they carry different
// messages. Asserting only the id would let one masquerade as the other.
assert!(
details.iter().any(|d| d.contains("not mounted")),
"an unmounted shard must be distinguished from an unopenable one, got {:?}",
details
);
}
}
@@ -78,7 +78,7 @@ impl EcVolumeShard {
let file = self
.ecd_file
.as_ref()
.ok_or_else(|| io::Error::other("shard file not open"))?;
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "shard file not open"))?;
#[cfg(unix)]
{
@@ -102,7 +102,7 @@ impl EcVolumeShard {
let file = self
.ecd_file
.as_mut()
.ok_or_else(|| io::Error::other("shard file not open"))?;
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "shard file not open"))?;
file.write_all(data)?;
self.ecd_file_size += data.len() as i64;
Ok(())
@@ -112,21 +112,6 @@ impl EcVolumeShard {
self.ecd_file_size
}
/// A duplicate of the mounted shard handle, for a reader that has to
/// outlive the store guard.
///
/// This is the same descriptor `read_at` serves from, so it carries the
/// `O_NOATIME` from `open_volume_file` and keeps pointing at the shard
/// that was mounted, whatever later happens to the path. `dup` shares the
/// kernel file offset, which is why every read through it must be
/// positional (`read_at`), never seek-based.
pub fn try_clone_file(&self) -> io::Result<File> {
self.ecd_file
.as_ref()
.ok_or_else(|| io::Error::other("shard file not open"))?
.try_clone()
}
/// Protobuf descriptor for this shard. Mirrors Go's ToEcShardInfo.
pub fn to_ec_shard_info(&self) -> crate::pb::volume_server_pb::EcShardInfo {
crate::pb::volume_server_pb::EcShardInfo {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@ use crate::storage::needle::needle::get_actual_size;
use crate::storage::types::*;
use std::io::{self, Read, Seek, SeekFrom};
pub(crate) const ROWS_TO_READ: usize = 1024;
const ROWS_TO_READ: usize = 1024;
/// Walk all entries in an .idx file, calling `f` for each.
/// Mirrors Go's `WalkIndexFile()`.
+2 -4
View File
@@ -21,7 +21,7 @@ impl CRC {
/// Legacy `.Value()` function — deprecated in Go but needed for backward compat check.
/// Formula: (crc >> 15 | crc << 17) + 0xa282ead8
pub fn legacy_value(&self) -> u32 {
self.0.rotate_right(15).wrapping_add(0xa282ead8)
(self.0 >> 15 | self.0 << 17).wrapping_add(0xa282ead8)
}
}
@@ -67,9 +67,7 @@ mod tests {
fn test_crc_legacy_value() {
let crc = CRC(0x12345678);
let v = crc.legacy_value();
// (0x12345678 >> 15 | 0x12345678 << 17) + 0xa282ead8, worked out by hand so
// the test checks the rotate rather than restating it.
let expected = 0x4f730f40_u32;
let expected = (0x12345678u32 >> 15 | 0x12345678u32 << 17).wrapping_add(0xa282ead8);
assert_eq!(v, expected);
}
}
-1
View File
@@ -1,5 +1,4 @@
pub mod crc;
#[expect(clippy::module_inception, reason = "needle/needle.rs mirrors the Go package layout")]
pub mod needle;
pub mod ttl;
+15 -21
View File
@@ -560,7 +560,7 @@ impl Needle {
// Padding to 8-byte alignment
let padding = padding_length(self.size, version).0 as usize;
buf.extend(std::iter::repeat_n(0u8, padding));
buf.extend(std::iter::repeat(0u8).take(padding));
buf
}
@@ -824,13 +824,11 @@ mod tests {
#[test]
fn test_needle_write_read_round_trip_v3() {
let mut n = Needle {
cookie: Cookie(42),
id: NeedleId(100),
data: b"hello world".to_vec(),
flags: 0,
..Needle::default()
};
let mut n = Needle::default();
n.cookie = Cookie(42);
n.id = NeedleId(100);
n.data = b"hello world".to_vec();
n.flags = 0;
n.set_has_name();
n.name = b"test.txt".to_vec();
n.name_size = 8;
@@ -869,13 +867,11 @@ mod tests {
#[test]
fn test_needle_write_read_round_trip_v2() {
let mut n = Needle {
cookie: Cookie(77),
id: NeedleId(200),
data: b"data v2".to_vec(),
flags: 0,
..Needle::default()
};
let mut n = Needle::default();
n.cookie = Cookie(77);
n.id = NeedleId(200);
n.data = b"data v2".to_vec();
n.flags = 0;
let bytes = n.write_bytes(VERSION_2);
let expected_size = get_actual_size(n.size, VERSION_2);
@@ -890,12 +886,10 @@ mod tests {
#[test]
fn test_read_bytes_meta_only_handles_tombstone_v3() {
let mut tombstone = Needle {
cookie: Cookie(0x1234abcd),
id: NeedleId(300),
append_at_ns: 999_999,
..Needle::default()
};
let mut tombstone = Needle::default();
tombstone.cookie = Cookie(0x1234abcd);
tombstone.id = NeedleId(300);
tombstone.append_at_ns = 999_999;
let bytes = tombstone.write_bytes(VERSION_3);
+17 -50
View File
@@ -81,7 +81,7 @@ impl TTL {
return Ok(TTL::EMPTY);
}
let last_byte = s.as_bytes()[s.len() - 1];
let (num_str, unit_byte) = if last_byte.is_ascii_digit() {
let (num_str, unit_byte) = if last_byte >= b'0' && last_byte <= b'9' {
// All digits — default to minutes (matching Go)
(s, b'm')
} else {
@@ -144,73 +144,40 @@ fn fit_ttl_count(count: u32, unit: u8) -> TTL {
const MINUTE_SECS: u64 = 60;
// First pass: try exact fits from largest to smallest
if seconds.is_multiple_of(YEAR_SECS) && seconds / YEAR_SECS < 256 {
return TTL {
count: (seconds / YEAR_SECS) as u8,
unit: TTL_UNIT_YEAR,
};
if seconds % YEAR_SECS == 0 && seconds / YEAR_SECS < 256 {
return TTL { count: (seconds / YEAR_SECS) as u8, unit: TTL_UNIT_YEAR };
}
if seconds.is_multiple_of(MONTH_SECS) && seconds / MONTH_SECS < 256 {
return TTL {
count: (seconds / MONTH_SECS) as u8,
unit: TTL_UNIT_MONTH,
};
if seconds % MONTH_SECS == 0 && seconds / MONTH_SECS < 256 {
return TTL { count: (seconds / MONTH_SECS) as u8, unit: TTL_UNIT_MONTH };
}
if seconds.is_multiple_of(WEEK_SECS) && seconds / WEEK_SECS < 256 {
return TTL {
count: (seconds / WEEK_SECS) as u8,
unit: TTL_UNIT_WEEK,
};
if seconds % WEEK_SECS == 0 && seconds / WEEK_SECS < 256 {
return TTL { count: (seconds / WEEK_SECS) as u8, unit: TTL_UNIT_WEEK };
}
if seconds.is_multiple_of(DAY_SECS) && seconds / DAY_SECS < 256 {
return TTL {
count: (seconds / DAY_SECS) as u8,
unit: TTL_UNIT_DAY,
};
if seconds % DAY_SECS == 0 && seconds / DAY_SECS < 256 {
return TTL { count: (seconds / DAY_SECS) as u8, unit: TTL_UNIT_DAY };
}
if seconds.is_multiple_of(HOUR_SECS) && seconds / HOUR_SECS < 256 {
return TTL {
count: (seconds / HOUR_SECS) as u8,
unit: TTL_UNIT_HOUR,
};
if seconds % HOUR_SECS == 0 && seconds / HOUR_SECS < 256 {
return TTL { count: (seconds / HOUR_SECS) as u8, unit: TTL_UNIT_HOUR };
}
// Minutes: truncating division
if seconds / MINUTE_SECS < 256 {
return TTL {
count: (seconds / MINUTE_SECS) as u8,
unit: TTL_UNIT_MINUTE,
};
return TTL { count: (seconds / MINUTE_SECS) as u8, unit: TTL_UNIT_MINUTE };
}
// Second pass: truncating division from smallest to largest
if seconds / HOUR_SECS < 256 {
return TTL {
count: (seconds / HOUR_SECS) as u8,
unit: TTL_UNIT_HOUR,
};
return TTL { count: (seconds / HOUR_SECS) as u8, unit: TTL_UNIT_HOUR };
}
if seconds / DAY_SECS < 256 {
return TTL {
count: (seconds / DAY_SECS) as u8,
unit: TTL_UNIT_DAY,
};
return TTL { count: (seconds / DAY_SECS) as u8, unit: TTL_UNIT_DAY };
}
if seconds / WEEK_SECS < 256 {
return TTL {
count: (seconds / WEEK_SECS) as u8,
unit: TTL_UNIT_WEEK,
};
return TTL { count: (seconds / WEEK_SECS) as u8, unit: TTL_UNIT_WEEK };
}
if seconds / MONTH_SECS < 256 {
return TTL {
count: (seconds / MONTH_SECS) as u8,
unit: TTL_UNIT_MONTH,
};
return TTL { count: (seconds / MONTH_SECS) as u8, unit: TTL_UNIT_MONTH };
}
if seconds / YEAR_SECS < 256 {
return TTL {
count: (seconds / YEAR_SECS) as u8,
unit: TTL_UNIT_YEAR,
};
return TTL { count: (seconds / YEAR_SECS) as u8, unit: TTL_UNIT_YEAR };
}
TTL::EMPTY
}
File diff suppressed because it is too large Load Diff
@@ -31,7 +31,7 @@ struct CompactEntry {
}
impl CompactEntry {
fn to_needle_value(self) -> NeedleValue {
fn to_needle_value(&self) -> NeedleValue {
NeedleValue {
offset: Offset::from_bytes(&self.offset),
size: self.size,
@@ -1,161 +0,0 @@
//! Rebuild a needle map's counters from the whole `.idx` history.
//!
//! The volume's garbage ratio is `deleted_size / content_size`, and both are
//! additive over the life of the volume: an overwritten needle keeps its
//! bytes in `content_size` and adds them to `deleted_size`. A backend whose
//! table only holds the final value per key (redb) cannot recover that from
//! the table, so on load the counters come from the `.idx` file instead.
//!
//! This mirrors Go's `needleMapMetricFromIndexFile`: walk the index newest
//! entry first with a bloom filter of the keys already seen, so the memory
//! cost is a few bits per entry instead of a map of every key. The counting
//! rule reproduces what the live `on_put`/`on_delete` path accumulates:
//!
//! - every live entry is one put: `file_count`, `file_byte_count`;
//! - a live entry with a newer entry for the same key was overwritten or
//! deleted later, so it is also one deletion: `deletion_count`,
//! `deletion_byte_count`;
//! - a tombstone only marks its key as seen.
//!
//! A bloom false positive (0.1%) can only add a spurious deletion, which
//! over-reports garbage slightly; it never hides any.
use std::io::{self, Read, Seek, SeekFrom};
use std::sync::atomic::Ordering;
use xxhash_rust::xxh64::xxh64;
use super::NeedleMapMetric;
use crate::storage::types::*;
/// Entries read per batch while walking backwards (64 KiB of index).
const BATCH_ENTRIES: usize = 4096;
/// Same target false-positive rate as the Go server's filter.
const FALSE_POSITIVE_RATE: f64 = 0.001;
/// Minimal bloom filter over needle ids, double hashing with xxh64.
struct SeenKeys {
bits: Vec<u64>,
bit_count: u64,
hashes: u64,
}
impl SeenKeys {
fn new(expected: u64, false_positive_rate: f64) -> Self {
let n = expected.max(1) as f64;
let ln2 = std::f64::consts::LN_2;
let bit_count = (-(n * false_positive_rate.ln()) / (ln2 * ln2))
.ceil()
.max(64.0) as u64;
let hashes = ((bit_count as f64 / n) * ln2).round().clamp(1.0, 16.0) as u64;
SeenKeys {
bits: vec![0u64; bit_count.div_ceil(64) as usize],
bit_count,
hashes,
}
}
/// Whether `key` was (probably) seen before; marks it seen either way.
fn test_and_add(&mut self, key: u64) -> bool {
let bytes = key.to_le_bytes();
let h1 = xxh64(&bytes, 0);
let h2 = xxh64(&bytes, 0x9E37_79B9_7F4A_7C15) | 1;
let mut seen = true;
for i in 0..self.hashes {
let bit = h1.wrapping_add(i.wrapping_mul(h2)) % self.bit_count;
let word = (bit / 64) as usize;
let mask = 1u64 << (bit % 64);
if self.bits[word] & mask == 0 {
seen = false;
self.bits[word] |= mask;
}
}
seen
}
}
/// Walk `reader` (an `.idx` file) newest entry first and return the counters
/// a live volume would hold after applying the same history. A torn partial
/// entry at the tail is ignored, as `walk_index_file` does. The reader is
/// left positioned at the start of the file.
pub(super) fn metrics_from_idx<R: Read + Seek>(
reader: &mut R,
version: Version,
) -> io::Result<NeedleMapMetric> {
let metric = NeedleMapMetric::default();
let file_size = reader.seek(SeekFrom::End(0))?;
let entry_count = file_size / NEEDLE_MAP_ENTRY_SIZE as u64;
let mut seen = SeenKeys::new(entry_count, FALSE_POSITIVE_RATE);
let mut buf = vec![0u8; NEEDLE_MAP_ENTRY_SIZE * BATCH_ENTRIES];
let mut remaining = entry_count;
while remaining > 0 {
let batch = remaining.min(BATCH_ENTRIES as u64) as usize;
let first_entry = remaining - batch as u64;
let len = batch * NEEDLE_MAP_ENTRY_SIZE;
reader.seek(SeekFrom::Start(first_entry * NEEDLE_MAP_ENTRY_SIZE as u64))?;
reader.read_exact(&mut buf[..len])?;
for i in (0..batch).rev() {
let entry = &buf[i * NEEDLE_MAP_ENTRY_SIZE..(i + 1) * NEEDLE_MAP_ENTRY_SIZE];
let (key, offset, size) = idx_entry_from_bytes(entry);
metric.maybe_set_max_needle_end(offset, size, version);
let superseded = seen.test_and_add(key.into());
if offset.is_zero() || size.is_deleted() {
// Tombstone: reserves no bytes, only marks the key as seen.
continue;
}
metric.maybe_set_max_file_key(key);
metric.file_count.fetch_add(1, Ordering::Relaxed);
metric
.file_byte_count
.fetch_add(size.0 as u64, Ordering::Relaxed);
if superseded && size.0 > 0 {
metric.deletion_count.fetch_add(1, Ordering::Relaxed);
metric
.deletion_byte_count
.fetch_add(size.0 as u64, Ordering::Relaxed);
}
}
remaining = first_entry;
}
reader.seek(SeekFrom::Start(0))?;
Ok(metric)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_seen_keys_reports_repeats_and_not_fresh_keys() {
let mut seen = SeenKeys::new(10_000, FALSE_POSITIVE_RATE);
// Fresh keys may occasionally collide (that is the false-positive
// rate), but only rarely.
let fresh_reported_seen = (0..10_000u64)
.filter(|&key| seen.test_and_add(key))
.count();
assert!(
fresh_reported_seen < 50,
"fresh keys reported seen: {fresh_reported_seen}"
);
// A repeated key is never reported fresh: no false negatives.
for key in 0..10_000u64 {
assert!(seen.test_and_add(key), "repeated key {key} reported fresh");
}
// Over 100k never-inserted keys the false-positive rate stays near
// the 0.1% target; allow a generous margin.
let false_positives = (1_000_000..1_100_000u64)
.filter(|k| {
let bytes = k.to_le_bytes();
let h1 = xxh64(&bytes, 0);
let h2 = xxh64(&bytes, 0x9E37_79B9_7F4A_7C15) | 1;
(0..seen.hashes).all(|i| {
let bit = h1.wrapping_add(i.wrapping_mul(h2)) % seen.bit_count;
seen.bits[(bit / 64) as usize] & (1u64 << (bit % 64)) != 0
})
})
.count();
assert!(false_positives < 500, "false positives: {false_positives}");
}
}
@@ -226,7 +226,10 @@ impl SortedFileNeedleMap {
.fail_sdx_mark
.load(std::sync::atomic::Ordering::Relaxed)
{
return Err(io::Error::other("injected .sdx mark failure"));
return Err(io::Error::new(
io::ErrorKind::Other,
"injected .sdx mark failure",
));
}
let mut buf = [0u8; SIZE_SIZE];
TOMBSTONE_FILE_SIZE.to_bytes(&mut buf);
@@ -306,7 +309,7 @@ impl SortedFileNeedleMap {
let rows = rows_per_read.min(entry_count - done) as usize;
let bytes = &mut block[..rows * NEEDLE_MAP_ENTRY_SIZE];
read_exact_at(&file, bytes, done * NEEDLE_MAP_ENTRY_SIZE as u64)?;
for entry in bytes.as_chunks::<NEEDLE_MAP_ENTRY_SIZE>().0 {
for entry in bytes.chunks_exact(NEEDLE_MAP_ENTRY_SIZE) {
let (key, offset, size) = idx_entry_from_bytes(entry);
if !size.is_valid() || pending.contains_key(&key) {
continue; // deleted in place, or still awaiting that mark
+54 -550
View File
@@ -369,7 +369,6 @@ impl Store {
}
/// Create a new volume, placing it on the location with the most free space.
#[expect(clippy::too_many_arguments)]
pub fn add_volume(
&mut self,
vid: VolumeId,
@@ -384,10 +383,10 @@ impl Store {
return Err(VolumeError::AlreadyExists);
}
let loc_idx = self.find_free_location(&disk_type).ok_or_else(|| {
VolumeError::Io(io::Error::other(format!(
"no free location for disk type {:?}",
disk_type
)))
VolumeError::Io(io::Error::new(
io::ErrorKind::Other,
format!("no free location for disk type {:?}", disk_type),
))
})?;
self.locations[loc_idx].create_volume(
@@ -428,26 +427,6 @@ impl Store {
false
}
/// Reports whether any local volume or EC shard is currently quarantined
/// due to sustained storage-media EIO. Mirrors Go's Store.HasIoQuarantine.
pub fn has_io_quarantine(&self) -> bool {
for loc in &self.locations {
for (_, vol) in loc.iter_volumes() {
let (_, _, quarantined) = vol.get_io_error_state();
if quarantined {
return true;
}
}
for (_, ec_vol) in loc.ec_volumes() {
let (_, _, quarantined) = ec_vol.get_io_error_state();
if quarantined {
return true;
}
}
}
false
}
/// Mount a volume from an existing .dat file.
pub fn mount_volume(
&mut self,
@@ -460,7 +439,7 @@ impl Store {
}
// Find the location where the .dat file exists
for loc in &mut self.locations {
if loc.disk_type != disk_type {
if &loc.disk_type != &disk_type {
continue;
}
let base = crate::storage::volume::volume_file_name(&loc.directory, collection, vid);
@@ -473,10 +452,10 @@ impl Store {
// Fail the mount so the caller (VolumeCopy) treats it as an error.
let note_path = format!("{}.note", base);
if std::path::Path::new(&note_path).exists() {
return Err(VolumeError::Io(io::Error::other(format!(
"volume {} copy incomplete: .note still present",
vid
))));
return Err(VolumeError::Io(io::Error::new(
io::ErrorKind::Other,
format!("volume {} copy incomplete: .note still present", vid),
)));
}
return loc.create_volume(
vid,
@@ -497,185 +476,45 @@ impl Store {
/// Mount a volume by id only (Go's MountVolume behavior).
/// Scans all locations for a matching .dat file and loads with its collection prefix.
/// When a collection hint is given, the expected <collection>_<vid>.vif/.idx
/// path is probed directly before falling back to the directory scan.
pub fn mount_volume_by_id(
&mut self,
vid: VolumeId,
collection: Option<&str>,
) -> Result<(), VolumeError> {
pub fn mount_volume_by_id(&mut self, vid: VolumeId) -> Result<(), VolumeError> {
if self.find_volume(vid).is_some() {
return Err(VolumeError::AlreadyExists);
}
// Remember the last non-NotFound error so a caller gets a useful
// message when every candidate fails to open, instead of a generic
// NotFound. A successful mount returns immediately.
let mut last_err: Option<VolumeError> = None;
if let Some(collection) = collection {
// The hint is only an optimization: a collection carrying a path
// separator could route file creation outside the storage
// directory, so skip the shortcut and let the safe directory scan
// resolve the volume instead. Rejecting the exact ".." name is
// sufficient — a collection like "foo..bar" is a valid name and
// stays inside the directory (the ".." is part of the filename, not
// a parent reference, because volume_file_name joins with "_").
let hint_safe = !collection.is_empty()
&& !collection.contains('/')
&& !collection.contains('\\')
&& collection != "..";
if hint_safe {
for loc in &mut self.locations {
let base = crate::storage::volume::volume_file_name(
&loc.directory,
collection,
vid,
);
// Confirm a collection-named sidecar exists before using the
// hint. A lone .vif/.idx (e.g. an EC sidecar whose .ecx is on
// a sibling disk) must NOT mount here: create_volume would
// write an empty .dat and register a phantom normal volume
// that shadows the real EC volume. Match the guard in
// load_existing_volumes: only mount when a real .dat is
// present, or the .vif points at a remote-tiered file.
let sidecar_present = [".vif", ".idx"].iter().any(|ext| {
std::fs::metadata(format!("{}{}", base, ext))
.map(|m| !m.is_dir())
.unwrap_or(false)
});
if !sidecar_present {
continue;
}
let dat_path = format!("{}.dat", base);
let dat_exists = std::fs::metadata(&dat_path)
.map(|m| !m.is_dir())
.unwrap_or(false);
let idx_base = crate::storage::volume::volume_file_name(
&loc.idx_directory,
collection,
vid,
);
let has_remote = crate::storage::disk_location::vif_references_remote_file(
&format!("{}.vif", base),
) || crate::storage::disk_location::vif_references_remote_file(
&format!("{}.vif", idx_base),
);
if dat_exists || has_remote {
// A persisting .note means the copy that produced these
// files never completed; mounting it would expose a
// truncated volume. Skip this candidate and keep
// searching (matches load_existing_volumes).
let note_path = format!("{}.note", base);
if std::path::Path::new(&note_path).exists() {
continue;
}
// An open failure on one candidate must not block a
// valid volume on a later disk — remember the error and
// keep scanning (matches open_volumes / Go mountVolume).
match loc.create_volume(
vid,
collection,
self.needle_map_kind,
None,
None,
0,
Version::current(),
) {
Ok(()) => return Ok(()),
Err(e) => {
last_err = Some(e);
continue;
}
}
}
// Lone sidecar: leave it for the directory scan below.
}
}
}
// Iterate every matching candidate, not just the first. A lone
// sidecar on an earlier disk must not hide a real .dat on a later
// disk (the split-disk EC layout the guard above protects against).
for (loc_idx, base_path, collection) in self.find_volume_file_bases(vid) {
// The scan matches any volume file (.dat/.vif/.idx). A lone .vif/.idx
// sidecar (e.g. an EC sidecar whose .ecx is on a sibling disk) must
// NOT mount here: create_volume would write an empty .dat and
// register a phantom normal volume that shadows the real EC volume.
// Match the guard in load_existing_volumes: only mount when a real
// .dat is present, or the .vif points at a remote-tiered file.
let dat_exists = std::fs::metadata(format!("{}.dat", base_path))
.map(|m| !m.is_dir())
.unwrap_or(false);
let idx_base = crate::storage::volume::volume_file_name(
&self.locations[loc_idx].idx_directory,
&collection,
if let Some((loc_idx, _base_path, collection)) = self.find_volume_file_base(vid) {
let loc = &mut self.locations[loc_idx];
return loc.create_volume(
vid,
&collection,
self.needle_map_kind,
None,
None,
0,
Version::current(),
);
let has_remote = crate::storage::disk_location::vif_references_remote_file(
&format!("{}.vif", base_path),
) || crate::storage::disk_location::vif_references_remote_file(
&format!("{}.vif", idx_base),
);
if dat_exists || has_remote {
// A persisting .note means the copy that produced these files
// never completed; mounting it would expose a truncated volume.
// Skip this candidate and keep searching (matches
// load_existing_volumes).
let note_path = format!("{}.note", base_path);
if std::path::Path::new(&note_path).exists() {
continue;
}
// An open failure on one candidate must not block a valid
// volume on a later disk — remember the error and keep
// scanning (matches open_volumes / Go mountVolume).
let loc = &mut self.locations[loc_idx];
match loc.create_volume(
vid,
&collection,
self.needle_map_kind,
None,
None,
0,
Version::current(),
) {
Ok(()) => return Ok(()),
Err(e) => {
last_err = Some(e);
continue;
}
}
}
}
Err(last_err.unwrap_or_else(|| VolumeError::Io(io::Error::new(
Err(VolumeError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!("volume {} not found on disk", vid),
))))
)))
}
fn find_volume_file_base(&self, vid: VolumeId) -> Option<(usize, String, String)> {
self.find_volume_file_bases(vid).into_iter().next()
}
/// Collect every location/collection whose directory holds a volume file
/// (.dat/.vif/.idx) for `vid`, in scan order. Callers that need to skip
/// lone sidecars (mount_volume_by_id) must see all candidates so a sidecar
/// on an earlier disk does not hide a real .dat on a later one.
fn find_volume_file_bases(&self, vid: VolumeId) -> Vec<(usize, String, String)> {
let mut results = Vec::new();
for (loc_idx, loc) in self.locations.iter().enumerate() {
if let Ok(entries) = std::fs::read_dir(&loc.directory) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some((collection, file_vid)) = parse_volume_filename(&name)
&& file_vid == vid
&& let Some(base) = strip_volume_suffix(&name)
{
let base_path = format!("{}/{}", loc.directory, base);
results.push((loc_idx, base_path, collection));
if let Some((collection, file_vid)) = parse_volume_filename(&name) {
if file_vid == vid {
let base = strip_volume_suffix(&name)?;
let base_path = format!("{}/{}", loc.directory, base);
return Some((loc_idx, base_path, collection));
}
}
}
}
}
results
None
}
/// Configure a volume's replica placement on disk.
@@ -842,8 +681,10 @@ impl Store {
let vol_count = loc.volumes_len() as i32;
let loc_ec_shards = loc.ec_shard_count();
let ec_equivalent = loc_ec_shards
.div_ceil(crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT)
let ec_equivalent = ((loc_ec_shards
+ crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT
- 1)
/ crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT)
as i32;
let mut max_count = vol_count + ec_equivalent;
@@ -1033,20 +874,6 @@ impl Store {
dirs
}
/// Every per-disk `EcVolume` this store maps for `vid`, in location order.
/// Immutable twin of [`Self::find_all_ec_volumes_mut`].
///
/// Reconciliation can mount one vid as N runtimes holding disjoint shard
/// subsets, and the first-match `find_ec_volume` hides the siblings. Anything
/// that has to reach the whole volume, rather than any one runtime of it,
/// uses this.
pub fn find_all_ec_volumes(&self, vid: VolumeId) -> Vec<&EcVolume> {
self.locations
.iter()
.filter_map(|loc| loc.find_ec_volume(vid))
.collect()
}
pub fn find_all_ec_volumes_mut(&mut self, vid: VolumeId) -> Vec<&mut EcVolume> {
self.locations
.iter_mut()
@@ -1092,10 +919,10 @@ impl Store {
/// first disk and miss shards that live on a sibling.
pub fn find_ec_shard_location(&self, vid: VolumeId, shard_id: u32) -> Option<usize> {
for (i, loc) in self.locations.iter().enumerate() {
if let Some(ecv) = loc.find_ec_volume(vid)
&& ecv.has_shard(shard_id as u8)
{
return Some(i);
if let Some(ecv) = loc.find_ec_volume(vid) {
if ecv.has_shard(shard_id as u8) {
return Some(i);
}
}
}
None
@@ -1104,12 +931,16 @@ impl Store {
/// Like [`Self::find_ec_shard_location`] but returns the EcVolume
/// reference directly. Borrows the store immutably for the
/// EcVolume's lifetime.
pub fn find_ec_volume_with_shard(&self, vid: VolumeId, shard_id: u32) -> Option<&EcVolume> {
pub fn find_ec_volume_with_shard(
&self,
vid: VolumeId,
shard_id: u32,
) -> Option<&EcVolume> {
for loc in &self.locations {
if let Some(ecv) = loc.find_ec_volume(vid)
&& ecv.has_shard(shard_id as u8)
{
return Some(ecv);
if let Some(ecv) = loc.find_ec_volume(vid) {
if ecv.has_shard(shard_id as u8) {
return Some(ecv);
}
}
}
None
@@ -1138,9 +969,9 @@ impl Store {
if found_vol.is_none() {
found_vol = Some(ecv);
}
for (shard_id, dir) in dirs.iter_mut().enumerate() {
if dir.is_none() && ecv.has_shard(shard_id as u8) {
*dir = Some(loc.directory.clone());
for shard_id in 0..max_shard_count {
if dirs[shard_id].is_none() && ecv.has_shard(shard_id as u8) {
dirs[shard_id] = Some(loc.directory.clone());
}
}
}
@@ -1159,19 +990,12 @@ impl Store {
for (disk_id, loc) in self.locations.iter_mut().enumerate() {
let mut expired_vids = Vec::new();
let mut io_quarantined_vids = Vec::new();
for (vid, ec_vol) in loc.ec_volumes() {
if ec_vol.is_time_to_destroy() {
expired_vids.push(*vid);
} else {
let (_, io_count, quarantined) = ec_vol.get_io_error_state();
if quarantined || io_count >= crate::storage::erasure_coding::ec_volume::IO_ERROR_TOLERANCE
{
io_quarantined_vids.push(*vid);
} else {
ec_shards
.extend(ec_vol.to_volume_ec_shard_information_messages(disk_id as u32));
}
ec_shards
.extend(ec_vol.to_volume_ec_shard_information_messages(disk_id as u32));
}
}
@@ -1192,20 +1016,6 @@ impl Store {
ec_shards.extend(messages);
}
}
for vid in io_quarantined_vids {
if let Some(ec_vol) = loc.find_ec_volume(vid) {
let (_, io_count, quarantined) = ec_vol.get_io_error_state();
if !quarantined {
ec_vol.mark_io_quarantined();
tracing::warn!(
volume_id = vid.0,
io_count,
"ec volume quarantined after consecutive IO errors"
);
}
}
}
}
(ec_shards, deleted)
@@ -1510,10 +1320,9 @@ fn load_vif_volume_info(path: &str) -> Result<VifVolumeInfo, VolumeError> {
read_only: bool,
}
if let Ok(legacy) = serde_json::from_str::<LegacyVolumeInfo>(&content) {
return Ok(VifVolumeInfo {
read_only: legacy.read_only,
..VifVolumeInfo::default()
});
let mut vif = VifVolumeInfo::default();
vif.read_only = legacy.read_only;
return Ok(vif);
}
Err(VolumeError::Io(io::Error::new(
io::ErrorKind::InvalidData,
@@ -1523,7 +1332,7 @@ fn load_vif_volume_info(path: &str) -> Result<VifVolumeInfo, VolumeError> {
fn save_vif_volume_info(path: &str, info: &VifVolumeInfo) -> Result<(), VolumeError> {
let content = serde_json::to_string_pretty(info)
.map_err(|e| VolumeError::Io(io::Error::other(e.to_string())))?;
.map_err(|e| VolumeError::Io(io::Error::new(io::ErrorKind::Other, e.to_string())))?;
std::fs::write(path, content)?;
Ok(())
}
@@ -1654,311 +1463,6 @@ mod tests {
assert_eq!(store.total_volume_count(), 1);
}
#[test]
fn test_mount_volume_by_id_hint_skips_lone_sidecar() {
// A lone .vif/.idx sidecar (e.g. an EC sidecar whose .ecx is on a
// sibling disk) must NOT make the collection hint create a phantom
// empty .dat. The hint is skipped and the directory scan finds nothing.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut store = make_test_store(&[dir]);
let base = volume_file_name(dir, "coll", VolumeId(5));
std::fs::write(format!("{}.vif", base), "{}").unwrap();
std::fs::write(format!("{}.idx", base), b"").unwrap();
// No .dat, and the .vif does not reference a remote file.
let err = store
.mount_volume_by_id(VolumeId(5), Some("coll"))
.unwrap_err();
assert!(matches!(err, VolumeError::Io(ref e)
if e.kind() == std::io::ErrorKind::NotFound));
// No phantom .dat was created.
assert!(!std::path::Path::new(&format!("{}.dat", base)).exists());
assert!(store.find_volume(VolumeId(5)).is_none());
}
#[test]
fn test_mount_volume_by_id_hint_rejects_traversal_collection() {
// A collection carrying a parent reference must not route file
// creation outside the storage directory; the hint is dropped.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut store = make_test_store(&[dir]);
let escaped = format!(
"{}/../evil_5.dat",
dir
);
let err = store
.mount_volume_by_id(VolumeId(5), Some("../evil"))
.unwrap_err();
assert!(matches!(err, VolumeError::Io(ref e)
if e.kind() == std::io::ErrorKind::NotFound));
assert!(!std::path::Path::new(&escaped).exists());
assert!(store.find_volume(VolumeId(5)).is_none());
}
#[test]
fn test_mount_volume_by_id_hint_loads_real_volume() {
// With a real .dat on disk, the collection hint mounts the volume
// directly without scanning the directory.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut store = make_test_store(&[dir]);
store
.add_volume(
VolumeId(7),
"coll",
None,
None,
0,
DiskType::HardDrive,
Version::current(),
)
.unwrap();
// Write a needle so the volume has real data, then unmount it so the
// .dat stays on disk but is no longer registered.
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"hint me".to_vec(),
data_size: 7,
..Needle::default()
};
store
.write_volume_needle(VolumeId(7), &mut n, false)
.unwrap();
assert!(store.unmount_volume(VolumeId(7)));
store
.mount_volume_by_id(VolumeId(7), Some("coll"))
.unwrap();
assert!(store.find_volume(VolumeId(7)).is_some());
let mut got = Needle {
id: NeedleId(1),
..Needle::default()
};
let count = store.read_volume_needle(VolumeId(7), &mut got).unwrap();
assert_eq!(count, 7);
assert_eq!(got.data, b"hint me");
}
#[test]
fn test_mount_volume_by_id_hint_accepts_double_dot_collection() {
// A valid collection like "foo..bar" contains ".." but is not a parent
// reference — volume_file_name joins with "_" so it stays in the dir.
// The hint must NOT be rejected for it.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut store = make_test_store(&[dir]);
store
.add_volume(
VolumeId(9),
"foo..bar",
None,
None,
0,
DiskType::HardDrive,
Version::current(),
)
.unwrap();
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"dots".to_vec(),
data_size: 4,
..Needle::default()
};
store.write_volume_needle(VolumeId(9), &mut n, false).unwrap();
assert!(store.unmount_volume(VolumeId(9)));
// The hint is accepted and mounts the volume.
store
.mount_volume_by_id(VolumeId(9), Some("foo..bar"))
.unwrap();
assert!(store.find_volume(VolumeId(9)).is_some());
let mut got = Needle {
id: NeedleId(1),
..Needle::default()
};
let count = store.read_volume_needle(VolumeId(9), &mut got).unwrap();
assert_eq!(count, 4);
assert_eq!(got.data, b"dots");
}
#[test]
fn test_mount_volume_by_id_skips_incomplete_note() {
// A persisting .note means a VolumeCopy was interrupted; the volume
// must not mount as live (would expose truncated data). The candidate
// is skipped and mount returns NotFound.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let mut store = make_test_store(&[dir]);
store
.add_volume(
VolumeId(11),
"coll",
None,
None,
0,
DiskType::HardDrive,
Version::current(),
)
.unwrap();
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"partial".to_vec(),
data_size: 7,
..Needle::default()
};
store.write_volume_needle(VolumeId(11), &mut n, false).unwrap();
assert!(store.unmount_volume(VolumeId(11)));
// Simulate an interrupted copy: drop a .note marker.
let base = volume_file_name(dir, "coll", VolumeId(11));
std::fs::write(format!("{}.note", base), "interrupted").unwrap();
// Hint path: skipped because of .note.
let err = store
.mount_volume_by_id(VolumeId(11), Some("coll"))
.unwrap_err();
assert!(matches!(err, VolumeError::Io(ref e)
if e.kind() == std::io::ErrorKind::NotFound));
assert!(store.find_volume(VolumeId(11)).is_none());
// Fallback path (no hint): also skipped because of .note.
let err = store.mount_volume_by_id(VolumeId(11), None).unwrap_err();
assert!(matches!(err, VolumeError::Io(ref e)
if e.kind() == std::io::ErrorKind::NotFound));
assert!(store.find_volume(VolumeId(11)).is_none());
}
#[test]
fn test_mount_volume_by_id_fallback_skips_sidecar_finds_real_dat() {
// A lone sidecar on disk 0 must not hide a real .dat on disk 1.
// The fallback now iterates all candidates instead of stopping at
// the first (sidecar-only) match.
let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap();
let dir0 = tmp1.path().to_str().unwrap();
let dir1 = tmp2.path().to_str().unwrap();
// disk 0: lone .vif sidecar, no .dat, no remote.
let base0 = volume_file_name(dir0, "coll", VolumeId(13));
std::fs::write(format!("{}.vif", base0), "{}").unwrap();
// disk 1: real volume with data.
let mut store = make_test_store(&[dir0, dir1]);
store
.add_volume(
VolumeId(13),
"coll",
None,
None,
0,
DiskType::HardDrive,
Version::current(),
)
.unwrap();
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"real".to_vec(),
data_size: 4,
..Needle::default()
};
store.write_volume_needle(VolumeId(13), &mut n, false).unwrap();
assert!(store.unmount_volume(VolumeId(13)));
// No hint: the fallback scan finds the sidecar on disk 0 first (skip,
// no .dat), then the real .dat on disk 1 (mount).
store.mount_volume_by_id(VolumeId(13), None).unwrap();
assert!(store.find_volume(VolumeId(13)).is_some());
let mut got = Needle {
id: NeedleId(1),
..Needle::default()
};
let count = store.read_volume_needle(VolumeId(13), &mut got).unwrap();
assert_eq!(count, 4);
assert_eq!(got.data, b"real");
}
#[cfg(unix)]
#[test]
fn test_mount_volume_by_id_continues_past_open_failure() {
// A create_volume failure on an earlier candidate must not block a
// valid volume on a later disk. The scan remembers the error and
// keeps going (matches open_volumes / Go mountVolume).
use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::Ordering;
let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap();
let dir0 = tmp1.path().to_str().unwrap();
let dir1 = tmp2.path().to_str().unwrap();
let mut store = make_test_store(&[dir0, dir1]);
// Force add_volume onto disk 1 by marking disk 0 as low on space.
store.locations[0].is_disk_space_low.store(true, Ordering::Relaxed);
store
.add_volume(
VolumeId(15),
"coll",
None,
None,
0,
DiskType::HardDrive,
Version::current(),
)
.unwrap();
let mut n = Needle {
id: NeedleId(1),
cookie: Cookie(0xaa),
data: b"later".to_vec(),
data_size: 5,
..Needle::default()
};
store.write_volume_needle(VolumeId(15), &mut n, false).unwrap();
assert!(store.unmount_volume(VolumeId(15)));
// Clear the low-space flag so mount_volume_by_id considers disk 0.
store.locations[0].is_disk_space_low.store(false, Ordering::Relaxed);
// disk 0: a .dat that exists but is unreadable (chmod 000). The guard
// sees dat_exists=true (metadata succeeds, not a dir), but
// create_volume -> Volume::new -> load fails opening it.
let base0 = volume_file_name(dir0, "coll", VolumeId(15));
std::fs::write(format!("{}.dat", base0), b"").unwrap();
std::fs::set_permissions(
format!("{}.dat", base0),
std::fs::Permissions::from_mode(0o000),
)
.unwrap();
// The fallback scan hits disk 0 first (create_volume fails on the
// unreadable .dat), then disk 1 (succeeds). Mount succeeds from disk 1.
store.mount_volume_by_id(VolumeId(15), None).unwrap();
assert!(store.find_volume(VolumeId(15)).is_some());
let mut got = Needle {
id: NeedleId(1),
..Needle::default()
};
let count = store.read_volume_needle(VolumeId(15), &mut got).unwrap();
assert_eq!(count, 5);
assert_eq!(got.data, b"later");
// Restore permissions so TempDir cleanup can remove the file.
let _ = std::fs::set_permissions(
format!("{}.dat", base0),
std::fs::Permissions::from_mode(0o644),
);
}
#[test]
fn test_store_read_write_delete() {
let tmp = TempDir::new().unwrap();
+52 -127
View File
@@ -80,11 +80,6 @@ struct EcxOwnerInfo {
idx_dir: String,
}
/// One unit of reconcile work: the disk holding orphan shards, the volume
/// they belong to, the shard files, the `.ecx` owner, and whether the
/// mirror already installed sidecars locally (`use_local_idx`).
type OrphanShardLoad = (usize, EcKey, Vec<(String, u32)>, EcxOwnerInfo, bool);
impl Store {
/// Run cross-disk orphan-shard reconciliation. Should be called
/// after every DiskLocation has finished its per-disk EC scan.
@@ -103,7 +98,7 @@ impl Store {
// `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<OrphanShardLoad> = Vec::new();
let mut to_load: Vec<(usize, EcKey, Vec<(String, u32)>, EcxOwnerInfo, bool)> = 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 {
@@ -298,10 +293,10 @@ impl Store {
// may be sole copies of a distributed volume.
let mut node_wide_bits = ev.shard_bits().0;
for other in &self.locations {
if let Some(other_ev) = other.find_ec_volume(*vid)
&& other_ev.collection == ev.collection
{
node_wide_bits |= other_ev.shard_bits().0;
if let Some(other_ev) = other.find_ec_volume(*vid) {
if other_ev.collection == ev.collection {
node_wide_bits |= other_ev.shard_bits().0;
}
}
}
let node_wide = node_wide_bits.count_ones() as usize;
@@ -504,53 +499,6 @@ impl Store {
}
}
/// Walk a disk's data directory and return the `.ec??` shard files
/// that are present on disk but not yet registered in the location's
/// `ec_volumes` map. Keyed by (collection, vid) so callers can match
/// each group against its `.ecx`-owning disk in one lookup. Zero-byte
/// shard files are ignored — same shape as `load_all_ec_shards`.
fn collect_orphan_ec_shards(
loc: &crate::storage::disk_location::DiskLocation,
_loc_idx: usize,
) -> HashMap<EcKey, Vec<(String, u32)>> {
let mut orphans: HashMap<EcKey, Vec<(String, u32)>> = HashMap::new();
let Ok(read) = fs::read_dir(&loc.directory) else {
return orphans;
};
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);
let Some(shard_id) = is_ec_shard_extension(ext) else {
continue;
};
// Ignore zero-byte shards. Use the DirEntry's metadata so we
// don't pay a second stat syscall per file beyond what
// read_dir already returned.
match ent.metadata() {
Ok(meta) if meta.len() > 0 => {}
_ => continue,
}
let Some((collection, vid)) = parse_collection_volume_id_pub(base) else {
continue;
};
// Skip shards that are already registered to an EcVolume.
if let Some(ecv) = loc.find_ec_volume(vid)
&& ecv.has_shard(shard_id as u8)
{
continue;
}
let key = EcKey { collection, vid };
orphans.entry(key).or_default().push((name, shard_id));
}
orphans
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1236,76 +1184,6 @@ mod tests {
assert!(!std::ptr::eq(ev0, ev1));
}
/// `find_ec_volume` returns only disk 0's runtime, which is what hides
/// sibling-disk shards from every scrub mode. The plural lookup must
/// return one runtime per disk holding the vid, in location order.
#[test]
fn test_find_all_ec_volumes_returns_every_disk() {
let (store, _tmp) = build_split_disk_store(7010);
let vid = VolumeId(7010);
let all = store.find_all_ec_volumes(vid);
assert_eq!(all.len(), 2, "expected one EcVolume per disk holding the vid");
// Disk 0 carries shards 0 and 12; disk 1 carries shard 1.
assert!(all[0].has_shard(0));
assert!(all[0].has_shard(12));
assert!(all[1].has_shard(1));
// The singular lookup sees only the first — the bug being fixed.
let first = store.find_ec_volume(vid).unwrap();
assert!(std::ptr::eq(first, all[0]));
// A vid nobody mounts yields an empty vec, not a panic.
assert!(store.find_all_ec_volumes(VolumeId(9999)).is_empty());
}
/// End-to-end: with the vid mounted on two disks, a scrub driven through
/// the Store must reach BOTH disks' shards. Before the aggregation fix
/// `find_ec_volume` returned disk 0 and disk 1's shard 1 was never read.
#[test]
fn test_scrub_plans_reach_every_disk_through_the_store() {
use crate::storage::erasure_coding::ec_volume::{
merge_ec_runtimes, EcChecksumScrubPlan, EcLocalScrubPlan,
};
let (store, _tmp) = build_split_disk_store(7030);
let vid = VolumeId(7030);
let runtimes = store.find_all_ec_volumes(vid);
assert_eq!(runtimes.len(), 2);
// Reachability is the invariant, so assert on the resolved slots rather
// than on scrub message text: shards 0 and 12 live on disk 0, shard 1 on
// disk 1. The old first-match lookup could never see shard 1.
let merged = merge_ec_runtimes(&runtimes).expect("two runtimes merge");
assert!(merged.slots[0].is_some(), "disk 0's shard 0 unreachable");
assert!(merged.slots[12].is_some(), "disk 0's shard 12 unreachable");
assert!(merged.slots[1].is_some(), "disk 1's shard 1 unreachable — the bug");
assert!(merged.skipped.is_empty(), "same generation: {:?}", merged.skipped);
// Shard 1 is owned by the sibling runtime, not the anchor.
let (owner, _) = merged.slots[1].unwrap();
assert!(std::ptr::eq(owner, runtimes[1]));
// Both plans build over the union rather than over disk 0 alone.
assert!(EcChecksumScrubPlan::for_volumes(&runtimes).is_some());
assert!(EcLocalScrubPlan::for_volumes(&runtimes).is_some());
// ...and `is_some()` is a real question: `for_volumes` has exactly one
// `None` (the vanished-volume case), so without this the two lines above
// would hold for any input at all.
assert!(EcChecksumScrubPlan::for_volumes(&[]).is_none());
assert!(EcLocalScrubPlan::for_volumes(&[]).is_none());
// Regression guard: a single-runtime view still sees only its own disk,
// which is exactly what made aggregation necessary.
let disk0 = merge_ec_runtimes(&[runtimes[0]]).unwrap();
assert!(
disk0.slots.get(1).copied().flatten().is_none(),
"disk 0's runtime must not see the sibling's shard"
);
}
/// `Store::unmount_ec_shards` used to return after the first
/// location with the vid, so a request to unmount a shard that
/// lives on a sibling disk became a silent no-op. After the fix,
@@ -1811,3 +1689,50 @@ mod tests {
assert!(std::path::Path::new(&format!("{}.ecx", ec_base)).exists());
}
}
/// Walk a disk's data directory and return the `.ec??` shard files
/// that are present on disk but not yet registered in the location's
/// `ec_volumes` map. Keyed by (collection, vid) so callers can match
/// each group against its `.ecx`-owning disk in one lookup. Zero-byte
/// shard files are ignored — same shape as `load_all_ec_shards`.
fn collect_orphan_ec_shards(
loc: &crate::storage::disk_location::DiskLocation,
_loc_idx: usize,
) -> HashMap<EcKey, Vec<(String, u32)>> {
let mut orphans: HashMap<EcKey, Vec<(String, u32)>> = HashMap::new();
let Ok(read) = fs::read_dir(&loc.directory) else {
return orphans;
};
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);
let Some(shard_id) = is_ec_shard_extension(ext) else {
continue;
};
// Ignore zero-byte shards. Use the DirEntry's metadata so we
// don't pay a second stat syscall per file beyond what
// read_dir already returned.
match ent.metadata() {
Ok(meta) if meta.len() > 0 => {}
_ => continue,
}
let Some((collection, vid)) = parse_collection_volume_id_pub(base) else {
continue;
};
// Skip shards that are already registered to an EcVolume.
if let Some(ecv) = loc.find_ec_volume(vid) {
if ecv.has_shard(shard_id as u8) {
continue;
}
}
let key = EcKey { collection, vid };
orphans.entry(key).or_default().push((name, shard_id));
}
orphans
}
+24 -4
View File
@@ -155,7 +155,7 @@ impl Size {
return 0;
}
if self.0 < 0 {
return -self.0 as u32;
return (self.0 * -1) as u32;
}
self.0 as u32
}
@@ -284,9 +284,8 @@ impl fmt::Display for Offset {
// DiskType
// ============================================================================
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum DiskType {
#[default]
HardDrive,
Ssd,
Custom(String),
@@ -320,6 +319,12 @@ impl fmt::Display for DiskType {
}
}
impl Default for DiskType {
fn default() -> Self {
DiskType::HardDrive
}
}
// ============================================================================
// VolumeId
// ============================================================================
@@ -392,7 +397,7 @@ impl From<u8> for Version {
///
/// Fields are split into request-side options (set by the caller) and response-side
/// flags (set during the read to communicate status back).
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone)]
pub struct ReadOption {
// -- request --
/// If true, allow reading needles that have been soft-deleted.
@@ -418,6 +423,21 @@ pub struct ReadOption {
pub read_buffer_size: i32,
}
impl Default for ReadOption {
fn default() -> Self {
ReadOption {
read_deleted: false,
attempt_meta_only: false,
must_meta_only: false,
is_meta_only: false,
volume_revision: 0,
is_out_of_range: false,
has_slow_read: false,
read_buffer_size: 0,
}
}
}
// ============================================================================
// NeedleMapEntry helpers (for .idx file)
// ============================================================================
File diff suppressed because it is too large Load Diff
@@ -30,9 +30,6 @@ pub fn report_hash(m: &master_pb::VolumeInformationMessage) -> u64 {
if m.read_only {
buf[56] = 1;
}
if m.read_only_can_delete {
buf[56] |= 2;
}
let mut h = xxh64(&buf, 0);
h = fold(h, xxh64(&(m.modified_at_second as u64).to_le_bytes(), 0));
@@ -73,10 +70,8 @@ mod tests {
let empty = master_pb::VolumeInformationMessage::default();
assert_eq!(report_hash(&empty), 10988706248825469653);
let one = master_pb::VolumeInformationMessage {
id: 1,
..Default::default()
};
let mut one = master_pb::VolumeInformationMessage::default();
one.id = 1;
assert_eq!(report_hash(&one), 2035849960016744285);
let full = master_pb::VolumeInformationMessage {
@@ -87,7 +82,6 @@ mod tests {
delete_count: 2,
deleted_byte_count: 99,
read_only: true,
read_only_can_delete: false,
replica_placement: 10,
version: 3,
ttl: 3 << 8,
+1 -1
View File
@@ -66,7 +66,7 @@ fn parse_go_version_number() -> Option<String> {
}
}
match (major, minor) {
(Some(maj), Some(min)) => Some(format!("{}.{:02}", maj, min)),
(Some(maj), Some(min)) => Some(format!("{}.{}", maj, format!("{:02}", min))),
_ => None,
}
}
-2
View File
@@ -6556,13 +6556,11 @@ dependencies = [
"lance-table",
"prometheus",
"reqwest 0.12.28",
"rustls",
"seaweed-worker-core",
"seaweed-worker-sort",
"serde",
"serde_json",
"tokio",
"tonic",
"tracing",
"tracing-subscriber",
]
+1 -13
View File
@@ -10,19 +10,7 @@ members = ["crates/core", "crates/lance", "crates/sort"]
[workspace.package]
version = "0.1.0"
edition = "2024"
# The edition needs 1.85; the dependency tree needs more. Verified with
# `cargo +1.94.1 check --all-targets` (1.94.0 fails on the AWS SDK that
# lance's `aws` feature pulls in).
rust-version = "1.94.1"
[workspace.lints.clippy]
# Every RPC path returns tonic::Status (176 bytes). Boxing it would change
# every handler signature for no gain, so the large-Err lint is off.
result_large_err = "allow"
# Protobuf message literals keep `..Default::default()` on purpose: it is
# what lets a proto gain a field without touching every constructor.
needless_update = "allow"
edition = "2021"
[workspace.dependencies]
anyhow = "1"
-6
View File
@@ -14,12 +14,6 @@ one.
## Building
Requires Rust 1.94.1+ (2024 edition), matching `rust-version` in `Cargo.toml`.
The patch release matters: 1.94.0 does not build. The edition itself only needs
1.85; the higher floor comes from the dependency tree — lance's `aws` feature
pulls in the AWS SDK — so it moves with those crates. CI builds on the latest
stable.
`core` compiles `plugin.proto` with the protoc that protoc-bin-vendored ships,
the way seaweed-volume does, so it needs no system install.
-4
View File
@@ -2,7 +2,6 @@
name = "seaweed-worker-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
description = "SeaweedFS plugin.proto worker contract"
[lib]
@@ -26,6 +25,3 @@ tonic-build.workspace = true
# install, and so the version is pinned rather than whatever the platform's
# package manager happens to carry. The same crate seaweed-volume uses.
protoc-bin-vendored = "3"
[lints]
workspace = true
+1 -6
View File
@@ -4,12 +4,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// version. An explicit PROTOC still wins, for packagers supplying their own
// and for the lance crates, whose own build scripts read the same variable.
if std::env::var_os("PROTOC").is_none() {
// SAFETY: a build script's main runs single-threaded before anything
// else in this process, so no other thread can be reading the
// environment concurrently.
unsafe {
std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?);
}
std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?);
}
// Compiled straight out of the Go tree, the way seaweed-volume already reads
+4 -4
View File
@@ -14,10 +14,10 @@ pub fn server_to_grpc_address(server: &str) -> Option<String> {
let (host, port_part) = server.rsplit_once(':')?;
// "port.grpcPort" states the gRPC port outright.
if let Some((_, grpc_port)) = port_part.split_once('.')
&& let Ok(port) = grpc_port.parse::<u16>()
{
return Some(join_host_port(host, port));
if let Some((_, grpc_port)) = port_part.split_once('.') {
if let Ok(port) = grpc_port.parse::<u16>() {
return Some(join_host_port(host, port));
}
}
let port: u16 = port_part.parse().ok()?;
-3
View File
@@ -16,9 +16,6 @@ pub mod stream;
/// Generated plugin.proto types.
pub mod pb {
// prost gives every oneof its own enum; the variant sizes are the
// messages' own, not a choice made here.
#![allow(clippy::large_enum_variant)]
tonic::include_proto!("plugin");
}
-6
View File
@@ -2,7 +2,6 @@
name = "weed-lance-worker"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
description = "SeaweedFS maintenance worker for Lance tables"
[lib]
@@ -36,7 +35,6 @@ anyhow.workspace = true
async-trait.workspace = true
clap = { version = "4", features = ["derive", "env"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rustls = "0.23"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio.workspace = true
@@ -45,11 +43,7 @@ tracing-subscriber.workspace = true
[dev-dependencies]
tokio = { workspace = true }
tonic.workspace = true
arrow-array = "58"
arrow-schema = "58"
arrow-cast = "58"
lance-linalg = "10"
[lints]
workspace = true
-1
View File
@@ -10,6 +10,5 @@ pub mod dataset;
pub mod jobs;
pub mod metrics;
pub mod preview;
pub mod tls;
pub use jobs::handlers;
-2
View File
@@ -113,8 +113,6 @@ fn metrics_address(ip: &str, port: u16) -> Result<SocketAddr> {
#[tokio::main]
async fn main() -> Result<()> {
weed_lance_worker::tls::install_default_crypto_provider();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
-35
View File
@@ -1,35 +0,0 @@
use rustls::crypto::aws_lc_rs;
// aws-lc-rs and ring both get linked transitively (lance's aws backend pulls
// aws-lc-rs, reqwest's rustls-tls pulls ring), so rustls can't auto-select a
// provider and tonic's client TLS panics on first use. Pin the default to
// aws-lc-rs, matching the Rust volume server. Idempotent.
pub fn install_default_crypto_provider() {
let _ = aws_lc_rs::default_provider().install_default();
}
#[cfg(test)]
mod tests {
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
use super::install_default_crypto_provider;
const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIBPDCB76ADAgECAhRuRPQgeAu43BT/M7EfAWSdapVdYDAFBgMrZXAwFDESMBAG\nA1UEAwwJbG9jYWxob3N0MB4XDTI2MDcwNTE2MTUyOVoXDTM2MDcwMjE2MTUyOVow\nFDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEAr/3bNIFI+8V32oCiY6y+\nXRFmZpdNQ2g//VtRkT+nQg+jUzBRMB0GA1UdDgQWBBTsy9tLf1zPiXCQfgci6zNi\ndEzRSjAfBgNVHSMEGDAWgBTsy9tLf1zPiXCQfgci6zNidEzRSjAPBgNVHRMBAf8E\nBTADAQH/MAUGAytlcANBAIvsdw0IbvOBBkb9cd7BfMJfIP9pQQrAL03pCRWJFnFh\nSysaLVgFXI4T078IiaM874oO+iB+5vNbWEpc7CkGow4=\n-----END CERTIFICATE-----\n";
const TEST_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIHbyn71Kk+Y7KT3sBctit7uZpErpoH6qDbFj6P8qGaZH\n-----END PRIVATE KEY-----\n";
// Without install_default_crypto_provider this panics in the lance crate,
// where both aws-lc-rs and ring are linked. Mirrors the volume server's
// test_build_grpc_endpoint_with_tls_resolves_crypto_provider.
#[tokio::test]
async fn tls_channel_builds_after_crypto_provider_installed() {
install_default_crypto_provider();
let config = ClientTlsConfig::new()
.ca_certificate(Certificate::from_pem(TEST_CERT_PEM))
.identity(Identity::from_pem(TEST_CERT_PEM, TEST_KEY_PEM));
let endpoint = Channel::from_shared("https://127.0.0.1:9")
.unwrap()
.tls_config(config)
.unwrap();
assert_eq!(endpoint.uri().scheme_str(), Some("https"));
}
}
-4
View File
@@ -2,7 +2,6 @@
name = "seaweed-worker-sort"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
description = "The sort specification shared by SeaweedFS sorting jobs"
[lib]
@@ -11,6 +10,3 @@ name = "seaweed_worker_sort"
[dependencies]
seaweed-worker-core = { path = "../core" }
anyhow.workspace = true
[lints]
workspace = true
+4 -4
View File
@@ -152,10 +152,10 @@ fn parse_field(entry: &str) -> Result<SortField> {
/// back: sorting by the worker's default order instead of the one the table
/// asked for would silently rewrite the table the wrong way.
pub fn resolve(declared: Option<&str>, configured: &str) -> Result<Option<SortSpec>> {
if let Some(declared) = declared
&& let Some(spec) = SortSpec::parse(declared).context("read the table's declared order")?
{
return Ok(Some(spec));
if let Some(declared) = declared {
if let Some(spec) = SortSpec::parse(declared).context("read the table's declared order")? {
return Ok(Some(spec));
}
}
SortSpec::parse(configured).context("read the configured sort order")
}
@@ -113,7 +113,7 @@ done
wait_http "http://127.0.0.1:$VPORT/healthz" 60 && ok "volume /healthz up (mTLS)" || bad "volume /healthz"
launch filer-f0
wait_http "http://127.0.0.1:$FPORT/healthz" 30 && ok "filer /healthz up (mTLS)" || bad "filer /healthz"
wait_http "http://127.0.0.1:$FPORT/" 30 && ok "filer / up (mTLS)" || bad "filer /"
# [jwt.filer_signing] is active, so the filer requires a signed JWT for writes.
# An unsigned write MUST be rejected with 401 -- this proves the JWT signing key
# rendered into security.toml is enforced (positive security assertion).
+3 -41
View File
@@ -726,20 +726,6 @@ func (r *chaosRun) seedAndSpread() {
require.GreaterOrEqual(r.t, len(r.volumes), 2, "seeding should produce at least two volumes")
time.Sleep(3 * time.Second)
// Cap the grows per server so heartbeat lag cannot run away: the spread
// check reads the master topology, which lags the volume.grow writes, so
// an uncapped loop re-fires every tick and fills every disk to capacity
// before the master registers the prior grow. A full disk has no free EC
// shard slots and the source disk drops below the encode's FreeVolumeCount
// >= 2 health check, failing ec.encode with "no healthy replicas" or "no
// free ec shard slots".
//
// Use -count 1 so each grow lands exactly one volume on the volume
// server's least-loaded disk (deterministic spreading), and cap the
// total grows per server well below the per-disk max so the source disk
// always retains FreeVolumeCount >= 2 for ec.encode's shard generation.
growsPerServer := make(map[string]int)
const maxGrowsPerServer = 4
require.Eventually(r.t, func() bool {
spread := nodeVolumeDiskCounts(r.t, r.env)
if len(spread) == chaosServerCount && allAtLeast(spread, 2) {
@@ -747,26 +733,9 @@ func (r *chaosRun) seedAndSpread() {
}
for i := 0; i < chaosServerCount; i++ {
server := "127.0.0.1:" + chaosVolumePort(i)
if spread[server] < 2 && growsPerServer[server] < maxGrowsPerServer {
// Pin the data center and rack as well as the data node. The
// master's grow picks the rack by weighted random when -rack is
// unset, and only one of the three racks holds the requested
// data node, so an unpinned grow lands on the wrong rack two
// times out of three and the VolumeGrow RPC swallows the
// "No matching data node" error (non-cache grows ignore the
// internal failure). Those silent no-ops exhaust the per-server
// cap before the volumes ever spread, so seedAndSpread times
// out. Pinning the rack makes every grow reach the target node.
if spread[server] < 2 {
out, gerr := captureCommandOutput(r.t, shell.Commands[findCommandIndex("volume.grow")],
[]string{"-collection", chaosCollection, "-dataCenter", "dc1",
"-rack", fmt.Sprintf("rack%d", i), "-dataNode", server, "-count", "1"}, r.env)
// Only count successful grows toward the cap: a transient
// collectTopologyInfo or VolumeGrow RPC error would otherwise
// exhaust the retry budget without creating any volume, and
// the loop would then only poll until the Eventually timeout.
if gerr == nil {
growsPerServer[server]++
}
[]string{"-collection", chaosCollection, "-dataNode", server, "-count", "4"}, r.env)
r.t.Logf("volume.grow on %s: err=%v output:\n%s", server, gerr, out)
}
}
@@ -1190,13 +1159,7 @@ func (c *chaosCluster) startVolumeServer(ctx context.Context, i int, logName str
return nil, err
}
diskDirs = append(diskDirs, dir)
// ec.encode generates 14 shards on the source disk (2 volume-slot
// equivalents) and refuses a source whose disk has FreeVolumeCount < 2,
// and the cluster-wide capacity check needs at least one free EC shard
// slot. seedAndSpread's spread loop grows 4 volumes per tick and can
// over-grow before the master's topology catches up, so leave enough
// headroom that a near-full disk never starves the encode.
maxVolumes = append(maxVolumes, "8")
maxVolumes = append(maxVolumes, "4")
if d == chaosDisksPerNode-1 {
diskTypes = append(diskTypes, "ssd")
} else {
@@ -1213,7 +1176,6 @@ func (c *chaosCluster) startVolumeServer(ctx context.Context, i int, logName str
"-dir.idx", idxDir,
"-disk", strings.Join(diskTypes, ","),
"-max", strings.Join(maxVolumes, ","),
"-minFreeSpace", "0",
"-master", chaosMasterAddr,
"-ip", "127.0.0.1",
"-dataCenter", "dc1",
-51
View File
@@ -309,57 +309,6 @@ func (c *failoverCluster) FileVolumeIds(path string) ([]uint32, error) {
return vids, nil
}
// FileChunkList returns the filer's chunk list for a file: each chunk's file
// id, logical offset, size, and the volume id it lives on. Used to pinpoint
// which chunk covers a corrupted byte range and which volume server holds it.
func (c *failoverCluster) FileChunkList(path string) ([]struct {
FileId string
Offset int64
Size uint64
VolumeId uint32
}, error) {
body, err := c.FilerGet(path + "?metadata=true&resolveManifest=true")
if err != nil {
return nil, err
}
var entry struct {
Chunks []struct {
FileId string `json:"file_id"`
Offset int64 `json:"offset"`
Size uint64 `json:"size"`
Fid struct {
VolumeId uint32 `json:"volume_id"`
} `json:"fid"`
} `json:"chunks"`
}
if err = json.Unmarshal(body, &entry); err != nil {
return nil, fmt.Errorf("decode entry %s: %w", path, err)
}
out := make([]struct {
FileId string
Offset int64
Size uint64
VolumeId uint32
}, len(entry.Chunks))
for i, ch := range entry.Chunks {
vid := ch.Fid.VolumeId
if vid == 0 && ch.FileId != "" {
parsed, parseErr := strconv.ParseUint(strings.SplitN(ch.FileId, ",", 2)[0], 10, 32)
if parseErr != nil {
return nil, fmt.Errorf("parse file id %s: %w", ch.FileId, parseErr)
}
vid = uint32(parsed)
}
out[i] = struct {
FileId string
Offset int64
Size uint64
VolumeId uint32
}{ch.FileId, ch.Offset, ch.Size, vid}
}
return out, nil
}
// VolumeHolders returns the volume server addresses the master currently lists
// for a volume id.
func (c *failoverCluster) VolumeHolders(vid uint32) ([]string, error) {
+4 -83
View File
@@ -9,7 +9,6 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -397,91 +396,13 @@ func runChaosAppend(t *testing.T, c *failoverCluster, name string, chaos func())
// own mount and the filer make of the same file, which says whether the
// data was lost on the way in or is only invisible from this side.
d := firstDiff(want, got)
fromWriter, writerReadErr := os.ReadFile(writePath)
fromWriter, _ := os.ReadFile(writePath)
viaFiler, filerErr := c.FilerGet("/" + name)
require.Failf(t, "final content mismatch",
"%s: first difference at offset %d (want %d bytes, got %d)\nwant %q\ngot %q\nmount0 matches=%v (writer read err %v) filer matches=%v (err %v)\n%s\n%s\n%s",
"%s: first difference at offset %d (want %d bytes, got %d)\nwant %q\ngot %q\nmount0 matches=%v filer matches=%v (err %v)\n%s",
name, d, len(want), len(got), window(want, d), window(got, d),
string(fromWriter) == want, writerReadErr, string(viaFiler) == want, filerErr,
c.tailLog("mount0"),
dumpChunkList(c, "/"+name, d),
dumpHexAround(fromWriter, d, "writer mount"),
)
}
// dumpChunkList returns a human-readable summary of the filer's chunk list
// for path, annotated with the volume id and the master's current holders
// for each. When diffAt >= 0, the chunk covering that logical offset is
// flagged so a corruption can be tied to a specific chunk and volume server.
func dumpChunkList(c *failoverCluster, path string, diffAt int) string {
chunks, err := c.FileChunkList(path)
if err != nil {
return fmt.Sprintf("chunk list for %s: %v", path, err)
}
var b strings.Builder
fmt.Fprintf(&b, "chunk list for %s (%d chunks):", path, len(chunks))
for i, ch := range chunks {
holders, holdersErr := c.VolumeHolders(ch.VolumeId)
holdersStr := "unknown"
if holdersErr != nil {
holdersStr = fmt.Sprintf("lookup failed: %v", holdersErr)
} else if len(holders) > 0 {
holdersStr = strings.Join(holders, ",")
}
marker := ""
if diffAt >= 0 && int64(diffAt) >= ch.Offset && int64(diffAt) < ch.Offset+int64(ch.Size) {
marker = " <-- covers diff"
}
fmt.Fprintf(&b, "\n [%d] fid=%s offset=%d size=%d vid=%d holders=[%s]%s",
i, ch.FileId, ch.Offset, ch.Size, ch.VolumeId, holdersStr, marker)
}
return b.String()
}
// dumpHexAround returns a hex+ASCII dump of a 32-byte window around off in
// label's copy of the file, so the exact zero-filled region is visible in
// the failure message instead of only a quoted string window.
func dumpHexAround(data []byte, off int, label string) string {
if off < 0 {
return fmt.Sprintf("%s hex dump: no divergence offset", label)
}
start := max(0, off-16)
end := min(len(data), off+16)
if start >= end {
return fmt.Sprintf("%s hex dump: offset %d out of range (len %d)", label, off, len(data))
}
var b strings.Builder
fmt.Fprintf(&b, "%s hex dump around offset %d:", label, off)
for i := start; i < end; i += 16 {
lineEnd := min(i+16, end)
hexPart := hexDump(data[i:lineEnd])
asciiPart := asciiDump(data[i:lineEnd])
fmt.Fprintf(&b, "\n %06x %-48s %s", i, hexPart, asciiPart)
}
return b.String()
}
func hexDump(b []byte) string {
var sb strings.Builder
for i, x := range b {
if i > 0 {
sb.WriteByte(' ')
}
fmt.Fprintf(&sb, "%02x", x)
}
return sb.String()
}
func asciiDump(b []byte) string {
var sb strings.Builder
for _, x := range b {
if x >= 0x20 && x < 0x7f {
sb.WriteByte(x)
} else {
sb.WriteByte('.')
}
}
return sb.String()
string(fromWriter) == want, string(viaFiler) == want, filerErr,
c.tailLog("mount0"))
}
// firstDiff returns the offset of the first differing byte, or -1 when equal.
@@ -1,66 +0,0 @@
package example
import (
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestListMultipartUploadsInitiated verifies that ListMultipartUploads
// returns the Initiated timestamp for each in-progress upload, and that
// the timestamp is preserved across repeated listings rather than
// reflecting the time of the listing.
func TestListMultipartUploadsInitiated(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
cluster, err := startMiniCluster(t)
require.NoError(t, err)
defer cluster.Stop()
bucket := createTestBucket(t, cluster, "test-list-mpu-initiated-")
beforeInit := time.Now().UTC()
createOut, err := cluster.s3Client.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
Bucket: aws.String(bucket),
Key: aws.String("unfinished.bin"),
})
require.NoError(t, err)
afterInit := time.Now().UTC()
listOut, err := cluster.s3Client.ListMultipartUploads(&s3.ListMultipartUploadsInput{
Bucket: aws.String(bucket),
})
require.NoError(t, err)
require.Len(t, listOut.Uploads, 1)
upload := listOut.Uploads[0]
assert.Equal(t, "unfinished.bin", aws.StringValue(upload.Key))
assert.Equal(t, aws.StringValue(createOut.UploadId), aws.StringValue(upload.UploadId))
require.NotNil(t, upload.Initiated, "Initiated timestamp must be populated")
initiated := upload.Initiated.UTC()
assert.False(t, initiated.Before(beforeInit.Add(-time.Second)), "Initiated %v is before upload creation %v", initiated, beforeInit)
assert.False(t, initiated.After(afterInit.Add(time.Second)), "Initiated %v is after upload creation %v", initiated, afterInit)
time.Sleep(2 * time.Second)
listOut2, err := cluster.s3Client.ListMultipartUploads(&s3.ListMultipartUploadsInput{
Bucket: aws.String(bucket),
})
require.NoError(t, err)
require.Len(t, listOut2.Uploads, 1)
require.NotNil(t, listOut2.Uploads[0].Initiated, "Initiated timestamp must be populated on repeated listing")
assert.True(t, listOut2.Uploads[0].Initiated.Equal(*upload.Initiated), "Initiated must be preserved across listings, got %v then %v", *upload.Initiated, *listOut2.Uploads[0].Initiated)
_, err = cluster.s3Client.AbortMultipartUpload(&s3.AbortMultipartUploadInput{
Bucket: aws.String(bucket),
Key: aws.String("unfinished.bin"),
UploadId: createOut.UploadId,
})
require.NoError(t, err)
}
-2
View File
@@ -128,8 +128,6 @@ start-primary: check-deps
-s3.allowDeleteBucketNotEmpty=true \
-s3.config=s3_config.json \
-volume.allowUntrustedRemoteEndpoints \
-filer.allowUntrustedRemoteEndpoints \
-s3.allowUntrustedRemoteEndpoints \
-dir=$(PRIMARY_DIR) \
-ip=127.0.0.1 \
-ip.bind=127.0.0.1 \
+2 -7
View File
@@ -42,7 +42,7 @@ This tests the full remote caching workflow including singleflight deduplication
| Test File | Commands Tested | Test Count | Description |
|-----------|----------------|------------|-------------|
| `remote_cache_test.go` | Basic caching | 6 tests | Original caching workflow and singleflight tests, plus a mount with `-cacheWait=0` reading straight from the remote |
| `remote_cache_test.go` | Basic caching | 5 tests | Original caching workflow and singleflight tests |
| `remote_cache_copy_test.go` | S3 CopyObject / UploadPartCopy from a remote-only source | 2 tests | Source object lives only in remote storage; CopyObject and UploadPartCopy must cache it locally before persisting the destination so the result is readable |
| `command_remote_configure_test.go` | `remote.configure` | 6 tests | Configuration management |
| `command_remote_mount_test.go` | `remote.mount`, `remote.unmount`, `remote.mount.buckets` | 10 tests | Mount operations |
@@ -51,7 +51,7 @@ This tests the full remote caching workflow including singleflight deduplication
| `command_remote_meta_sync_test.go` | `remote.meta.sync` | 8 tests | Metadata synchronization |
| `command_edge_cases_test.go` | All commands | 11 tests | Edge cases and stress tests |
**Total: 68 test cases covering 8 weed shell commands and the S3 copy paths for remote-only sources**
**Total: 67 test cases covering 8 weed shell commands and the S3 copy paths for remote-only sources**
### Commands Tested
@@ -223,11 +223,6 @@ Tests HTTP range requests work correctly after caching.
### TestRemoteCacheNotFound
Tests proper error handling for non-existent objects.
### TestRemoteCacheWaitZero
Remounts with `remote.mount -cacheWait=0` and checks that a read of a remote-only
object is served from the remote and leaves the object uncached, while the default
size-based wait caches it.
## Troubleshooting
### View logs
-63
View File
@@ -40,9 +40,6 @@ const (
// Bucket name - mounted on primary as remote storage
testBucket = "remotemounted"
// Bucket name on the remote that testBucket is mounted on
remoteBucket = "remotesourcebucket"
// Path to weed binary
weedBinary = "../../../weed/weed_binary"
)
@@ -391,66 +388,6 @@ func TestRemoteCacheRangeRequest(t *testing.T) {
t.Log("Range request test passed")
}
// mountRemote remounts the test bucket, keeping the metadata already pulled,
// with the given extra remote.mount flags.
func mountRemote(t *testing.T, extraFlags string) {
cmd := fmt.Sprintf("remote.mount -dir=/buckets/%s -remote=seaweedremote/%s -nonempty -metadataStrategy=lazy %s", testBucket, remoteBucket, extraFlags)
output, err := runWeedShell(t, cmd)
require.NoErrorf(t, err, "remount failed: %s", output)
time.Sleep(time.Second)
}
// copyLocalToRemote pushes matching local objects to the remote, which is what
// lets remote.uncache drop their local chunks afterwards.
func copyLocalToRemote(t *testing.T, pattern string) {
output, err := runWeedShell(t, fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=%s", testBucket, pattern))
require.NoErrorf(t, err, "remote.copy.local failed: %s", output)
}
// localChunkCount reports how many local chunks an entry has, so a test can
// tell a cached read from one served straight out of the remote.
func localChunkCount(t *testing.T, key string) string {
meta, err := runWeedShell(t, fmt.Sprintf("fs.meta.cat /buckets/%s/%s", testBucket, key))
require.NoError(t, err)
// fs.meta.cat closes with "chunks N meta size: ..."
summary := strings.LastIndex(meta, "chunks ")
require.GreaterOrEqualf(t, summary, 0, "no chunk count in %s", meta)
return strings.Fields(meta[summary+len("chunks "):])[0]
}
// TestRemoteCacheWaitZero tests that a mount with -cacheWait=0 serves a read of
// an uncached object from the remote without caching it locally.
func TestRemoteCacheWaitZero(t *testing.T) {
checkServersRunning(t)
testKey := fmt.Sprintf("test-cachewait-%d.bin", time.Now().UnixNano())
testData := make([]byte, 1024*1024)
for i := range testData {
testData[i] = byte(i % 256)
}
t.Log("Step 1: Writing 1MB object, pushing it to the remote and dropping the local chunks...")
uploadToPrimary(t, testKey, testData)
copyLocalToRemote(t, testKey)
uncacheLocal(t, testKey)
require.Equal(t, "0", localChunkCount(t, testKey), "the object must start out remote-only")
t.Log("Step 2: Remounting with -cacheWait=0...")
mountRemote(t, "-cacheWait=0")
defer mountRemote(t, "")
t.Log("Step 3: Reading the object (should stream from remote)...")
assert.Equal(t, testData, getFromPrimary(t, testKey), "data mismatch reading from remote")
assert.Equal(t, "0", localChunkCount(t, testKey), "the read must not cache chunks locally")
t.Log("Step 4: Reading with the size based wait (should cache locally)...")
mountRemote(t, "")
assert.Equal(t, testData, getFromPrimary(t, testKey), "data mismatch reading through the cache")
assert.NotEqual(t, "0", localChunkCount(t, testKey), "the read must cache chunks locally")
t.Log("Zero cache wait test passed")
}
// TestRemoteCacheNotFound tests that non-existent objects return proper errors
func TestRemoteCacheNotFound(t *testing.T) {
checkServersRunning(t)
@@ -1,285 +0,0 @@
package s3api
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/require"
)
type postPolicyUploadResult struct {
versionID string
etag string
}
func postPolicyUpload(ctx context.Context, client *s3.Client, bucket, key string, body []byte) (postPolicyUploadResult, error) {
return postPolicyUploadWithFields(ctx, client, bucket, key, body, nil)
}
func postPolicyUploadWithFields(ctx context.Context, client *s3.Client, bucket, key string, body []byte, extraFields map[string]string) (postPolicyUploadResult, error) {
presigner := s3.NewPresignClient(client, s3.WithPresignClientFromClientOptions(func(options *s3.Options) {
options.BaseEndpoint = aws.String(defaultConfig.Endpoint)
options.EndpointResolver = nil
options.UsePathStyle = true
}))
presigned, err := presigner.PresignPostObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}, func(options *s3.PresignPostOptions) {
options.Expires = time.Hour
for name, value := range extraFields {
options.Conditions = append(options.Conditions, map[string]string{name: value})
}
})
if err != nil {
return postPolicyUploadResult{}, err
}
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
for name, value := range presigned.Values {
if err := writer.WriteField(name, value); err != nil {
return postPolicyUploadResult{}, err
}
}
for name, value := range extraFields {
if err := writer.WriteField(name, value); err != nil {
return postPolicyUploadResult{}, err
}
}
file, err := writer.CreateFormFile("file", "payload.bin")
if err != nil {
return postPolicyUploadResult{}, err
}
if _, err := file.Write(body); err != nil {
return postPolicyUploadResult{}, err
}
if err := writer.Close(); err != nil {
return postPolicyUploadResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, presigned.URL, &requestBody)
if err != nil {
return postPolicyUploadResult{}, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return postPolicyUploadResult{}, err
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return postPolicyUploadResult{}, readErr
}
if resp.StatusCode != http.StatusNoContent {
return postPolicyUploadResult{}, fmt.Errorf("POST Object returned %s: %s", resp.Status, responseBody)
}
return postPolicyUploadResult{
versionID: resp.Header.Get("x-amz-version-id"),
etag: resp.Header.Get("ETag"),
}, nil
}
func TestPostPolicyPreservesVersionHistoryAcrossPutAndDelete(t *testing.T) {
ctx := context.Background()
client := getS3Client(t)
bucket := getNewBucketName()
key := "post-policy-history.bin"
createBucket(t, client, bucket)
defer deleteBucket(t, client, bucket)
putObject(t, client, bucket, key, "legacy-null")
enableVersioning(t, client, bucket)
postOne, err := postPolicyUpload(ctx, client, bucket, key, []byte("post-one"))
require.NoError(t, err)
require.NotEmpty(t, postOne.versionID)
require.NotEmpty(t, postOne.etag)
putTwo := putObject(t, client, bucket, key, "put-two")
require.NotNil(t, putTwo.VersionId)
require.NotEmpty(t, *putTwo.VersionId)
postThree, err := postPolicyUpload(ctx, client, bucket, key, []byte("post-three"))
require.NoError(t, err)
require.NotEmpty(t, postThree.versionID)
deleted, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
require.NoError(t, err)
require.True(t, aws.ToBool(deleted.DeleteMarker))
require.NotEmpty(t, aws.ToString(deleted.VersionId))
postFour, err := postPolicyUpload(ctx, client, bucket, key, []byte("post-four"))
require.NoError(t, err)
require.NotEmpty(t, postFour.versionID)
requireVersionBody(t, client, bucket, key, "null", []byte("legacy-null"), "legacy null version")
requireVersionBody(t, client, bucket, key, postOne.versionID, []byte("post-one"), "first POST version")
requireVersionBody(t, client, bucket, key, aws.ToString(putTwo.VersionId), []byte("put-two"), "interleaved PUT version")
requireVersionBody(t, client, bucket, key, postThree.versionID, []byte("post-three"), "second POST version")
requireVersionBody(t, client, bucket, key, postFour.versionID, []byte("post-four"), "POST after delete marker")
listed, err := client.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket),
Prefix: aws.String(key),
})
require.NoError(t, err)
require.Len(t, listed.Versions, 5)
require.Len(t, listed.DeleteMarkers, 1)
latest := 0
latestVersionID := ""
for _, version := range listed.Versions {
if aws.ToBool(version.IsLatest) {
latest++
latestVersionID = aws.ToString(version.VersionId)
}
}
require.Equal(t, 1, latest)
require.Equal(t, postFour.versionID, latestVersionID)
require.False(t, aws.ToBool(listed.DeleteMarkers[0].IsLatest))
}
func TestPostPolicyRejectsIncompleteObjectLockHeaders(t *testing.T) {
ctx := context.Background()
client := getS3Client(t)
bucket := getNewBucketName()
key := "post-policy-invalid-object-lock.bin"
createBucketWithObjectLock(t, client, bucket)
defer deleteBucket(t, client, bucket)
_, err := postPolicyUploadWithFields(ctx, client, bucket, key, []byte("rejected"), map[string]string{
"x-amz-object-lock-mode": "GOVERNANCE",
})
require.ErrorContains(t, err, "400 Bad Request")
require.ErrorContains(t, err, "<Code>InvalidRequest</Code>")
listed, listErr := client.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket),
Prefix: aws.String(key),
})
require.NoError(t, listErr)
require.Empty(t, listed.Versions)
}
func TestPostPolicyVersioningStateCompatibility(t *testing.T) {
ctx := context.Background()
client := getS3Client(t)
bucket := getNewBucketName()
key := "post-policy-state.bin"
createBucket(t, client, bucket)
defer deleteBucket(t, client, bucket)
unconfigured, err := postPolicyUpload(ctx, client, bucket, key, []byte("unconfigured"))
require.NoError(t, err)
require.Empty(t, unconfigured.versionID)
unconfigured, err = postPolicyUpload(ctx, client, bucket, key, []byte("unconfigured-two"))
require.NoError(t, err)
require.Empty(t, unconfigured.versionID)
requireVersionBody(t, client, bucket, key, "null", []byte("unconfigured-two"), "unconfigured POST replaces null version")
enableVersioning(t, client, bucket)
versioned := putObject(t, client, bucket, key, "numbered")
require.NotEmpty(t, aws.ToString(versioned.VersionId))
suspendVersioning(t, client, bucket)
suspended, err := postPolicyUpload(ctx, client, bucket, key, []byte("suspended-one"))
require.NoError(t, err)
require.Empty(t, suspended.versionID)
suspended, err = postPolicyUpload(ctx, client, bucket, key, []byte("suspended-two"))
require.NoError(t, err)
require.Empty(t, suspended.versionID)
requireVersionBody(t, client, bucket, key, "null", []byte("suspended-two"), "suspended POST replaces null version")
requireVersionBody(t, client, bucket, key, aws.ToString(versioned.VersionId), []byte("numbered"), "suspended POST preserves numbered version")
listed, err := client.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket),
Prefix: aws.String(key),
})
require.NoError(t, err)
require.Len(t, listed.Versions, 2)
}
func TestPostPolicyConcurrentWritesKeepEveryVersion(t *testing.T) {
const writeCount = 8
ctx := context.Background()
client := getS3Client(t)
bucket := getNewBucketName()
key := "post-policy-concurrent.bin"
createBucket(t, client, bucket)
defer deleteBucket(t, client, bucket)
enableVersioning(t, client, bucket)
type writeResult struct {
versionID string
body []byte
err error
}
start := make(chan struct{})
results := make(chan writeResult, writeCount)
var wg sync.WaitGroup
for i := 0; i < writeCount; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
<-start
body := []byte(fmt.Sprintf("concurrent-%d", i))
if i%2 == 0 {
post, err := postPolicyUpload(ctx, client, bucket, key, body)
results <- writeResult{versionID: post.versionID, body: body, err: err}
return
}
put, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: bytes.NewReader(body),
})
versionID := ""
if put != nil {
versionID = aws.ToString(put.VersionId)
}
results <- writeResult{versionID: versionID, body: body, err: err}
}()
}
close(start)
wg.Wait()
close(results)
seen := make(map[string]struct{}, writeCount)
for result := range results {
require.NoError(t, result.err)
require.NotEmpty(t, result.versionID)
_, duplicate := seen[result.versionID]
require.False(t, duplicate, "each successful write must receive a unique version ID")
seen[result.versionID] = struct{}{}
requireVersionBody(t, client, bucket, key, result.versionID, result.body, "concurrent version body")
}
listed, err := client.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{
Bucket: aws.String(bucket),
Prefix: aws.String(key),
})
require.NoError(t, err)
require.Len(t, listed.Versions, writeCount)
latest := 0
for _, version := range listed.Versions {
if aws.ToBool(version.IsLatest) {
latest++
}
}
require.Equal(t, 1, latest)
}
@@ -201,7 +201,6 @@ def main():
"uri": args.catalog_url,
"warehouse": args.warehouse,
"prefix": args.prefix,
"auth": {"type": "noop"},
"s3.anonymous": "true", # Disable AWS request signing for unauthenticated access
}
)
@@ -173,7 +173,6 @@ def main():
"uri": args.catalog_url,
"warehouse": args.warehouse,
"prefix": args.prefix,
"auth": {"type": "noop"},
"s3.access-key-id": args.access_key,
"s3.secret-access-key": args.secret_key,
}
@@ -99,7 +99,6 @@ def connect(args):
"uri": args.catalog_url,
"warehouse": f"s3://{args.bucket}/",
"prefix": args.bucket,
"auth": {"type": "noop"},
"s3.endpoint": args.s3_endpoint,
"s3.access-key-id": args.access_key,
"s3.secret-access-key": args.secret_key,
@@ -179,9 +179,7 @@ func TestAllocateDuplicateAndMountUnmountMissingVariants(t *testing.T) {
}
}
// Evacuating a server in maintenance mode ends each move by deleting the source
// volume, so VolumeDelete must stay available in that mode (issue #11066).
func TestMaintenanceModeAllowsVolumeDelete(t *testing.T) {
func TestMaintenanceModeRejectsVolumeDelete(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
@@ -196,14 +194,22 @@ func TestMaintenanceModeAllowsVolumeDelete(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
framework.EnableMaintenanceMode(t, ctx, client)
if _, err := client.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{VolumeId: volumeID, OnlyEmpty: true}); err != nil {
t.Fatalf("VolumeDelete should succeed in maintenance mode, got: %v", err)
stateResp, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = client.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
_, err := client.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
_, err = client.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{VolumeId: volumeID, OnlyEmpty: true})
if err == nil {
t.Fatalf("VolumeStatus should fail once the volume is deleted")
t.Fatalf("VolumeDelete should fail when maintenance mode is enabled")
}
if !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("expected maintenance mode error, got: %v", err)
}
}
@@ -117,27 +117,19 @@ func TestVolumeMarkReadonlyWritableErrorPaths(t *testing.T) {
// enter maintenance mode
framework.EnableMaintenanceMode(t, ctx, grpcClient)
// marking readonly only restricts the server further and is the first step
// of evacuating it, so maintenance mode lets it through (issue #11066)
// existing volume in maintenance mode should return "maintenance mode" error
_, err = grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: volumeID, Persist: true})
if err != nil {
t.Fatalf("VolumeMarkReadonly should succeed in maintenance mode, got: %v", err)
}
statusResp, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeStatus after readonly in maintenance failed: %v", err)
}
if !statusResp.GetIsReadOnly() {
t.Fatalf("VolumeStatus expected readonly=true after VolumeMarkReadonly in maintenance mode")
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeMarkReadonly maintenance error mismatch: %v", err)
}
// reopening a volume for writes is still refused on a read-only server
_, err = grpcClient.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{VolumeId: volumeID})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeMarkWritable maintenance error mismatch: %v", err)
}
// non-existent volume in maintenance mode should still return "not found"
// (volume lookup happens before maintenance check)
_, err = grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: 98773, Persist: true})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeMarkReadonly missing-volume in maintenance error mismatch: %v", err)

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