mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-17 20:15:53 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
379e292189 |
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -12,6 +12,33 @@
|
||||
|
||||

|
||||
|
||||
<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
|
||||
[](https://www.nodion.com)
|
||||
[](https://www.piknik.com)
|
||||
[](https://www.keepsec.ca)
|
||||
[](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
|
||||
[](https://www.nodion.com)
|
||||
[](https://www.piknik.com)
|
||||
[](https://www.keepsec.ca)
|
||||
[](https://zyner.org)
|
||||
|
||||
[Back to TOC](#table-of-contents)
|
||||
|
||||
## Star History
|
||||
|
||||

|
||||
## Stargazers over time
|
||||
[](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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 |
Generated
+3
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")?);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -236,7 +236,6 @@ message VolumeIncrementalCopyResponse {
|
||||
|
||||
message VolumeMountRequest {
|
||||
uint32 volume_id = 1;
|
||||
optional string collection = 2;
|
||||
}
|
||||
message VolumeMountResponse {
|
||||
}
|
||||
|
||||
+81
-108
@@ -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,6 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod images;
|
||||
pub mod malloc_tuning;
|
||||
pub mod metrics;
|
||||
pub mod remote_storage;
|
||||
pub mod security;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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@").
|
||||
|
||||
@@ -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>(())
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 ¤t_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, ¤t);
|
||||
let (new_ec_shards, deleted_ec_shards) =
|
||||
diff_ec_shard_delta_messages(&previous, ¤t);
|
||||
|
||||
assert_eq!(new_ec_shards.len(), 1);
|
||||
assert!(deleted_ec_shards.is_empty());
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()`.
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¬e_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(¬e_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(¬e_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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
+306
-1073
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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-2
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,5 @@ pub mod dataset;
|
||||
pub mod jobs;
|
||||
pub mod metrics;
|
||||
pub mod preview;
|
||||
pub mod tls;
|
||||
|
||||
pub use jobs::handlers;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user