Compare commits

..
Author SHA1 Message Date
Chris Lu 89e7753ed5 filer: drop unused constant in oversized-chunk test 2026-10-03 15:21:22 +08:00
Chris Lu 495e24a476 filer: fall back to range fetch when a chunk exceeds the reader budget
A ciphered or compressed chunk larger than readerCacheSizeMB can never
be read through the whole-chunk path — the budget rejects the buffer —
so its partial views must still range-fetch or the GET fails outright.
2026-10-03 14:59:36 +08:00
Chris Lu 1f15e7865a filer: keep compressed chunks on the whole-chunk path
Like ciphered chunks, a range request on a compressed chunk makes the
volume server read and decompress the whole needle, so range-per-buffer
would repeat the full backend read for each 256KiB window. Route them
through the shared whole-chunk path via ChunkView.CanRangeFetch.
2026-10-03 12:34:55 +08:00
Chris Lu 521a19c567 filer: keep ciphered chunks on the whole-chunk path
A range fetch cannot save bytes for a ciphered chunk: readEncryptedUrl
always downloads and decrypts the whole blob before slicing. Sending
partial views of ciphered chunks through fetchChunkRange would repeat the
full download per buffer, so they keep the shared whole-chunk path where
one download serves every buffer. Prefetch stays enabled for them for
the same reason.
2026-10-03 09:38:40 +08:00
Chris Lu dd75add62f filer: fetch only the part of a chunk the view covers
Replaces the PinRandomMode size heuristic with a per-chunk coverage rule.
ViewFromVisibleIntervals already clips chunk views to the request window,
so a view that is not IsFullChunk() is one the request only partially
needs; fetch it as a range regardless of the detected read pattern.

This closes the holes a request-size pin left open: ranges larger than
SeqTolerance no longer revert to whole-chunk downloads once their buffers
look sequential, and ranges that fully cover a chunk keep the shared
whole-chunk path instead of fetching 256KiB slices piecemeal. Prefetch
(MaybeCache) skips clipped views so it cannot amplify a range read either.

PinRandomMode is dropped: no caller needs it once coverage drives the
fetch choice. Range fetches route through fetchChunkDataFn so tests
observe them the same way as whole-chunk downloads.
2026-10-03 09:36:59 +08:00
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 58aa9acfb0 s3: pin small ranged GETs to range reads
A ranged GET whose first read lands within SeqTolerance of offset 0 is
judged sequential immediately, and even a far-starting range could flip
back mid-request; either way readChunkSliceAt downloads each covered
chunk in full, multiplying disk reads for small ranged reads (measured
~7x). Pin random mode for ranged requests no larger than SeqTolerance so
all of the request's buffer reads stay range fetches. Larger ranges keep
the dynamic pattern, where whole-chunk fetches amortize.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-02 09:33:33 -07:00
Chris LuandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 8227ae3f49 filer: keep a ranged read in random mode through its contiguous tail
A far ReadAt on a fresh ReaderPattern left the sequential counter at -1,
so the next buffer of the same ranged request landed on the frontier and
flipped the verdict straight back to sequential — readChunkSliceAt then
paid a whole-chunk fetch for the remainder of the range. Drop the
counter to -ModeChangeLimit when random mode is entered so the verdict
needs sustained sequential evidence to undo, matching the hysteresis an
established sequential stream already gets.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-02 09:32:40 -07:00
602 changed files with 6982 additions and 56535 deletions
-2
View File
@@ -10,5 +10,3 @@ updates:
directory: "/"
schedule:
interval: weekly
ignore:
- dependency-name: "github.com/seaweedfs/goexif"
-240
View File
@@ -1,240 +0,0 @@
#!/usr/bin/env python3
"""Check JWT extraction and, with --context, real Helm upgrade persistence."""
import argparse
import base64
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import uuid
try:
import tomllib
except ModuleNotFoundError: # CI also exercises Python 3.10.
import tomli as tomllib
ROOT = Path(__file__).resolve().parents[2]
CHART = ROOT / "k8s/charts/seaweedfs"
SECTIONS = ("jwt.signing", "jwt.signing.read", "jwt.filer_signing", "jwt.filer_signing.read")
KEYS = {section: f"active-{index}" for index, section in enumerate(SECTIONS)}
ESCAPED_HEADER = '["jw\\u0074".signing]\nkey = "existing"'
def run(*args):
return subprocess.run(args, check=True, text=True, capture_output=True).stdout
def keys(raw):
document = tomllib.loads(raw)
result = {}
for section in SECTIONS:
table = document
for part in section.split("."):
table = table.get(part, {})
if "key" in table:
result[section] = table["key"]
return result
def fixtures():
canonical = "\n".join(f'[{section}]\nkey = "{value}"' for section, value in KEYS.items())
yield "canonical / final line without newline", canonical
yield "commented stale keys", canonical.replace("key =", '# key = "stale"\nkey =')
yield "commented headers / mixed line endings", "\n".join(
f'# [{section}]\r\n# key = "stale"\r\n[{section}]\nkey = "{value}"'
for section, value in KEYS.items()
)
yield "indented headers and keys / trailing comments", "\n".join(
f' \t[ {section} ] \t# header [note]\n \tkey \t= \t"{value}" # key comment'
for section, value in KEYS.items()
)
yield "CRLF", canonical.replace("\n", "\r\n")
yield "quoted and spaced section names", "\n".join(
f'["{section.split(".")[0]}" . \'{section.split(".")[1]}\''
+ (f' . "{section.split(".")[2]}"' if section.count(".") == 2 else "")
+ f'] # original table\nkey = "{value}"'
for section, value in KEYS.items()
)
yield "unrelated quoted header before JWT keys", '["custom section"]\nkey = "other"\n' + canonical
yield "brackets in comments", canonical.replace("key =", "# consult [notes]\nkey =")
yield "quoted values and quoted key names", "\n".join((
'[jwt.signing]\n"key" = "brackets[inside]#value"',
"[jwt.signing.read]\n'key' = 'literal\\path[#value]'",
r'[jwt.filer_signing]' + '\n' + r'key = "escaped\"quote\\slash\u0041"',
'[jwt.filer_signing.read]\nkey = ""',
))
yield "absent keys and sections / unrelated key", '\n'.join((
'[jwt.signing]\nexpires_after_seconds = 10',
'[jwt.signing.read]\nkey = "read-only"',
'[unrelated]\nkey = "not-a-jwt-key"',
'# [jwt.filer_signing]\n# key = "not-active"',
))
yield "no existing security.toml", ""
def check_generated(value):
decoded = base64.b64decode(value, validate=True).decode("ascii")
assert len(decoded) == 10 and decoded.isascii() and decoded.isalnum(), "invalid generated JWT key"
def check_helpers(helm, reference_helm):
# Exercise the real helper, not a second implementation of its matching rules.
with tempfile.TemporaryDirectory(prefix="helm-jwt-helper-") as directory:
chart = Path(directory)
(chart / "templates").mkdir()
(chart / "Chart.yaml").write_text("apiVersion: v2\nname: jwt-regression\nversion: 0.0.0\n")
shutil.copyfile(CHART / "templates/shared/_helpers.tpl", chart / "templates/_helpers.tpl")
entries = [
json.dumps(section) + ': {{ include "seaweedfs.existingTomlKey" (list '
+ json.dumps(section) + ' .Values.raw) | toJson }}'
for section in SECTIONS
]
template = chart / "templates/keys.yaml"
prefix = '{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"keys"},"data":{'
helper_template = prefix + ",".join(entries) + "}}"
reference_entries = [
json.dumps(section) + ': {{ dig '
+ " ".join(json.dumps(part) for part in section.split("."))
+ ' "key" "__ABSENT__" (fromToml .Values.raw) | toJson }}'
for section in SECTIONS
]
reference_template = prefix + ",".join(reference_entries) + "}}"
def render(binary, source, raw):
template.write_text(source)
values = chart / "input.json"
values.write_text(json.dumps({"raw": raw}))
output = run(binary, "template", "keys", str(chart), "-f", str(values))
return json.loads(output[output.index("{"):])["data"]
for name, raw in fixtures():
expected = keys(raw)
tokens = render(helm, helper_template, raw)
actual = {section: tomllib.loads("key = " + token)["key"]
for section, token in tokens.items() if token != ""}
assert actual == expected, f"{name}: extracted keys differ from stored TOML"
if reference_helm:
reference = render(reference_helm, reference_template, raw)
reference = {section: value for section, value in reference.items() if value != "__ABSENT__"}
assert actual == reference, f"{name}: keys differ from fromToml/dig"
print(f"PASS helper: {name}")
# A present but unsupported value must not silently become a fresh key.
try:
render(helm, helper_template, '[jwt.signing]\nkey = """multi\nline"""')
except subprocess.CalledProcessError as error:
assert "refusing to replace an existing key" in error.stderr, error.stderr
else:
raise AssertionError("multiline existing key was silently accepted or replaced")
print("PASS helper: unsupported existing value fails without rotation")
try:
render(helm, helper_template, ESCAPED_HEADER)
except subprocess.CalledProcessError as error:
assert "unsupported quoted section header" in error.stderr, error.stderr
else:
raise AssertionError("unsupported quoted header silently rotated its key")
print("PASS helper: unsupported quoted header fails without rotation")
def check_upgrades(helm, context):
namespace = "jwt-key-persist-" + uuid.uuid4().hex[:8]
current = "jk-seaweedfs-security-config"
legacy = "seaweedfs-security-config"
kubectl = ["kubectl", "--context", context, "-n", namespace]
release_args = ["jk", str(CHART), "--kube-context", context, "-n", namespace]
# No workload is needed to exercise Helm's real ConfigMap lookup and update.
for setting in (
"master.enabled=false", "volume.enabled=false", "filer.enabled=false",
"global.seaweedfs.createClusterRole=false",
"global.seaweedfs.securityConfig.jwtSigning.volumeWrite=true",
"global.seaweedfs.securityConfig.jwtSigning.volumeRead=true",
"global.seaweedfs.securityConfig.jwtSigning.filerWrite=true",
"global.seaweedfs.securityConfig.jwtSigning.filerRead=true",
):
release_args += ["--set", setting]
def stored():
cm = json.loads(run(*kubectl, "get", "configmap", current, "-o", "json"))
return keys(cm["data"]["security.toml"])
def upgrade():
run(helm, "upgrade", *release_args)
return stored()
def patch(raw):
# Seed previous-release content without taking Helm 4's SSA ownership.
run(*kubectl, "patch", "configmap", current, "--type=merge", "--field-manager=helm", "-p",
json.dumps({"data": {"security.toml": raw}}))
run(*kubectl, "create", "namespace", namespace)
try:
run(helm, "install", *release_args)
initial = stored()
assert set(initial) == set(SECTIONS), "install omitted a JWT section"
for value in initial.values():
check_generated(value)
assert upgrade() == initial, "no-op upgrade changed an existing key"
print("PASS upgrade: all four generated keys persist")
for name, raw in fixtures():
patch(raw)
actual = upgrade()
expected = keys(raw)
assert set(actual) == set(SECTIONS), f"{name}: upgrade omitted a JWT section"
for section in SECTIONS:
if section in expected:
assert actual[section] == expected[section], f"{name}: changed {section}"
else:
check_generated(actual[section])
assert upgrade() == actual, f"{name}: subsequent upgrade changed a key"
print(f"PASS upgrade: {name}")
# Migration from the old chart name, followed by precedence of the current name.
legacy_raw = "\n".join(f'[{section}]\nkey = "legacy-{index}"'
for index, section in enumerate(SECTIONS))
run(*kubectl, "create", "configmap", legacy, "--from-literal=security.toml=" + legacy_raw)
run(*kubectl, "delete", "configmap", current)
assert upgrade() == keys(legacy_raw), "legacy ConfigMap keys were not preserved"
print("PASS upgrade: legacy ConfigMap migration")
current_raw = next(fixtures())[1]
patch(current_raw)
assert upgrade() == keys(current_raw), "legacy ConfigMap overrode current ConfigMap"
print("PASS upgrade: current ConfigMap takes precedence")
patch(ESCAPED_HEADER)
try:
upgrade()
except subprocess.CalledProcessError as error:
assert "unsupported quoted section header" in error.stderr, error.stderr
else:
raise AssertionError("unsupported quoted header silently rotated its key")
assert keys(run(*kubectl, "get", "configmap", current, "-o",
"jsonpath={.data.security\\.toml}"))["jwt.signing"] == "existing"
print("PASS upgrade: unsupported quoted header leaves stored key untouched")
finally:
run(*kubectl, "delete", "namespace", namespace, "--wait=false")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--helm", default="helm")
parser.add_argument("--reference-helm", help="Helm >=3.17 binary for differential checks")
parser.add_argument("--context", help="explicit disposable Kubernetes context for live upgrade checks")
args = parser.parse_args()
print(run(args.helm, "version", "--short").strip())
check_helpers(args.helm, args.reference_helm)
if args.context:
check_upgrades(args.helm, args.context)
if __name__ == "__main__":
try:
main()
except subprocess.CalledProcessError as error:
print(error.stderr, file=sys.stderr)
sys.exit(error.returncode)
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.38.3
uses: github/codeql-action/init@v4.38.2
# 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.3
uses: github/codeql-action/autobuild@v4.38.2
# ℹ️ 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.3
uses: github/codeql-action/analyze@v4.38.2
+1 -1
View File
@@ -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.3
uses: github/codeql-action/upload-sarif@v4.38.2
if: always()
with:
sarif_file: trivy-results.sarif
@@ -460,7 +460,7 @@ jobs:
- name: Upload Trivy scan results to GitHub Security
if: always()
uses: github/codeql-action/upload-sarif@v4.38.3
uses: github/codeql-action/upload-sarif@v4.38.2
with:
sarif_file: trivy-results.sarif
category: trivy-${{ matrix.variant }}
-18
View File
@@ -148,21 +148,3 @@ jobs:
# -short skips the e2e suites already covered on amd64.
- name: Test linux/386
run: cd weed; GOOS=linux GOARCH=386 go test -short ./...
build-azurekms:
name: Build and test with azurekms tag
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: 'go.mod'
# weed/kms/azure is excluded from every default build, so without this
# nothing compiles it and tag-gated code silently rots.
- name: Build and test with azurekms
run: |
cd weed
go build -tags azurekms ./...
go test -tags azurekms ./kms/...
+5 -353
View File
@@ -3,10 +3,10 @@ name: "helm: lint and test charts"
on:
push:
branches: [ master ]
paths: ['k8s/**', '.github/workflows/helm_ci.yml', '.github/scripts/helm_jwt_keys.py']
paths: ['k8s/**', '.github/workflows/helm_ci.yml']
pull_request:
branches: [ master ]
paths: ['k8s/**', '.github/workflows/helm_ci.yml', '.github/scripts/helm_jwt_keys.py']
paths: ['k8s/**', '.github/workflows/helm_ci.yml']
permissions:
contents: read
@@ -20,14 +20,6 @@ jobs:
with:
fetch-depth: 0
- name: Set up Helm before fromToml was available
uses: azure/setup-helm@v5
with:
version: v3.16.3
- name: Record legacy Helm binary
run: echo "HELM_LEGACY=$(command -v helm)" >> "$GITHUB_ENV"
- name: Set up Helm
uses: azure/setup-helm@v5
with:
@@ -39,7 +31,7 @@ jobs:
check-latest: true
- name: Set up chart-testing
uses: helm/chart-testing-action@v2.9.0
uses: helm/chart-testing-action@v2.8.0
- name: Run chart-testing (list-changed)
id: list-changed
@@ -52,17 +44,6 @@ jobs:
- name: Run chart-testing (lint)
run: ct lint --target-branch ${{ github.event.repository.default_branch }} --all --validate-maintainers=false --chart-dirs k8s/charts
- name: Verify legacy Helm rendering
run: |
"$HELM_LEGACY" lint k8s/charts/seaweedfs
"$HELM_LEGACY" template test k8s/charts/seaweedfs > "$RUNNER_TEMP/legacy-default.yaml"
"$HELM_LEGACY" template test k8s/charts/seaweedfs \
--set global.seaweedfs.securityConfig.jwtSigning.volumeRead=true \
--set global.seaweedfs.securityConfig.jwtSigning.filerWrite=true \
--set global.seaweedfs.securityConfig.jwtSigning.filerRead=true \
> "$RUNNER_TEMP/legacy-jwt.yaml"
- name: Verify template rendering
run: |
set -e
@@ -76,60 +57,7 @@ jobs:
helm template test $CHART_DIR --set s3.enabled=true > /tmp/s3.yaml
grep -q "kind: Deployment" /tmp/s3.yaml && grep -q "seaweedfs-s3" /tmp/s3.yaml
echo "S3 deployment renders correctly"
echo "=== Testing S3 rollout settings ==="
helm template test $CHART_DIR --show-only templates/s3/s3-deployment.yaml \
--set s3.enabled=true > /tmp/s3-rollout-defaults.yaml
grep -q "terminationGracePeriodSeconds: 10$" /tmp/s3-rollout-defaults.yaml
test "$(grep -cE '^ strategy:|^ +lifecycle:' /tmp/s3-rollout-defaults.yaml)" -eq 0
helm template test $CHART_DIR --show-only templates/s3/s3-deployment.yaml \
--set s3.enabled=true \
--set s3.terminationGracePeriodSeconds=30 \
--set s3.updateStrategy.type=RollingUpdate \
--set s3.updateStrategy.rollingUpdate.maxUnavailable=0 \
--set-string 's3.lifecycle.preStop.exec.command={sleep,5}' \
> /tmp/s3-rollout.yaml
grep -q "terminationGracePeriodSeconds: 30$" /tmp/s3-rollout.yaml
grep -A 4 "^ strategy:" /tmp/s3-rollout.yaml | grep -q "maxUnavailable: 0"
grep -A 4 "^ strategy:" /tmp/s3-rollout.yaml | grep -q "type: RollingUpdate"
grep -A 5 "^ lifecycle:" /tmp/s3-rollout.yaml | grep -q -- "- sleep"
echo "S3 rollout settings render correctly"
echo "=== Testing the S3 config checksum is stable across renders ==="
# s3-secret.yaml keeps its generated keys with lookup. Hashing the rendered
# Secret drew new keys for the checksum whenever lookup found nothing (on
# install), so the first upgrade changed the checksum and restarted the
# filer and S3 pods although nothing had changed.
s3_checksum() { # <template> <helm args...>: the checksum/s3config of one workload
local template=$1; shift
local sum
sum=$(helm template test $CHART_DIR --show-only "$template" "$@" \
| grep 'checksum/s3config:' | awk '{print $2}')
test -n "$sum" || { echo "no checksum/s3config in $template" >&2; return 1; }
echo "$sum"
}
s3_auth_args=(--set s3.enabled=true --set s3.enableAuth=true)
s3_sum=$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}")
filer_sum=$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}")
test "$s3_sum" = "$filer_sum" # one Secret, one checksum
test "$s3_sum" = "$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}")"
s3_creds_sum=$(s3_checksum templates/s3/s3-deployment.yaml "${s3_auth_args[@]}" \
--set s3.credentials.admin.accessKey=a --set s3.credentials.admin.secretKey=b)
test "$s3_sum" != "$s3_creds_sum"
filer_audit_sum=$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}" \
--set-json 'filer.s3.auditLogConfig={"fluent_port":24224}')
test "$filer_sum" != "$filer_audit_sum"
# Settings the Secret does not read must not restart the S3 and filer pods.
test "$filer_sum" = "$(s3_checksum templates/filer/filer-statefulset.yaml "${s3_auth_args[@]}" \
--set s3.replicas=3 --set s3.resources.limits.cpu=1)"
all_in_one_args=(--set allInOne.enabled=true --set allInOne.s3.enabled=true --set allInOne.s3.enableAuth=true)
aio_sum=$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}")
test "$aio_sum" = "$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}")"
aio_creds_sum=$(s3_checksum templates/all-in-one/all-in-one-deployment.yaml "${all_in_one_args[@]}" \
--set s3.credentials.admin.accessKey=a --set s3.credentials.admin.secretKey=b)
test "$aio_sum" != "$aio_creds_sum"
echo "S3 config checksum is stable and follows only what the S3 Secret reads"
echo "=== Testing S3 credentials from an existing secret ==="
credential_args=(
--set s3.credentials.admin.existingSecret=minio-root
@@ -168,23 +96,6 @@ jobs:
grep -A 12 "^kind: Ingress" /tmp/s3-ingress-labels.yaml | grep -q "^ external-dns: s3"
echo "S3 ingress renders custom labels"
echo "=== Testing HTTPRoute ==="
helm template test $CHART_DIR --show-only templates/s3/s3-httproute.yaml \
--set s3.enabled=true,s3.httpRoute.enabled=true \
--set 's3.httpRoute.parentRefs[0].name=gateway' > /tmp/s3-httproute.yaml
grep -q "^kind: HTTPRoute" /tmp/s3-httproute.yaml
grep -A 4 -- '- group: ""' /tmp/s3-httproute.yaml | grep -q "name: test-seaweedfs-s3$"
grep -A 4 -- '- group: ""' /tmp/s3-httproute.yaml | grep -q "port: 8333$"
echo "S3 HTTPRoute routes to the s3 service by default"
helm template test $CHART_DIR --show-only templates/s3/s3-httproute.yaml \
--set filer.s3.enabled=true,filer.s3.port=8334,s3.httpRoute.enabled=true > /tmp/filer-s3-httproute.yaml
grep -A 4 -- '- group: ""' /tmp/filer-s3-httproute.yaml | grep -q "port: 8334$"
echo "S3 on filer HTTPRoute uses filer.s3.port"
helm template test $CHART_DIR --show-only templates/master/master-httproute.yaml \
--set allInOne.enabled=true,master.enabled=false,master.httpRoute.enabled=true > /tmp/allinone-master-httproute.yaml
grep -A 4 -- '- group: ""' /tmp/allinone-master-httproute.yaml | grep -q "name: test-seaweedfs-all-in-one$"
echo "All-in-one master HTTPRoute routes to the all-in-one service"
echo "=== Testing with all-in-one mode ==="
helm template test $CHART_DIR --set allInOne.enabled=true > /tmp/allinone.yaml
grep -q "seaweedfs-all-in-one" /tmp/allinone.yaml
@@ -205,21 +116,6 @@ jobs:
grep -q "security-config" /tmp/security.yaml
echo "Security configuration renders correctly"
echo "=== Testing secure bucket-creation hook certificate mounts ==="
helm template test $CHART_DIR \
--show-only templates/shared/post-install-bucket-hook.yaml \
--set s3.enabled=true \
--set 's3.createBuckets[0].name=data' \
--set global.seaweedfs.enableSecurity=true \
> /tmp/security-bucket-hook.yaml
test "$(grep -cE '^[[:space:]]*- name: ca-cert$' /tmp/security-bucket-hook.yaml)" -eq 2
test "$(grep -cE '^[[:space:]]*- name: client-cert$' /tmp/security-bucket-hook.yaml)" -eq 2
grep -q 'mountPath: /usr/local/share/ca-certificates/ca/' /tmp/security-bucket-hook.yaml
grep -q 'mountPath: /usr/local/share/ca-certificates/client/' /tmp/security-bucket-hook.yaml
grep -q 'secretName: test-seaweedfs-ca-cert' /tmp/security-bucket-hook.yaml
grep -q 'secretName: test-seaweedfs-client-cert' /tmp/security-bucket-hook.yaml
echo "Secure bucket-creation hook mounts its CA and client certificate"
echo ""
echo "=== Testing admin.allowInsecureBind satisfies the admin auth render guard ==="
helm template test $CHART_DIR --set admin.enabled=true --set admin.allowInsecureBind=true \
@@ -822,168 +718,6 @@ jobs:
helm template test $CHART_DIR --set cosi.enabled=true > /tmp/cosi.yaml
grep -q "seaweedfs-cosi" /tmp/cosi.yaml
echo "COSI driver renders correctly"
echo ""
echo "=== Testing configurable pod and container security contexts ==="
helm template test $CHART_DIR > /tmp/security-context-defaults.yaml
security_context_args=()
for component in master volume filer s3 sftp admin worker allInOne cosi; do
security_context_args+=(
--set "$component.podSecurityContext.enabled=true"
--set "$component.podSecurityContext.seccompProfile.type=RuntimeDefault"
--set "$component.containerSecurityContext.enabled=true"
--set "$component.containerSecurityContext.privileged=false"
--set "$component.containerSecurityContext.allowPrivilegeEscalation=false"
--set "$component.containerSecurityContext.readOnlyRootFilesystem=true"
--set "$component.containerSecurityContext.capabilities.drop[0]=ALL"
--set "$component.containerSecurityContext.seccompProfile.type=RuntimeDefault"
)
done
helm template test $CHART_DIR \
"${security_context_args[@]}" \
--set s3.enabled=true \
--set s3.createBuckets[0].name=test \
--set sftp.enabled=true \
--set admin.enabled=true \
--set admin.secret.adminPassword=ci-admin-password \
--set worker.enabled=true \
--set volume.idx.type=hostPath \
--set volume.idx.hostPathPrefix=/tmp \
--set cosi.enabled=true \
--set global.seaweedfs.tmpDir.sizeLimit=64Mi > /tmp/security-contexts.yaml
helm template test $CHART_DIR \
"${security_context_args[@]}" \
--set allInOne.enabled=true \
--set master.enabled=false \
--set volume.enabled=false \
--set filer.enabled=false \
--set global.seaweedfs.tmpDir.sizeLimit=64Mi > /tmp/security-contexts-aio.yaml
python3 - /tmp/security-context-defaults.yaml /tmp/security-contexts.yaml /tmp/security-contexts-aio.yaml <<'PYEOF'
import sys
import yaml
errors = []
workloads = 0
containers = 0
chart_managed_init_containers = 0
components = set()
def validate_container(workload_name, container):
context = container.get("securityContext", {})
prefix = f"{workload_name}/{container['name']}"
if "enabled" in context:
errors.append(f"{prefix}: internal enabled flag leaked into container securityContext")
if context.get("privileged") is not False:
errors.append(f"{prefix}: privileged is not false")
if context.get("allowPrivilegeEscalation") is not False:
errors.append(f"{prefix}: allowPrivilegeEscalation is not false")
if context.get("readOnlyRootFilesystem") is not True:
errors.append(f"{prefix}: readOnlyRootFilesystem is not true")
if context.get("capabilities", {}).get("drop") != ["ALL"]:
errors.append(f"{prefix}: capabilities.drop is not exactly [ALL]")
if context.get("seccompProfile", {}).get("type") != "RuntimeDefault":
errors.append(f"{prefix}: seccompProfile is not RuntimeDefault")
mounts = {mount["name"]: mount for mount in container.get("volumeMounts", [])}
if mounts.get("seaweedfs-tmp", {}).get("mountPath") != "/tmp":
errors.append(f"{prefix}: writable temporary volume is not mounted at /tmp")
with open(sys.argv[1]) as stream:
default_documents = [document for document in yaml.safe_load_all(stream) if document]
for document in default_documents:
if document.get("kind") not in ("Deployment", "StatefulSet", "Job"):
continue
name = document["metadata"]["name"]
pod = document["spec"]["template"]["spec"]
if "securityContext" in pod:
errors.append(f"{name}: pod securityContext should be absent by default")
if any(volume["name"] == "seaweedfs-tmp" for volume in pod.get("volumes", [])):
errors.append(f"{name}: writable /tmp volume should be absent by default")
for container in pod.get("containers", []):
if "securityContext" in container:
errors.append(
f"{name}/{container['name']}: container securityContext "
f"should be absent by default"
)
if any(
mount["name"] == "seaweedfs-tmp"
for mount in container.get("volumeMounts", [])
):
errors.append(
f"{name}/{container['name']}: writable /tmp mount "
f"should be absent by default"
)
for path in sys.argv[2:]:
with open(path) as stream:
documents = [document for document in yaml.safe_load_all(stream) if document]
for document in documents:
if document.get("kind") not in ("Deployment", "StatefulSet", "Job"):
continue
workloads += 1
name = document["metadata"]["name"]
pod = document["spec"]["template"]["spec"]
volumes = {volume["name"]: volume for volume in pod.get("volumes", [])}
temporary = volumes.get("seaweedfs-tmp", {}).get("emptyDir")
if temporary is None:
errors.append(f"{name}: writable /tmp emptyDir is missing")
elif temporary.get("sizeLimit") != "64Mi":
errors.append(f"{name}: temporary volume sizeLimit is not 64Mi")
component = document["spec"]["template"]["metadata"]["labels"].get("app.kubernetes.io/component")
if component:
components.add(component)
else:
errors.append(f"{name}: app.kubernetes.io/component label is missing")
pod_context = pod.get("securityContext", {})
if "enabled" in pod_context:
errors.append(f"{name}: internal enabled flag leaked into pod securityContext")
if pod_context.get("seccompProfile", {}).get("type") != "RuntimeDefault":
errors.append(f"{name}: pod seccompProfile is not RuntimeDefault")
for container in pod.get("containers", []):
containers += 1
validate_container(name, container)
for container in pod.get("initContainers", []):
if container["name"] != "seaweedfs-vol-move-idx":
continue
chart_managed_init_containers += 1
validate_container(name, container)
expected_components = {
"master", "volume", "filer", "s3", "sftp", "admin", "worker",
"objectstorage-provisioner", "bucket-hook", "seaweedfs-all-in-one",
}
if components != expected_components:
errors.append(
f"security context workload coverage is incomplete: "
f"expected {sorted(expected_components)}, got {sorted(components)}"
)
if workloads != 10 or containers != 12:
errors.append(
f"expected 10 workloads and 12 containers, got "
f"{workloads} workloads and {containers} containers"
)
if chart_managed_init_containers != 1:
errors.append(
f"expected one chart-managed seaweedfs-vol-move-idx init container, "
f"got {chart_managed_init_containers}"
)
if errors:
print("\n".join(f"FAIL: {error}" for error in errors), file=sys.stderr)
sys.exit(1)
print(f"Validated security contexts on {workloads} workloads and {containers} containers")
PYEOF
# The resize hook depends on lookup finding a live StatefulSet and
# therefore cannot render during helm template. Keep static coverage
# for both security-context blocks.
grep -Fqx ' securityContext: {{- omit .Values.volume.podSecurityContext "enabled" | toYaml | nindent 8 }}' \
"$CHART_DIR/templates/volume/volume-resize-hook.yaml"
grep -Fqx ' securityContext: {{- omit .Values.volume.containerSecurityContext "enabled" | toYaml | nindent 12 }}' \
"$CHART_DIR/templates/volume/volume-resize-hook.yaml"
echo "Volume resize hook security contexts are covered"
echo ""
echo "=== Testing long release name: service names match DNS references ==="
@@ -1869,79 +1603,7 @@ jobs:
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
- name: Create kind cluster
uses: helm/kind-action@v1.15.1
- name: Verify volume resize hook security contexts
run: |
set -e
CHART_DIR="k8s/charts/seaweedfs"
NS="resize-hook-security"
kubectl create namespace "$NS"
kubectl apply -n "$NS" -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data1-resize-seaweedfs-volume-0
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
EOF
helm template resize "$CHART_DIR" -n "$NS" --dry-run=server \
--set volume.dataDirs[0].name=data1 \
--set volume.dataDirs[0].type=persistentVolumeClaim \
--set volume.dataDirs[0].size=2Gi \
--set volume.podSecurityContext.enabled=true \
--set volume.podSecurityContext.seccompProfile.type=RuntimeDefault \
--set volume.containerSecurityContext.enabled=true \
--set volume.containerSecurityContext.privileged=false \
--set volume.containerSecurityContext.allowPrivilegeEscalation=false \
--set volume.containerSecurityContext.readOnlyRootFilesystem=true \
--set volume.containerSecurityContext.capabilities.drop[0]=ALL \
--set volume.containerSecurityContext.seccompProfile.type=RuntimeDefault \
--set global.seaweedfs.tmpDir.sizeLimit=64Mi \
> /tmp/security-context-resize-hook.yaml
python3 - /tmp/security-context-resize-hook.yaml <<'PYEOF'
import sys
import yaml
with open(sys.argv[1]) as stream:
documents = [document for document in yaml.safe_load_all(stream) if document]
jobs = [
document for document in documents
if document.get("kind") == "Job"
and document["metadata"]["name"].endswith("-volume-resize-hook")
]
if len(jobs) != 1:
raise AssertionError(f"expected one volume resize hook Job, got {len(jobs)}")
pod = jobs[0]["spec"]["template"]["spec"]
assert pod["securityContext"] == {
"seccompProfile": {"type": "RuntimeDefault"},
}
assert len(pod["containers"]) == 1
assert pod["containers"][0]["securityContext"] == {
"allowPrivilegeEscalation": False,
"capabilities": {"drop": ["ALL"]},
"privileged": False,
"readOnlyRootFilesystem": True,
"seccompProfile": {"type": "RuntimeDefault"},
}
assert pod["containers"][0]["volumeMounts"] == [
{"mountPath": "/tmp", "name": "seaweedfs-tmp"},
]
assert pod["volumes"] == [
{"emptyDir": {"sizeLimit": "64Mi"}, "name": "seaweedfs-tmp"},
]
PYEOF
kubectl delete namespace "$NS"
echo "Volume resize hook security contexts render correctly"
uses: helm/kind-action@v1.15.0
- name: Run chart-testing (install)
run: |
@@ -1993,16 +1655,6 @@ jobs:
kubectl delete namespace "$NS"
echo "SFTP host key lifecycle tests passed"
- name: Verify JWT signing key persistence across upgrades
run: |
# chart-testing puts its pip-less venv first on PATH; use setup-python.
PYTHON="$pythonLocation/bin/python3"
"$PYTHON" -m pip install tomli==2.2.1
CONTEXT=$(kubectl config current-context)
"$PYTHON" .github/scripts/helm_jwt_keys.py --context "$CONTEXT"
"$PYTHON" .github/scripts/helm_jwt_keys.py --helm "$HELM_LEGACY" \
--reference-helm helm --context "$CONTEXT"
- name: Verify install into a default-deny namespace
run: |
set -e
+2 -3
View File
@@ -139,8 +139,7 @@ jobs:
unit-tests:
name: Go Unit Tests (Implicit Directory)
runs-on: ubuntu-latest
# Leave time for setup and the focused run before the nine-minute suite.
timeout-minutes: 20
timeout-minutes: 10
steps:
- name: Checkout code
@@ -160,5 +159,5 @@ jobs:
- name: Run all S3 API tests
run: |
cd weed/s3api
go test -v -timeout 9m
go test -v -timeout 5m
-8
View File
@@ -289,10 +289,6 @@ jobs:
s3tests/functional/test_s3.py::test_object_write_check_etag \
s3tests/functional/test_s3.py::test_object_write_cache_control \
s3tests/functional/test_s3.py::test_object_write_expires \
s3tests/functional/test_s3.py::test_put_obj_enc_conflict_c_s3 \
s3tests/functional/test_s3.py::test_put_obj_enc_conflict_c_kms \
s3tests/functional/test_s3.py::test_put_obj_enc_conflict_s3_kms \
s3tests/functional/test_s3.py::test_put_obj_enc_conflict_bad_enc_kms \
s3tests/functional/test_s3.py::test_object_content_encoding_aws_chunked \
s3tests/functional/test_s3.py::test_object_write_read_update_read_delete \
s3tests/functional/test_s3.py::test_object_metadata_replaced_on_put \
@@ -1155,10 +1151,6 @@ jobs:
s3tests/functional/test_s3.py::test_object_write_check_etag \
s3tests/functional/test_s3.py::test_object_write_cache_control \
s3tests/functional/test_s3.py::test_object_write_expires \
s3tests/functional/test_s3.py::test_put_obj_enc_conflict_c_s3 \
s3tests/functional/test_s3.py::test_put_obj_enc_conflict_c_kms \
s3tests/functional/test_s3.py::test_put_obj_enc_conflict_s3_kms \
s3tests/functional/test_s3.py::test_put_obj_enc_conflict_bad_enc_kms \
s3tests/functional/test_s3.py::test_object_content_encoding_aws_chunked \
s3tests/functional/test_s3.py::test_object_write_read_update_read_delete \
s3tests/functional/test_s3.py::test_object_metadata_replaced_on_put \
-2
View File
@@ -82,8 +82,6 @@ AWS_ACCESS_KEY_ID=admin AWS_SECRET_ACCESS_KEY=secret \
The same process also runs the master, a volume server, the filer, WebDAV, the Iceberg REST catalog, and the Admin UI. Add `S3_TABLE_BUCKET=warehouse` to also create an Iceberg table bucket, or `warehouse:LANCE` for a Lance one. Drop the AWS keys to run without authentication for development.
Without Admin authentication or mTLS, `weed mini` binds the Admin UI/API and its worker gRPC control plane to loopback rather than `-ip.bind`. Set `WEED_ADMIN_PASSWORD` or configure `https.admin` mTLS to keep the network bind. Remote workers must opt in with `-admin.worker.ip=<address>` and should configure `grpc.admin` mTLS. `-admin.allowInsecureBind` restores the legacy unauthenticated network bind and should only be used on an isolated network.
> macOS: if the binary is quarantined, run `xattr -d com.apple.quarantine ./weed` first.
`weed mini` is auto-tuned for one node and is fine for single-node production, such as an S3 gateway that issues presigned URLs. See [Quick Start with weed mini][WeedMini].
-1
View File
@@ -169,7 +169,6 @@ seaweed-volume/
- `--memprofile`
- `--compactionMBps` (default 0)
- `--maintenanceMBps` (default 0)
- `--compactionMaxParallelCommits` (default 0)
- `--fileSizeLimitMB` (default 256)
- `--concurrentUploadLimitMB` (default 0)
- `--concurrentDownloadLimitMB` (default 0)
+66 -58
View File
@@ -23,8 +23,8 @@ require (
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
github.com/facebookgo/stats v0.0.0-20151006221625-1b76add642e4
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-redsync/redsync/v4 v4.18.0
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-zookeeper/zk v1.0.4 // indirect
github.com/golang/protobuf v1.5.4
@@ -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.20.1
github.com/klauspost/compress v1.19.2
github.com/klauspost/reedsolomon v1.14.2
github.com/kurin/blazer v0.5.3
github.com/linxGnu/grocksdb v1.10.8
@@ -60,11 +60,11 @@ require (
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.71.0 // indirect
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 v1.0.3
github.com/seaweedfs/goexif v2.0.0+incompatible
github.com/seaweedfs/raft v1.2.1
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/afero v1.15.0 // indirect
@@ -73,7 +73,7 @@ require (
github.com/stretchr/testify v1.12.1
github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965
github.com/tidwall/gjson v1.19.0
github.com/tidwall/gjson v1.18.0
github.com/tidwall/match v1.2.0
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tsuna/gohbase v0.0.0-20201125011725-348991136365
@@ -90,14 +90,14 @@ 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.58.0
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297
golang.org/x/crypto v0.57.0
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597
golang.org/x/image v0.46.0
golang.org/x/net v0.61.0
golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sys v0.49.0
golang.org/x/text v0.43.0 // indirect
golang.org/x/tools v0.51.0 // indirect
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/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.297.0
google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect
@@ -116,16 +116,16 @@ require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/Jille/raft-grpc-transport v1.6.1
github.com/ThreeDotsLabs/watermill v1.5.2
github.com/a-h/templ v0.3.1070
github.com/a-h/templ v0.3.1020
github.com/apache/cassandra-gocql-driver/v2 v2.1.2
github.com/apache/iceberg-go v0.7.0
github.com/apache/iceberg-go v0.6.1-0.20260817192109-c2105090c9e2
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.3
github.com/aws/aws-sdk-go-v2/config v1.33.6
github.com/aws/aws-sdk-go-v2/credentials v1.20.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.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/cespare/xxhash/v2 v2.3.0
github.com/cognusion/imaging v1.0.4
github.com/fluent/fluent-logger-golang v1.10.1
@@ -134,16 +134,16 @@ require (
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
github.com/hashicorp/raft v1.8.0
github.com/hashicorp/raft-boltdb/v2 v2.4.2
github.com/hashicorp/raft v1.7.3
github.com/hashicorp/raft-boltdb/v2 v2.3.1
github.com/hashicorp/vault/api v1.23.0
github.com/jhump/protoreflect v1.18.1
github.com/jhump/protoreflect v1.18.0
github.com/linkedin/goavro/v2 v2.15.0
github.com/minio/crc64nvme v1.1.1
github.com/orcaman/concurrent-map/v2 v2.0.1
github.com/parquet-go/parquet-go v0.32.0
github.com/pkg/sftp v1.13.11
github.com/rabbitmq/amqp091-go v1.15.0
github.com/rabbitmq/amqp091-go v1.14.0
github.com/rclone/rclone v1.75.1
github.com/rdleal/intervalst v1.5.0
github.com/redis/go-redis/v9 v9.22.0
@@ -156,11 +156,11 @@ require (
github.com/tikv/client-go/v2 v2.0.7
github.com/twmb/avro v1.9.0
github.com/xeipuuv/gojsonschema v1.2.0
github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.3
github.com/ydb-platform/ydb-go-sdk/v3 v3.155.1
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.12.0
golang.org/x/sync v0.24.0
golang.org/x/sync v0.23.0
golang.org/x/tools/godoc v0.1.0-deprecated
google.golang.org/grpc/security/advancedtls v1.0.0
)
@@ -168,22 +168,24 @@ require (
require github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect
require (
atomicgo.dev/cursor v0.2.0 // indirect
atomicgo.dev/keyboard v0.2.9 // indirect
atomicgo.dev/schedule v0.1.0 // indirect
cloud.google.com/go/longrunning v1.2.0 // indirect
cloud.google.com/go/pubsub/v2 v2.6.0 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/internal v0.1.0 // indirect
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.26.0 // 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.8.0 // 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.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // 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
@@ -198,6 +200,7 @@ require (
github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect
github.com/cockroachdb/redact v1.1.5 // indirect
github.com/cockroachdb/version v0.0.0-20250314144055-3860cd14adf2 // indirect
github.com/containerd/console v1.0.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
@@ -216,6 +219,7 @@ require (
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gookit/color v1.6.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
@@ -233,6 +237,7 @@ require (
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lib/pq v1.12.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lithammer/shortuuid/v3 v3.0.7 // indirect
github.com/lpar/calendar v0.2.0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
@@ -255,10 +260,12 @@ require (
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/pierrre/geohash v1.0.0 // indirect
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/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
github.com/sasha-s/go-deadlock v0.3.1 // indirect
github.com/smarty/assertions v1.15.0 // indirect
@@ -274,6 +281,7 @@ require (
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
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.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 // indirect
@@ -294,10 +302,10 @@ require (
cloud.google.com/go/iam v1.12.0 // indirect
cloud.google.com/go/monitoring v1.30.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.2
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.2
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect
@@ -314,26 +322,26 @@ require (
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/PuerkitoBio/goquery v1.12.0 // indirect
github.com/abbot/go-http-auth v0.4.0 // indirect
github.com/andybalholm/brotli v1.2.6 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
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.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // 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/feature/s3/manager v1.22.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // 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/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // 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/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.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1
github.com/aws/smithy-go v1.28.5
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.51.0
github.com/aws/smithy-go v1.28.1
github.com/boltdb/bolt v1.3.1 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/buengese/sgzip v0.1.1 // indirect
@@ -357,7 +365,7 @@ require (
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260819172001-e6e3fd93e4be // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
@@ -389,10 +397,10 @@ require (
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-metrics v0.7.0 // indirect
github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect
github.com/hashicorp/go-metrics v0.5.4 // indirect
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru v0.6.0 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
@@ -470,10 +478,10 @@ require (
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/winfsp/cgofuse v1.6.1-0.20260126094232-f2c4fccdb286
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20240819112322-98a264d392f6 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e // indirect
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260810122915-65bfd5c4b705 // indirect
github.com/ydb-platform/ydb-go-yc v0.12.5 // indirect
github.com/ydb-platform/ydb-go-yc-metadata v0.6.2 // indirect
github.com/ydb-platform/ydb-go-yc v0.12.1 // indirect
github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 // indirect
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
@@ -484,15 +492,15 @@ require (
go.opentelemetry.io/contrib/detectors/gcp v1.45.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
go.opentelemetry.io/otel v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/otel/sdk v1.46.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect
go.opentelemetry.io/otel/trace v1.46.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.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/term v0.47.0
golang.org/x/time v0.16.0
golang.org/x/term v0.46.0
golang.org/x/time v0.15.0
google.golang.org/genproto/googleapis/api v0.0.0-20260817212433-ac3dfec99bb1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
+1380 -130
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,4 +3,4 @@ description: SeaweedFS
name: seaweedfs
appVersion: "4.48"
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
version: 4.48.2
version: 4.48.0
-51
View File
@@ -27,19 +27,6 @@ so your deployment will be spread/HA.
* cert config exists and can be enabled, but not been tested, requires cert-manager to be installed.
## Prerequisites
The chart's templates render with Helm 3.16.3 and newer versions tested in CI.
Earlier Helm versions are not covered by this chart's compatibility checks.
When JWT signing is enabled, a live upgrade reuses keys from the existing
security ConfigMap (including the legacy ConfigMap name); a key absent from
that ConfigMap is generated. Plain `helm template` does not read cluster state;
use an install and upgrade against a cluster to check key persistence.
The key reader supports single-line quoted TOML strings in JWT sections,
including indented assignments, quoted key names, and bare or simply quoted
section-name segments. An unsupported value or quoted header fails the upgrade
rather than silently rotating a key.
### Database
leveldb is the default database, this supports multiple filer replicas that will [sync automatically](https://github.com/seaweedfs/seaweedfs/wiki/Filer-Store-Replication), with some [limitations](https://github.com/seaweedfs/seaweedfs/wiki/Filer-Store-Replication#limitation).
@@ -542,26 +529,6 @@ helm install seaweedfs-worker-vacuum seaweedfs/seaweedfs -f values-worker-vacuum
helm install seaweedfs-worker-balance seaweedfs/seaweedfs -f values-worker-balance.yaml
```
## Gateway API
Every component with an `ingress` block (master, volume, filer, s3 and admin) can also be exposed through a [Gateway API](https://gateway-api.sigs.k8s.io/) `HTTPRoute`, for clusters that route through a Gateway instead of an Ingress controller. `<component>.httpRoute` sits next to `<component>.ingress` and is disabled by default. The chart does not create the Gateway, so point `parentRefs` at one that already exists, and the `gateway.networking.k8s.io/v1` CRDs must be installed.
```yaml
s3:
httpRoute:
enabled: true
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: my-gateway
namespace: gateway-system
sectionName: https
hostnames:
- s3.example.com
```
With `rules` left empty the route sends all traffic to the component's own Service and port. A rule may set `matches`, `filters`, `timeouts` and `backendRefs`, and a rule without `backendRefs` still routes to that Service. In all-in-one mode the master, volume, filer and s3 routes target the all-in-one Service. When S3 runs only on the filer (`filer.s3.enabled` without `s3.enabled`), the s3 route uses `filer.s3.port`.
## Network Policies
In a namespace with a default-deny policy the install hangs: the components cannot resolve each other, and the post-install bucket hook waits on the master and filer until it gives up. `networkPolicy.enabled` renders one `NetworkPolicy` per component, selecting its pods by the standard `app.kubernetes.io/{name,instance,component}` labels and admitting traffic from the other pods of the release on the ports that component listens on.
@@ -603,23 +570,6 @@ Two things worth knowing before you turn this on:
The DNS selectors default to CoreDNS as kubeadm, kind and the managed offerings from AWS, Google and Azure install it. On OpenShift, override `egress.dnsNamespaceSelector` and `egress.dnsPodSelector` to match `openshift-dns`; see the comment in `values.yaml`.
## Pod and container security contexts
Pod and container security contexts are configurable independently for every built-in workload and remain empty by default for backwards compatibility. The examples in `values.yaml` show how to enable a `RuntimeDefault` seccomp profile, disable privilege escalation and privileged mode, drop all Linux capabilities, and use a read-only root filesystem.
SeaweedFS uses `/tmp` for Unix sockets, temporary uploads, worker task files, and other runtime data. When `readOnlyRootFilesystem` is enabled for a built-in component, the chart mounts a writable `emptyDir` at `/tmp` for its chart-managed containers. Its optional size limit can be configured globally:
```yaml
global:
seaweedfs:
tmpDir:
sizeLimit: 1Gi
```
The chart does not enable `runAsNonRoot` by default because its default `hostPath` storage may be owned by root. To enforce the Kubernetes `restricted` Pod Security Standard, use storage that is writable by a non-root user and configure `runAsNonRoot` or use the OpenShift overrides below.
Security contexts configured for a component also apply to the chart-managed helper containers for that component. User-provided init containers and sidecars must define their own container security context and writable mounts.
## OpenShift Support
SeaweedFS can be deployed on OpenShift or any cluster enforcing the Kubernetes "restricted" Pod Security Standard. By default, OpenShift blocks containers that run as root or use `hostPath` volumes.
@@ -628,7 +578,6 @@ To deploy on OpenShift, use the provided `openshift-values.yaml` which overrides
1. Use `PersistentVolumeClaims` instead of `hostPath`.
2. Enable `runAsNonRoot` and omit hardcoded UIDs to allow OpenShift to assign valid UIDs automatically.
3. Apply appropriate `seccompProfile` and drop capabilities.
4. Use a read-only root filesystem with writable temporary storage at `/tmp`.
Usage:
```bash
@@ -15,7 +15,6 @@
# automatically assign a valid UID from the namespace's allocated range.
# 3. Dropping all Linux capabilities and setting allowPrivilegeEscalation: false
# 4. Enabling RuntimeDefault seccompProfile
# 5. Using a read-only root filesystem with writable temporary storage
#
# Usage:
# helm install seaweedfs seaweedfs/seaweedfs \
@@ -50,7 +49,6 @@ master:
containerSecurityContext:
enabled: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
runAsNonRoot: true
@@ -78,7 +76,6 @@ volume:
containerSecurityContext:
enabled: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
runAsNonRoot: true
@@ -104,7 +101,6 @@ filer:
containerSecurityContext:
enabled: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
runAsNonRoot: true
@@ -129,7 +125,6 @@ s3:
containerSecurityContext:
enabled: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
runAsNonRoot: true
@@ -1,55 +0,0 @@
{{- if and .Values.admin.enabled .Values.admin.httpRoute.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "seaweedfs.componentName" (list . "admin") }}
namespace: {{ .Release.Namespace }}
{{- with .Values.admin.httpRoute.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: admin
{{- with .Values.admin.httpRoute.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.admin.httpRoute.parentRefs }}
parentRefs:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.admin.httpRoute.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.admin.httpRoute.rules | default (list dict) }}
- backendRefs:
{{- with .backendRefs }}
{{- toYaml . | nindent 8 }}
{{- else }}
- group: ""
kind: Service
name: {{ include "seaweedfs.componentName" (list $ "admin") }}
namespace: {{ $.Release.Namespace }}
port: {{ $.Values.admin.port }}
weight: 1
{{- end }}
{{- with .matches }}
matches:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .filters }}
filters:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .timeouts }}
timeouts:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- end }}
@@ -192,7 +192,6 @@ spec:
{{ $arg }}{{- if lt $index (sub (len $.Values.admin.extraArgs) 1) }} \{{ end }}
{{- end }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.admin.containerSecurityContext (tpl (.Values.admin.extraVolumeMounts | default "") .) (tpl (.Values.admin.extraVolumes | default "") .)) | nindent 12 }}
{{- 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") }}
- name: admin-data
mountPath: /data
@@ -268,7 +267,6 @@ spec:
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.admin.sidecars "context" $) | nindent 8 }}
{{- end }}
volumes:
{{- include "seaweedfs.tmpDirVolume" (list . .Values.admin.containerSecurityContext (tpl (.Values.admin.extraVolumeMounts | default "") .) (tpl (.Values.admin.extraVolumes | default "") .) false) | nindent 8 }}
{{- if eq .Values.admin.data.type "hostPath" }}
- name: admin-data
hostPath:
@@ -43,7 +43,7 @@ spec:
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace $existingS3ConfigSecret) | default dict }}
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
{{- else }}
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
{{- end }}
{{- $_ := set $podAnnotations "checksum/master-config" (include (print .Template.BasePath "/master/master-configmap.yaml") . | sha256sum) }}
annotations:
@@ -306,7 +306,6 @@ spec:
{{- end }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.allInOne.containerSecurityContext (tpl (.Values.allInOne.extraVolumeMounts | default "") .) (tpl (.Values.allInOne.extraVolumes | default "") .)) | nindent 12 }}
- name: data
mountPath: /data
{{- if and .Values.allInOne.s3.enabled (or .Values.allInOne.s3.enableAuth .Values.s3.enableAuth .Values.filer.s3.enableAuth) }}
@@ -434,7 +433,6 @@ spec:
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.allInOne.sidecars "context" $) | nindent 8 }}
{{- end }}
volumes:
{{- include "seaweedfs.tmpDirVolume" (list . .Values.allInOne.containerSecurityContext (tpl (.Values.allInOne.extraVolumeMounts | default "") .) (tpl (.Values.allInOne.extraVolumes | default "") .) false) | nindent 8 }}
{{- include "seaweedfs.licenseVolume" . | nindent 8 }}
- name: data
{{- if eq .Values.allInOne.data.type "hostPath" }}
@@ -28,13 +28,6 @@ rules:
- "update"
- "create"
- "delete"
- apiGroups: ["objectstorage.k8s.io"]
resources:
- "bucketclasses"
verbs:
- "get"
- "list"
- "watch"
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs:
@@ -68,7 +61,7 @@ metadata:
app.kubernetes.io/instance: {{ .Release.Name }}
subjects:
- kind: ServiceAccount
name: {{ include "seaweedfs.serviceAccountName" . }}-objectstorage-provisioner
name: {{ .Values.global.seaweedfs.serviceAccountName }}-objectstorage-provisioner
namespace: {{ .Release.Namespace }}
roleRef:
kind: ClusterRole
@@ -61,7 +61,7 @@ spec:
schedulerName: {{ .Values.cosi.schedulerName | quote }}
{{- end }}
enableServiceLinks: false
serviceAccountName: {{ include "seaweedfs.serviceAccountName" . }}-objectstorage-provisioner
serviceAccountName: {{ include "seaweedfs.componentName" (list . "objectstorage-provisioner") }}
{{- if .Values.cosi.initContainers }}
initContainers:
{{ tpl .Values.cosi.initContainers . | nindent 8 | trim }}
@@ -113,7 +113,6 @@ spec:
{{- end -}}
{{- end }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.cosi.containerSecurityContext (tpl (.Values.cosi.extraVolumeMounts | default "") .) (tpl (.Values.cosi.extraVolumes | default "") .)) | nindent 12 }}
- mountPath: /var/lib/cosi
name: socket
{{- if .Values.cosi.enableAuth }}
@@ -149,9 +148,6 @@ spec:
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.cosi.containerSecurityContext.enabled }}
securityContext: {{- omit .Values.cosi.containerSecurityContext "enabled" | toYaml | nindent 12 }}
{{- end }}
- name: seaweedfs-cosi-sidecar
image: "{{ .Values.cosi.sidecar.image }}"
imagePullPolicy: {{ default "IfNotPresent" .Values.global.seaweedfs.imagePullPolicy }}
@@ -163,7 +159,6 @@ spec:
fieldRef:
fieldPath: metadata.namespace
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.cosi.containerSecurityContext "" "") | nindent 12 }}
- mountPath: /var/lib/cosi
name: socket
{{- with .Values.cosi.sidecar.resources }}
@@ -177,7 +172,6 @@ spec:
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.cosi.sidecars "context" $) | nindent 8 }}
{{- end }}
volumes:
{{- include "seaweedfs.tmpDirVolume" (list . .Values.cosi.containerSecurityContext (tpl (.Values.cosi.extraVolumeMounts | default "") .) (tpl (.Values.cosi.extraVolumes | default "") .) true) | nindent 8 }}
- name: socket
emptyDir: {}
{{- if .Values.cosi.enableAuth }}
@@ -3,7 +3,7 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "seaweedfs.serviceAccountName" . }}-objectstorage-provisioner
name: {{ .Values.global.seaweedfs.serviceAccountName }}-objectstorage-provisioner
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
@@ -1,57 +0,0 @@
{{- $filerEnabled := or .Values.filer.enabled .Values.allInOne.enabled }}
{{- if and $filerEnabled .Values.filer.httpRoute.enabled }}
{{- $serviceName := ternary (include "seaweedfs.componentName" (list . "all-in-one")) (include "seaweedfs.componentName" (list . "filer")) .Values.allInOne.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "seaweedfs.componentName" (list . "filer") }}
namespace: {{ .Release.Namespace }}
{{- with .Values.filer.httpRoute.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: filer
{{- with .Values.filer.httpRoute.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.filer.httpRoute.parentRefs }}
parentRefs:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.filer.httpRoute.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.filer.httpRoute.rules | default (list dict) }}
- backendRefs:
{{- with .backendRefs }}
{{- toYaml . | nindent 8 }}
{{- else }}
- group: ""
kind: Service
name: {{ $serviceName }}
namespace: {{ $.Release.Namespace }}
port: {{ $.Values.filer.port }}
weight: 1
{{- end }}
{{- with .matches }}
matches:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .filters }}
filters:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .timeouts }}
timeouts:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- end }}
@@ -48,7 +48,7 @@ spec:
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.filer.s3.existingConfigSecret) | default dict }}
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
{{- else }}
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
{{- end }}
annotations:
{{- toYaml $podAnnotations | nindent 8 }}
@@ -229,7 +229,6 @@ spec:
{{ . }} \
{{- end }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.filer.containerSecurityContext (tpl (.Values.filer.extraVolumeMounts | default "") .) (tpl (.Values.filer.extraVolumes | default "") .)) | nindent 12 }}
{{- if (or (eq .Values.filer.logs.type "hostPath") (eq .Values.filer.logs.type "persistentVolumeClaim") (eq .Values.filer.logs.type "emptyDir")) }}
- name: seaweedfs-filer-log-volume
mountPath: "/logs/"
@@ -338,7 +337,6 @@ spec:
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.filer.sidecars "context" $) | nindent 8 }}
{{- end }}
volumes:
{{- include "seaweedfs.tmpDirVolume" (list . .Values.filer.containerSecurityContext (tpl (.Values.filer.extraVolumeMounts | default "") .) (tpl (.Values.filer.extraVolumes | default "") .) false) | nindent 8 }}
{{- if eq .Values.filer.logs.type "hostPath" }}
- name: seaweedfs-filer-log-volume
hostPath:
@@ -1,57 +0,0 @@
{{- $masterEnabled := or .Values.master.enabled .Values.allInOne.enabled }}
{{- if and $masterEnabled .Values.master.httpRoute.enabled }}
{{- $serviceName := ternary (include "seaweedfs.componentName" (list . "all-in-one")) (include "seaweedfs.componentName" (list . "master")) .Values.allInOne.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "seaweedfs.componentName" (list . "master") }}
namespace: {{ .Release.Namespace }}
{{- with .Values.master.httpRoute.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: master
{{- with .Values.master.httpRoute.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.master.httpRoute.parentRefs }}
parentRefs:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.master.httpRoute.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.master.httpRoute.rules | default (list dict) }}
- backendRefs:
{{- with .backendRefs }}
{{- toYaml . | nindent 8 }}
{{- else }}
- group: ""
kind: Service
name: {{ $serviceName }}
namespace: {{ $.Release.Namespace }}
port: {{ $.Values.master.port }}
weight: 1
{{- end }}
{{- with .matches }}
matches:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .filters }}
filters:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .timeouts }}
timeouts:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- end }}
@@ -179,7 +179,6 @@ spec:
{{ . }} \
{{- end }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.master.containerSecurityContext (tpl (.Values.master.extraVolumeMounts | default "") .) (tpl (.Values.master.extraVolumes | default "") .)) | nindent 12 }}
- name : data-{{ .Release.Namespace }}
mountPath: /data
{{- if or (eq .Values.master.logs.type "hostPath") (eq .Values.master.logs.type "persistentVolumeClaim") (eq .Values.master.logs.type "emptyDir") }}
@@ -259,7 +258,6 @@ spec:
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.master.sidecars "context" $) | nindent 8 }}
{{- end }}
volumes:
{{- include "seaweedfs.tmpDirVolume" (list . .Values.master.containerSecurityContext (tpl (.Values.master.extraVolumeMounts | default "") .) (tpl (.Values.master.extraVolumes | default "") .) false) | nindent 8 }}
{{- include "seaweedfs.licenseVolume" . | nindent 8 }}
{{- if eq .Values.master.logs.type "hostPath" }}
- name: seaweedfs-master-log-volume
@@ -17,10 +17,6 @@ metadata:
{{- end }}
spec:
replicas: {{ .Values.s3.replicas }}
{{- with .Values.s3.updateStrategy }}
strategy:
{{- toYaml . | nindent 4 }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
@@ -44,7 +40,7 @@ spec:
{{- $configSecret := (lookup "v1" "Secret" .Release.Namespace .Values.s3.existingConfigSecret) | default dict }}
{{- $_ := set $podAnnotations "checksum/s3config" ($configSecret | toYaml | sha256sum) }}
{{- else }}
{{- $_ := set $podAnnotations "checksum/s3config" (include "seaweedfs.s3.configChecksum" .) }}
{{- $_ := set $podAnnotations "checksum/s3config" (include (print .Template.BasePath "/s3/s3-secret.yaml") . | sha256sum) }}
{{- end }}
annotations:
{{- toYaml $podAnnotations | nindent 8 }}
@@ -63,7 +59,7 @@ spec:
{{ tpl .Values.s3.tolerations . | nindent 8 | trim }}
{{- end }}
{{- include "seaweedfs.imagePullSecrets" . | nindent 6 }}
terminationGracePeriodSeconds: {{ .Values.s3.terminationGracePeriodSeconds }}
terminationGracePeriodSeconds: 10
{{- if .Values.s3.priorityClassName }}
priorityClassName: {{ .Values.s3.priorityClassName | quote }}
{{- end }}
@@ -161,7 +157,6 @@ spec:
{{ . }} \
{{- end }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.s3.containerSecurityContext (tpl (.Values.s3.extraVolumeMounts | default "") .) (tpl (.Values.s3.extraVolumes | default "") .)) | nindent 12 }}
{{- if or (eq .Values.s3.logs.type "hostPath") (eq .Values.s3.logs.type "emptyDir") }}
- name: logs
mountPath: "/logs/"
@@ -239,10 +234,6 @@ spec:
failureThreshold: {{ .Values.s3.livenessProbe.failureThreshold }}
timeoutSeconds: {{ .Values.s3.livenessProbe.timeoutSeconds }}
{{- end }}
{{- with .Values.s3.lifecycle }}
lifecycle:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.s3.resources }}
resources:
{{- toYaml . | nindent 12 }}
@@ -254,7 +245,6 @@ spec:
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.s3.sidecars "context" $) | nindent 8 }}
{{- end }}
volumes:
{{- include "seaweedfs.tmpDirVolume" (list . .Values.s3.containerSecurityContext (tpl (.Values.s3.extraVolumeMounts | default "") .) (tpl (.Values.s3.extraVolumes | default "") .) false) | nindent 8 }}
{{- if .Values.s3.enableAuth }}
- name: config-users
secret:
@@ -1,64 +0,0 @@
{{- $s3Enabled := or .Values.s3.enabled (and .Values.filer.s3.enabled (not .Values.allInOne.enabled)) (and .Values.allInOne.enabled .Values.allInOne.s3.enabled) }}
{{- if and $s3Enabled .Values.s3.httpRoute.enabled }}
{{- $serviceName := include "seaweedfs.componentName" (list . "s3") }}
{{- $s3Port := .Values.filer.s3.port }}
{{- if and .Values.allInOne.enabled .Values.allInOne.s3.enabled }}
{{- $serviceName = include "seaweedfs.componentName" (list . "all-in-one") }}
{{- $s3Port = .Values.allInOne.s3.port | default .Values.s3.port }}
{{- else if .Values.s3.enabled }}
{{- $s3Port = .Values.s3.port }}
{{- end }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "seaweedfs.componentName" (list . "s3") }}
namespace: {{ .Release.Namespace }}
{{- with .Values.s3.httpRoute.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: s3
{{- with .Values.s3.httpRoute.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.s3.httpRoute.parentRefs }}
parentRefs:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.s3.httpRoute.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.s3.httpRoute.rules | default (list dict) }}
- backendRefs:
{{- with .backendRefs }}
{{- toYaml . | nindent 8 }}
{{- else }}
- group: ""
kind: Service
name: {{ $serviceName }}
namespace: {{ $.Release.Namespace }}
port: {{ $s3Port }}
weight: 1
{{- end }}
{{- with .matches }}
matches:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .filters }}
filters:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .timeouts }}
timeouts:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- end }}
@@ -170,7 +170,6 @@ spec:
-userStoreFile=/etc/sw/seaweedfs_sftp_config \
-filer={{ include "seaweedfs.componentName" (list . "filer-client") }}.{{ .Release.Namespace }}:{{ .Values.filer.port }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.sftp.containerSecurityContext (tpl (.Values.sftp.extraVolumeMounts | default "") .) (tpl (.Values.sftp.extraVolumes | default "") .)) | nindent 12 }}
{{- if or (eq .Values.sftp.logs.type "hostPath") (eq .Values.sftp.logs.type "emptyDir") }}
- name: logs
mountPath: "/logs/"
@@ -250,7 +249,6 @@ spec:
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.sftp.sidecars "context" $) | nindent 8 }}
{{- end }}
volumes:
{{- include "seaweedfs.tmpDirVolume" (list . .Values.sftp.containerSecurityContext (tpl (.Values.sftp.extraVolumeMounts | default "") .) (tpl (.Values.sftp.extraVolumes | default "") .) false) | nindent 8 }}
{{- if .Values.sftp.enableAuth }}
- name: config-users
secret:
@@ -76,43 +76,6 @@ Inject extra environment vars in the format key:value, if populated
{{- end }}
{{- end -}}
{{/*
Writable temporary directory for containers using a read-only root filesystem.
Input: list of the root context, the component container security context, the
rendered extraVolumeMounts and extraVolumes, and whether the pod has secondary
chart-managed containers that mount seaweedfs-tmp. A user-supplied /tmp mount
only covers the main container, so the volume is still emitted for secondaries;
a user-supplied seaweedfs-tmp volume is reused rather than duplicated.
*/}}
{{- define "seaweedfs.tmpDirCovered" -}}
{{- regexMatch `(?m)^\s*-?\s*mountPath:\s*['"]?/tmp/?['"]?\s*(#.*)?$` (index . 2) -}}
{{- end -}}
{{- define "seaweedfs.tmpDirVolume" -}}
{{- $root := index . 0 -}}
{{- $securityContext := index . 1 -}}
{{- if and $securityContext.enabled $securityContext.readOnlyRootFilesystem
(or (index . 4) (ne (include "seaweedfs.tmpDirCovered" .) "true"))
(not (regexMatch `(?m)^\s*-?\s*name:\s*['"]?seaweedfs-tmp['"]?\s*(#.*)?$` (index . 3))) }}
- name: seaweedfs-tmp
{{- with $root.Values.global.seaweedfs.tmpDir.sizeLimit }}
emptyDir:
sizeLimit: {{ . | quote }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
{{- end -}}
{{- define "seaweedfs.tmpDirVolumeMount" -}}
{{- $securityContext := index . 1 -}}
{{- if and $securityContext.enabled $securityContext.readOnlyRootFilesystem
(ne (include "seaweedfs.tmpDirCovered" .) "true") }}
- name: seaweedfs-tmp
mountPath: /tmp
{{- end }}
{{- end -}}
{{/* Whether the mysql filer store is selected; a flag the chart cannot read counts as selected. */}}
{{- define "seaweedfs.filer.mysqlEnabled" -}}
{{- $merged := dict -}}
@@ -444,46 +407,6 @@ true
{{- end -}}
{{- end -}}
{{/* Read a JWT key from the chart's existing security.toml without fromToml
(which requires Helm >=3.17). Args: (list "<section>" $raw).
Return the single-line TOML string token, including its quotes, so escapes
and explicitly empty values survive unchanged. No output means absent.
Accept bare or simply quoted dotted header segments. Fail on other quoted
headers rather than risk rotating a key hidden by unsupported syntax.
This reads the chart's section/key layout, not arbitrary TOML syntax. */}}
{{- define "seaweedfs.existingTomlKey" -}}
{{- $section := index . 0 -}}
{{- $raw := index . 1 -}}
{{- $parts := list -}}
{{- range $part := splitList "." $section -}}
{{- $escaped := regexQuoteMeta $part -}}
{{- $parts = append $parts (printf `(?:%s|"%s"|'%s')` $escaped $escaped $escaped) -}}
{{- end -}}
{{- $header := printf `^\[[ \t]*%s[ \t]*\][ \t]*(#.*)?$` (join `[ \t]*\.[ \t]*` $parts) -}}
{{- $segment := `(?:[A-Za-z0-9_-]+|"[^"\\]*"|'[^']*')` -}}
{{- $simpleHeader := printf `^\[[ \t]*%s(?:[ \t]*\.[ \t]*%s)*[ \t]*\][ \t]*(#.*)?$` $segment $segment -}}
{{- $assignment := `^(key|"key"|'key')[ \t]*=[ \t]*` -}}
{{- $string := `"([^"\\]|\\.)*"|'[^']*'` -}}
{{- $active := false -}}
{{- $key := "" -}}
{{- range $rawLine := splitList "\n" $raw -}}
{{- $line := trim $rawLine -}}
{{- if hasPrefix "[" $line -}}
{{- if and (regexMatch `^\[[^]]*["']` $line) (not (regexMatch $simpleHeader $line)) (eq $key "") -}}
{{- fail (printf "security.toml has an unsupported quoted section header; refusing to replace [%s].key" $section) -}}
{{- end -}}
{{- $active = regexMatch $header $line -}}
{{- else if and $active (eq $key "") (regexMatch $assignment $line) -}}
{{- $value := regexReplaceAll $assignment $line "" -}}
{{- if not (regexMatch (printf `^(%s)[ \t]*(#.*)?$` $string) $value) -}}
{{- fail (printf "security.toml [%s].key must be a single-line quoted TOML string; refusing to replace an existing key" $section) -}}
{{- end -}}
{{- $key = regexFind $string $value -}}
{{- end -}}
{{- end -}}
{{- $key -}}
{{- end -}}
{{/* True when the post-install bucket hook Job renders: an S3 endpoint, plus
buckets to create on it. Read by the Job itself and by its NetworkPolicy,
which has to appear exactly when the Job does - a Job without its policy
@@ -544,9 +467,8 @@ true
{{- $pvcName := printf "%s-%s-%s-%d" $dir.name $seaweedfsName $volumeName $e }}
{{- $currentPVC := (lookup "v1" "PersistentVolumeClaim" $.Release.Namespace $pvcName) }}
{{- if $currentPVC }}
{{- /* include returns a string such as "6.442450944e+10"; convert back to a number, or gt compares lexically */}}
{{- $oldSize := include "seaweedfs.resource-quantity" $currentPVC.spec.resources.requests.storage | float64 }}
{{- $newSize := include "seaweedfs.resource-quantity" $desiredSize | float64 }}
{{- $oldSize := include "seaweedfs.resource-quantity" $currentPVC.spec.resources.requests.storage }}
{{- $newSize := include "seaweedfs.resource-quantity" $desiredSize }}
{{- if gt $newSize $oldSize }}
{{- $commands = append $commands (printf "kubectl patch pvc %s-%s-%s-%d -p '{\"spec\":{\"resources\":{\"requests\":{\"storage\":\"%s\"}}}}'" $dir.name $seaweedfsName $volumeName $e $desiredSize) }}
{{- end }}
@@ -692,28 +614,3 @@ Usage: {{ include "seaweedfs.service.loadBalancerFields" .Values.s3.service }}
{{- end }}
{{- end }}
{{- end -}}
{{/*
Checksum that restarts the S3-serving pods when the generated S3 config changes. It hashes the
values s3-secret.yaml is built from, not the rendered Secret: that template keeps its generated keys
with lookup, so rendering it again for a checksum draws new random keys whenever lookup finds
nothing (on install), and the first upgrade then changed the checksum and restarted the pods
although nothing had changed. Only the values that template reads are hashed, so scaling the S3
gateway or changing its resources does not restart the filer. Keep this list in step with
s3-secret.yaml.
Keys the chart generated are not part of it. Deleting the Secret to rotate them regenerates them on
the next upgrade without restarting the pods; restart them afterwards (kubectl rollout restart).
Folding the stored keys in would bring the first-upgrade restart back, since lookup finds nothing
on install.
*/}}
{{- define "seaweedfs.s3.configChecksum" -}}
{{- dict
"secretName" (include "seaweedfs.fullname" .)
"enabled" (list .Values.s3.enabled .Values.filer.s3.enabled .Values.allInOne.enabled .Values.allInOne.s3.enabled)
"enableAuth" (list .Values.s3.enableAuth .Values.filer.s3.enableAuth .Values.allInOne.s3.enableAuth)
"existingConfigSecret" (list .Values.s3.existingConfigSecret .Values.filer.s3.existingConfigSecret .Values.allInOne.s3.existingConfigSecret)
"credentials" .Values.s3.credentials
"legacySecret" (list .Values.s3.reuseLegacySecret .Values.s3.legacySecretName)
"auditLogConfig" (list .Values.s3.auditLogConfig .Values.filer.s3.auditLogConfig)
| toJson | sha256sum -}}
{{- end -}}
@@ -16,12 +16,6 @@
{{- $clusterUpper := upper $clusterAlias }}
{{- $clusterMasterKey := printf "WEED_CLUSTER_%s_MASTER" $clusterUpper }}
{{- $clusterFilerKey := printf "WEED_CLUSTER_%s_FILER" $clusterUpper }}
{{- $podSecurityContext := .Values.filer.podSecurityContext }}
{{- $containerSecurityContext := .Values.filer.containerSecurityContext }}
{{- if .Values.allInOne.enabled }}
{{- $podSecurityContext = .Values.allInOne.podSecurityContext }}
{{- $containerSecurityContext = .Values.allInOne.containerSecurityContext }}
{{- end }}
{{- /* Check allInOne mode first */}}
{{- if .Values.allInOne.enabled }}
@@ -77,8 +71,8 @@ spec:
app.kubernetes.io/component: bucket-hook
spec:
restartPolicy: Never
{{- if $podSecurityContext.enabled }}
securityContext: {{- omit $podSecurityContext "enabled" | toYaml | nindent 8 }}
{{- if .Values.filer.podSecurityContext.enabled }}
securityContext: {{- omit .Values.filer.podSecurityContext "enabled" | toYaml | nindent 8 }}
{{- end }}
{{- include "seaweedfs.imagePullSecrets" $ | nindent 6 }}
containers:
@@ -208,9 +202,8 @@ spec:
/usr/bin/weed shell
{{- end }}
{{- end }}
{{- if or (and $containerSecurityContext.enabled $containerSecurityContext.readOnlyRootFilesystem) $enableAuth (include "seaweedfs.securityConfigEnabled" .) }}
{{- if or $enableAuth (include "seaweedfs.securityConfigEnabled" .) }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . $containerSecurityContext "" "") | nindent 10 }}
{{- if $enableAuth }}
- name: config-users
mountPath: /etc/sw
@@ -222,14 +215,6 @@ spec:
mountPath: /etc/seaweedfs/security.toml
subPath: security.toml
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/ca/
- name: client-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/client/
{{- end }}
{{- end }}
ports:
- containerPort: {{ .Values.master.port }}
@@ -244,12 +229,11 @@ spec:
resources:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- if $containerSecurityContext.enabled }}
securityContext: {{- omit $containerSecurityContext "enabled" | toYaml | nindent 12 }}
{{- if .Values.filer.containerSecurityContext.enabled }}
securityContext: {{- omit .Values.filer.containerSecurityContext "enabled" | toYaml | nindent 12 }}
{{- end }}
{{- if or (and $containerSecurityContext.enabled $containerSecurityContext.readOnlyRootFilesystem) $enableAuth (include "seaweedfs.securityConfigEnabled" .) }}
{{- if or $enableAuth (include "seaweedfs.securityConfigEnabled" .) }}
volumes:
{{- include "seaweedfs.tmpDirVolume" (list . $containerSecurityContext "" "" false) | nindent 8 }}
{{- if $enableAuth }}
- name: config-users
secret:
@@ -265,13 +249,5 @@ spec:
configMap:
name: {{ include "seaweedfs.fullname" . }}-security-config
{{- end }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
secret:
secretName: {{ include "seaweedfs.fullname" . }}-ca-cert
- name: client-cert
secret:
secretName: {{ include "seaweedfs.fullname" . }}-client-cert
{{- end }}
{{- end }}
{{- end }}
@@ -18,7 +18,7 @@ data:
{{- $legacyName := printf "%s-%s" (include "seaweedfs.name" .) "security-config" }}
{{- $existing = lookup "v1" "ConfigMap" .Release.Namespace $legacyName }}
{{- end }}
{{- $existingToml := dig "data" "security.toml" "" $existing }}
{{- $securityConfig := fromToml (dig "data" "security.toml" "" $existing) }}
{{- $securityConfigValues := .Values.global.seaweedfs.securityConfig | default dict }}
{{- $jwtSigning := $securityConfigValues.jwtSigning | default dict }}
{{- $expiresAfterSeconds := $jwtSigning.expiresAfterSeconds | default dict }}
@@ -29,7 +29,7 @@ data:
# the jwt signing key is read by master and volume server
# the jwt defaults to expire after 10 seconds
[jwt.signing]
key = {{ include "seaweedfs.existingTomlKey" (list "jwt.signing" $existingToml) | default (randAlphaNum 10 | b64enc | quote) }}
key = "{{ dig "jwt" "signing" "key" (randAlphaNum 10 | b64enc) $securityConfig }}"
{{- if gt (int $expiresAfterSeconds.volumeWrite) 0 }}
expires_after_seconds = {{ int $expiresAfterSeconds.volumeWrite }}
{{- end }}
@@ -41,7 +41,7 @@ data:
# - the Volume server validates the JWT on reading
# the jwt defaults to expire after 60 seconds
[jwt.signing.read]
key = {{ include "seaweedfs.existingTomlKey" (list "jwt.signing.read" $existingToml) | default (randAlphaNum 10 | b64enc | quote) }}
key = "{{ dig "jwt" "signing" "read" "key" (randAlphaNum 10 | b64enc) $securityConfig }}"
{{- if gt (int $expiresAfterSeconds.volumeRead) 0 }}
expires_after_seconds = {{ int $expiresAfterSeconds.volumeRead }}
{{- end }}
@@ -53,7 +53,7 @@ data:
# - the Filer server validates the JWT on writing
# the jwt defaults to expire after 10 seconds
[jwt.filer_signing]
key = {{ include "seaweedfs.existingTomlKey" (list "jwt.filer_signing" $existingToml) | default (randAlphaNum 10 | b64enc | quote) }}
key = "{{ dig "jwt" "filer_signing" "key" (randAlphaNum 10 | b64enc) $securityConfig }}"
{{- if gt (int $expiresAfterSeconds.filerWrite) 0 }}
expires_after_seconds = {{ int $expiresAfterSeconds.filerWrite }}
{{- end }}
@@ -65,7 +65,7 @@ data:
# - the Filer server validates the JWT on reading
# the jwt defaults to expire after 60 seconds
[jwt.filer_signing.read]
key = {{ include "seaweedfs.existingTomlKey" (list "jwt.filer_signing.read" $existingToml) | default (randAlphaNum 10 | b64enc | quote) }}
key = "{{ dig "jwt" "filer_signing" "read" "key" (randAlphaNum 10 | b64enc) $securityConfig }}"
{{- if gt (int $expiresAfterSeconds.filerRead) 0 }}
expires_after_seconds = {{ int $expiresAfterSeconds.filerRead }}
{{- end }}
@@ -1,57 +0,0 @@
{{- $volumeEnabled := or .Values.volume.enabled .Values.allInOne.enabled }}
{{- if and $volumeEnabled .Values.volume.httpRoute.enabled }}
{{- $serviceName := ternary (include "seaweedfs.componentName" (list . "all-in-one")) (include "seaweedfs.componentName" (list . "volume")) .Values.allInOne.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "seaweedfs.componentName" (list . "volume") }}
namespace: {{ .Release.Namespace }}
{{- with .Values.volume.httpRoute.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: volume
{{- with .Values.volume.httpRoute.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.volume.httpRoute.parentRefs }}
parentRefs:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.volume.httpRoute.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.volume.httpRoute.rules | default (list dict) }}
- backendRefs:
{{- with .backendRefs }}
{{- toYaml . | nindent 8 }}
{{- else }}
- group: ""
kind: Service
name: {{ $serviceName }}
namespace: {{ $.Release.Namespace }}
port: {{ $.Values.volume.port }}
weight: 1
{{- end }}
{{- with .matches }}
matches:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .filters }}
filters:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .timeouts }}
timeouts:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- end }}
@@ -32,32 +32,13 @@ spec:
spec:
serviceAccountName: {{ $seaweedfsName }}-volume-resize-hook
restartPolicy: Never
{{- if .Values.volume.podSecurityContext.enabled }}
securityContext: {{- omit .Values.volume.podSecurityContext "enabled" | toYaml | nindent 8 }}
{{- end }}
containers:
- name: resize
image: {{ .Values.volume.resizeHook.image }}
{{- if and .Values.volume.containerSecurityContext.enabled .Values.volume.containerSecurityContext.readOnlyRootFilesystem }}
env:
- name: HOME
value: /tmp
{{- end }}
command: ["sh", "-xec"]
args:
- |
{{ $commands | indent 14 }}
{{- if and .Values.volume.containerSecurityContext.enabled .Values.volume.containerSecurityContext.readOnlyRootFilesystem }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.volume.containerSecurityContext "" "") | nindent 12 }}
{{- end }}
{{- if .Values.volume.containerSecurityContext.enabled }}
securityContext: {{- omit .Values.volume.containerSecurityContext "enabled" | toYaml | nindent 12 }}
{{- end }}
{{- if and .Values.volume.containerSecurityContext.enabled .Values.volume.containerSecurityContext.readOnlyRootFilesystem }}
volumes:
{{- include "seaweedfs.tmpDirVolume" (list . .Values.volume.containerSecurityContext "" "" false) | nindent 8 }}
{{- end }}
---
apiVersion: v1
kind: ServiceAccount
@@ -104,7 +104,6 @@ spec:
fi
done
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list $ $volume.containerSecurityContext "" "") | nindent 12 }}
- name: idx
mountPath: /idx
{{- range $dir := $volume.dataDirs }}
@@ -225,7 +224,6 @@ spec:
{{ . }} \
{{- end }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list $ $volume.containerSecurityContext (tpl (printf "{{ $volumeName := \"%s\" }}%s" $volumeName ($volume.extraVolumeMounts | default "")) $) (tpl ($volume.extraVolumes | default "") $)) | nindent 12 }}
{{- range $dir := $volume.dataDirs }}
{{- if not ( eq $dir.type "custom" ) }}
- name: {{ $dir.name }}
@@ -308,7 +306,6 @@ spec:
{{- include "seaweedfs.tplvalues.render" (dict "value" (printf "{{ $volumeName := \"%s\" }}%s" $volumeName $volume.sidecars) "context" $) | nindent 8 }}
{{- end }}
volumes:
{{- include "seaweedfs.tmpDirVolume" (list $ $volume.containerSecurityContext (tpl (printf "{{ $volumeName := \"%s\" }}%s" $volumeName ($volume.extraVolumeMounts | default "")) $) (tpl ($volume.extraVolumes | default "") $) (and $initContainers_exists $volume.idx)) | nindent 8 }}
{{- range $dir := $volume.dataDirs }}
@@ -144,7 +144,6 @@ spec:
{{ $arg }}{{- if lt $index (sub (len $.Values.worker.extraArgs) 1) }} \{{ end }}
{{- end }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.worker.containerSecurityContext (tpl (.Values.worker.extraVolumeMounts | default "") .) (tpl (.Values.worker.extraVolumes | default "") .)) | nindent 12 }}
{{- if or (eq .Values.worker.data.type "hostPath") (eq .Values.worker.data.type "emptyDir") (eq .Values.worker.data.type "existingClaim") }}
- name: worker-data
mountPath: {{ .Values.worker.workingDir }}
@@ -263,16 +262,15 @@ spec:
--metrics-ip=0.0.0.0 \
{{- end }}
--max-concurrency={{ .Values.worker.maxExecute }}
{{- if .Values.global.seaweedfs.enableSecurity }}
volumeMounts:
{{- include "seaweedfs.tmpDirVolumeMount" (list . .Values.worker.containerSecurityContext "" "") | nindent 12 }}
{{- if .Values.global.seaweedfs.enableSecurity }}
- name: ca-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/ca/
- name: worker-cert
readOnly: true
mountPath: /usr/local/share/ca-certificates/worker/
{{- end }}
{{- end }}
{{- if .Values.worker.lanceMetricsPort }}
ports:
- containerPort: {{ .Values.worker.lanceMetricsPort }}
@@ -304,7 +302,6 @@ spec:
{{- include "seaweedfs.tplvalues.render" (dict "value" .Values.worker.sidecars "context" $) | nindent 8 }}
{{- end }}
volumes:
{{- include "seaweedfs.tmpDirVolume" (list . .Values.worker.containerSecurityContext (tpl (.Values.worker.extraVolumeMounts | default "") .) (tpl (.Values.worker.extraVolumes | default "") .) (ne (include "seaweedfs.worker.lanceNamespaceUrl" .) "")) | nindent 8 }}
{{- if eq .Values.worker.data.type "hostPath" }}
- name: worker-data
hostPath:
-274
View File
@@ -25,9 +25,6 @@ global:
imagePullPolicy: IfNotPresent
restartPolicy: Always
loggingLevel: 1
# Writable temporary storage used when containers run with a read-only root filesystem.
tmpDir:
sizeLimit: ""
enableSecurity: false
masterServer: null
# filerWrite: true mounts security.toml on filer + admin without needing
@@ -263,8 +260,6 @@ master:
# runAsUser: 1000
# runAsGroup: 3000
# fsGroup: 2000
# seccompProfile:
# type: RuntimeDefault
podSecurityContext: {}
# Configure security context for Container
@@ -273,16 +268,7 @@ master:
# containerSecurityContext:
# enabled: true
# runAsUser: 2000
# runAsGroup: 3000
# runAsNonRoot: true
# privileged: false
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
# seccompProfile:
# type: RuntimeDefault
containerSecurityContext: {}
ingress:
@@ -311,24 +297,6 @@ master:
# sub_filter_once off;
tls: []
# Gateway API HTTPRoute, an alternative to the ingress above.
httpRoute:
enabled: false
labels: {}
annotations: {}
# Gateways this route attaches to, for example:
# - group: gateway.networking.k8s.io
# kind: Gateway
# name: my-gateway
# namespace: gateway-system
# sectionName: https
parentRefs: []
hostnames: []
# Each rule may set matches, filters, timeouts and backendRefs.
# A rule without backendRefs routes to the master service.
# An empty list routes all traffic there.
rules: []
extraEnvironmentVars:
WEED_MASTER_VOLUME_GROWTH_COPY_1: "7"
WEED_MASTER_VOLUME_GROWTH_COPY_2: "6"
@@ -592,8 +560,6 @@ volume:
# runAsUser: 1000
# runAsGroup: 3000
# fsGroup: 2000
# seccompProfile:
# type: RuntimeDefault
podSecurityContext: {}
# Configure security context for Container
@@ -602,16 +568,7 @@ volume:
# containerSecurityContext:
# enabled: true
# runAsUser: 2000
# runAsGroup: 3000
# runAsNonRoot: true
# privileged: false
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
# seccompProfile:
# type: RuntimeDefault
containerSecurityContext: {}
# used to configure livenessProbe on volume-server containers
@@ -665,24 +622,6 @@ volume:
# sub_filter '/seaweedfsstatic' './seaweedfsstatic';
# sub_filter_once off;
# Gateway API HTTPRoute, an alternative to the ingress above.
httpRoute:
enabled: false
labels: {}
annotations: {}
# Gateways this route attaches to, for example:
# - group: gateway.networking.k8s.io
# kind: Gateway
# name: my-gateway
# namespace: gateway-system
# sectionName: https
parentRefs: []
hostnames: []
# Each rule may set matches, filters, timeouts and backendRefs.
# A rule without backendRefs routes to the volume server service.
# An empty list routes all traffic there.
rules: []
# Map of named volume groups for topology-aware deployments.
# Each key inherits all fields from the `volume` section but can override
# them locally—for example, replicas, nodeSelector, dataCenter, etc.
@@ -906,8 +845,6 @@ filer:
# runAsUser: 1000
# runAsGroup: 3000
# fsGroup: 2000
# seccompProfile:
# type: RuntimeDefault
podSecurityContext: {}
# Configure security context for Container
@@ -916,16 +853,7 @@ filer:
# containerSecurityContext:
# enabled: true
# runAsUser: 2000
# runAsGroup: 3000
# runAsNonRoot: true
# privileged: false
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
# seccompProfile:
# type: RuntimeDefault
containerSecurityContext: {}
ingresses:
@@ -975,24 +903,6 @@ filer:
# requires ingress-nginx to run with --enable-ssl-passthrough.
tls: []
# Gateway API HTTPRoute, an alternative to the ingress above.
httpRoute:
enabled: false
labels: {}
annotations: {}
# Gateways this route attaches to, for example:
# - group: gateway.networking.k8s.io
# kind: Gateway
# name: my-gateway
# namespace: gateway-system
# sectionName: https
parentRefs: []
hostnames: []
# Each rule may set matches, filters, timeouts and backendRefs.
# A rule without backendRefs routes to the filer service.
# An empty list routes all traffic there.
rules: []
# extraEnvVars is a list of extra environment variables to set with the stateful set.
extraEnvironmentVars:
# the WEED_MYSQL_* keys and the db credential secret only render while this is "true"
@@ -1089,17 +999,6 @@ s3:
imageOverride: null
restartPolicy: null
replicas: 1
# Deployment update strategy, rendered as the Deployment's strategy
# ref: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy
# Example:
# updateStrategy:
# type: RollingUpdate
# rollingUpdate:
# maxUnavailable: 0
# maxSurge: 25%
updateStrategy: {}
# Time the s3 pod is given to shut down, including any preStop hook
terminationGracePeriodSeconds: 10
bindAddress: 0.0.0.0
port: 8333
# add additional https port
@@ -1228,8 +1127,6 @@ s3:
# runAsUser: 1000
# runAsGroup: 3000
# fsGroup: 2000
# seccompProfile:
# type: RuntimeDefault
podSecurityContext: {}
# Configure security context for Container
@@ -1238,16 +1135,7 @@ s3:
# containerSecurityContext:
# enabled: true
# runAsUser: 2000
# runAsGroup: 3000
# runAsNonRoot: true
# privileged: false
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
# seccompProfile:
# type: RuntimeDefault
containerSecurityContext: {}
# You can also use emptyDir storage:
@@ -1292,17 +1180,6 @@ s3:
failureThreshold: 100
timeoutSeconds: 10
# Lifecycle hooks for the s3 container. A preStop sleep lets the pod leave
# the Service endpoints before it receives SIGTERM; keep it shorter than
# terminationGracePeriodSeconds.
# ref: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/
# Example:
# lifecycle:
# preStop:
# exec:
# command: ["sleep", "5"]
lifecycle: {}
createBucketsHook:
resources: {}
@@ -1318,24 +1195,6 @@ s3:
annotations: {}
tls: []
# Gateway API HTTPRoute, an alternative to the ingress above.
httpRoute:
enabled: false
labels: {}
annotations: {}
# Gateways this route attaches to, for example:
# - group: gateway.networking.k8s.io
# kind: Gateway
# name: my-gateway
# namespace: gateway-system
# sectionName: https
parentRefs: []
hostnames: []
# Each rule may set matches, filters, timeouts and backendRefs.
# A rule without backendRefs routes to the s3 service.
# An empty list routes all traffic there.
rules: []
# Service settings
service:
type: ClusterIP
@@ -1428,33 +1287,7 @@ sftp:
priorityClassName: ""
schedulerName: ""
serviceAccountName: ""
# Configure security context for Pod
# ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
# Example:
# podSecurityContext:
# enabled: true
# runAsUser: 1000
# runAsGroup: 3000
# fsGroup: 2000
# seccompProfile:
# type: RuntimeDefault
podSecurityContext: {}
# Configure security context for Container
# ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
# Example:
# containerSecurityContext:
# enabled: true
# runAsUser: 2000
# runAsGroup: 3000
# runAsNonRoot: true
# privileged: false
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
# seccompProfile:
# type: RuntimeDefault
containerSecurityContext: {}
logs:
@@ -1601,33 +1434,7 @@ admin:
priorityClassName: ""
schedulerName: ""
serviceAccountName: ""
# Configure security context for Pod
# ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
# Example:
# podSecurityContext:
# enabled: true
# runAsUser: 1000
# runAsGroup: 3000
# fsGroup: 2000
# seccompProfile:
# type: RuntimeDefault
podSecurityContext: {}
# Configure security context for Container
# ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
# Example:
# containerSecurityContext:
# enabled: true
# runAsUser: 2000
# runAsGroup: 3000
# runAsNonRoot: true
# privileged: false
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
# seccompProfile:
# type: RuntimeDefault
containerSecurityContext: {}
extraEnvironmentVars: {}
@@ -1673,24 +1480,6 @@ admin:
annotations: {}
tls: []
# Gateway API HTTPRoute, an alternative to the ingress above.
httpRoute:
enabled: false
labels: {}
annotations: {}
# Gateways this route attaches to, for example:
# - group: gateway.networking.k8s.io
# kind: Gateway
# name: my-gateway
# namespace: gateway-system
# sectionName: https
parentRefs: []
hostnames: []
# Each rule may set matches, filters, timeouts and backendRefs.
# A rule without backendRefs routes to the admin service.
# An empty list routes all traffic there.
rules: []
service:
type: ClusterIP
annotations: {}
@@ -1802,33 +1591,7 @@ worker:
priorityClassName: ""
schedulerName: ""
serviceAccountName: ""
# Configure security context for Pod
# ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
# Example:
# podSecurityContext:
# enabled: true
# runAsUser: 1000
# runAsGroup: 3000
# fsGroup: 2000
# seccompProfile:
# type: RuntimeDefault
podSecurityContext: {}
# Configure security context for Container
# ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
# Example:
# containerSecurityContext:
# enabled: true
# runAsUser: 2000
# runAsGroup: 3000
# runAsNonRoot: true
# privileged: false
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
# seccompProfile:
# type: RuntimeDefault
containerSecurityContext: {}
extraEnvironmentVars: {}
@@ -2088,8 +1851,6 @@ allInOne:
# runAsUser: 1000
# runAsGroup: 3000
# fsGroup: 2000
# seccompProfile:
# type: RuntimeDefault
podSecurityContext: {}
# Configure security context for Container
@@ -2098,16 +1859,7 @@ allInOne:
# containerSecurityContext:
# enabled: true
# runAsUser: 2000
# runAsGroup: 3000
# runAsNonRoot: true
# privileged: false
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
# seccompProfile:
# type: RuntimeDefault
containerSecurityContext: {}
# Resource management
@@ -2146,33 +1898,7 @@ cosi:
# should have a secret key called seaweedfs_s3_config with an inline json configure
existingConfigSecret: null
# Configure security context for Pod
# ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
# Example:
# podSecurityContext:
# enabled: true
# runAsUser: 1000
# runAsGroup: 3000
# fsGroup: 2000
# seccompProfile:
# type: RuntimeDefault
podSecurityContext: {}
# Configure security context for Container
# ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
# Example:
# containerSecurityContext:
# enabled: true
# runAsUser: 2000
# runAsGroup: 3000
# runAsNonRoot: true
# privileged: false
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
# seccompProfile:
# type: RuntimeDefault
containerSecurityContext: {}
# used to assign a custom scheduler to cosi pods
+1005 -1071
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 53 KiB

+2 -2
View File
@@ -148,9 +148,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"log",
+8 -15
View File
@@ -234,9 +234,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -244,15 +244,14 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.45.0"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -2678,12 +2677,6 @@ dependencies = [
"spki 0.7.3",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
@@ -3396,9 +3389,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
@@ -3443,9 +3436,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
-1
View File
@@ -64,7 +64,6 @@ seaweed-volume --port 8080 --master localhost:9333 --dir /data/vol1 --max 7 \
| `--whiteList` | | Comma-separated IPs with write permission |
| `--preStopSeconds` | `10` | Graceful drain period before shutdown |
| `--compactionMBps` | `0` (unlimited) | Compaction I/O rate limit |
| `--compactionMaxParallelCommits` | `0` (unlimited) | Max concurrent vacuum commits |
| `--pprof` | `false` | Enable pprof HTTP handlers |
Set `RUST_LOG=debug` (or `trace`, `info`, `warn`) for log level control.
-3
View File
@@ -213,9 +213,6 @@ message KeepConnectedRequest {
string filer_group = 5;
string data_center = 6;
string rack = 7;
// A draining filer leaves the lock ring but stays a cluster member, so peers
// keep following its metadata until the stream closes.
bool leave_lock_ring = 8;
}
message VolumeLocation {
-8
View File
@@ -125,11 +125,6 @@ pub struct Cli {
#[arg(long = "maintenanceMBps", default_value_t = 0)]
pub maintenance_mb_per_second: u32,
/// Limit how many vacuum commits run at once on this server; queued
/// commits hold no volume lock. 0 means unlimited.
#[arg(long = "compactionMaxParallelCommits", default_value_t = 0)]
pub max_parallel_vacuum_commits: usize,
/// Limit file size to avoid out of memory.
#[arg(long = "fileSizeLimitMB", default_value_t = 256)]
pub file_size_limit_mb: u32,
@@ -268,8 +263,6 @@ pub struct VolumeServerConfig {
pub mem_profile: String,
pub compaction_byte_per_second: i64,
pub maintenance_byte_per_second: i64,
/// Max concurrent vacuum commits; 0 means unlimited.
pub max_parallel_vacuum_commits: usize,
pub file_size_limit_bytes: i64,
pub concurrent_upload_limit: i64,
pub concurrent_download_limit: i64,
@@ -934,7 +927,6 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
mem_profile: cli.mem_profile,
compaction_byte_per_second: cli.compaction_mb_per_second as i64 * 1024 * 1024,
maintenance_byte_per_second: cli.maintenance_mb_per_second as i64 * 1024 * 1024,
max_parallel_vacuum_commits: cli.max_parallel_vacuum_commits,
file_size_limit_bytes: cli.file_size_limit_mb as i64 * 1024 * 1024,
concurrent_upload_limit: cli.concurrent_upload_limit_mb as i64 * 1024 * 1024,
concurrent_download_limit: cli.concurrent_download_limit_mb as i64 * 1024 * 1024,
-11
View File
@@ -340,14 +340,6 @@ async fn run(
rack: config.rack.clone(),
file_size_limit_bytes: config.file_size_limit_bytes,
maintenance_byte_per_second: config.maintenance_byte_per_second,
vacuum_commit_slots: if config.max_parallel_vacuum_commits > 0 {
Some(tokio::sync::Semaphore::new(
config.max_parallel_vacuum_commits,
))
} else {
None
},
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
// Go sets isHeartbeating: true unconditionally at startup
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
has_master: !config.masters.is_empty(),
@@ -379,9 +371,6 @@ async fn run(
.to_string_lossy()
.into_owned()
},
ec_decodes_in_flight: std::sync::Mutex::new(std::collections::HashSet::new()),
ec_decode_tail: std::sync::Mutex::new(std::collections::HashSet::new()),
ec_decode_tail_notify: tokio::sync::Notify::new(),
});
// Load persisted state from disk if it exists (matches Go's State.Load on startup)
-1
View File
@@ -397,7 +397,6 @@ mod tests {
mem_profile: String::new(),
compaction_byte_per_second: 0,
maintenance_byte_per_second: 0,
max_parallel_vacuum_commits: 0,
file_size_limit_bytes: 0,
concurrent_upload_limit: 0,
concurrent_download_limit: 0,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1485,8 +1485,6 @@ mod tests {
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: std::sync::atomic::AtomicBool::new(false),
has_master: true,
pre_stop_seconds: 0,
@@ -1510,9 +1508,6 @@ mod tests {
security_file: String::new(),
cli_white_list: vec![],
state_file_path: String::new(),
ec_decodes_in_flight: std::sync::Mutex::new(std::collections::HashSet::new()),
ec_decode_tail: std::sync::Mutex::new(std::collections::HashSet::new()),
ec_decode_tail_notify: tokio::sync::Notify::new(),
})
}
-1
View File
@@ -136,7 +136,6 @@ mod tests {
mem_profile: String::new(),
compaction_byte_per_second: 0,
maintenance_byte_per_second: 0,
max_parallel_vacuum_commits: 0,
file_size_limit_bytes: 0,
concurrent_upload_limit: 0,
concurrent_download_limit: 0,
+26 -53
View File
@@ -419,24 +419,13 @@ async fn delete_on_ec_shard_holders(
let mut last_err = None;
if local_shards.contains(&shard_id) {
// A decode in its publishing tail must not miss this delete. The
// tail membership is verified again under the store write lock
// inside journal_delete_local — WouldBlock means the decode claimed
// it in the gap after this wait — so wait and retry.
loop {
crate::server::grpc_server::wait_ec_decode_tail(state, target.vid).await;
match journal_delete_local(state, target.vid, target.needle_id) {
Ok(()) => return Ok(true),
Err(e) if e.kind() == io::ErrorKind::WouldBlock => continue,
// Nothing was committed — the volume unmounted or remounted
// without the needle — so it is safe to fall back to other
// shard holders, unlike an RPC failure which may have landed.
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(e) => {
last_err = Some(e);
break;
}
}
match journal_delete_local(state, target.vid, target.needle_id) {
Ok(()) => return Ok(true),
// Nothing was committed — the volume unmounted or remounted
// without the needle — so it is safe to fall back to other
// shard holders, unlike an RPC failure which may have landed.
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(e) => last_err = Some(e),
}
}
if let Some(addrs) = addrs {
@@ -498,15 +487,6 @@ fn journal_delete_local(
needle_id: NeedleId,
) -> io::Result<()> {
let mut store = state.store.write().unwrap();
// Membership is read under the write lock: a decode can claim the
// publishing tail while this call waited for the decoder's read lock,
// so a check taken earlier would be stale by commit time.
if crate::server::grpc_server::ec_decode_tail_contains(state, vid) {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
format!("ec volume {} is in decode publishing tail", vid.0),
));
}
let ecv = store.find_ec_volume_mut(vid).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
@@ -1773,7 +1753,7 @@ async fn fetch_ec_index_from_one_peer(
.await
.map_err(|e| io::Error::other(format!("copy .ecx: {}", e)))?
.into_inner();
drain_copy_stream(stream, ecx_path).await?;
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)))?;
@@ -1786,30 +1766,15 @@ async fn fetch_ec_index_from_one_peer(
)));
}
// .ecj is the source peer's deletion journal; .vif carries EC params. Both
// are best-effort: a missing .ecj is recreated at mount and a missing .vif
// falls back to default EC parameters. The journal is a *set*: merge the
// peer's ids into any local ones as a union instead of appending, so
// a volume bounced between servers cannot double its journal. The merge
// only appends whole records, so a failure leaves nothing to clean up.
// .ecj is the source peer's deletion journal (appended); .vif carries EC
// params. Both are best-effort: a missing .ecj is recreated at mount and a
// missing .vif falls back to default EC parameters. A failed .ecj append
// leaves a partial file, so drop it.
match client.copy_file(copy_req(".ecj", true)).await {
Ok(resp) => {
let mut stream = resp.into_inner();
let merged = match crate::server::grpc_server::receive_ecj_ids(&mut stream).await {
Ok((ids, true)) => crate::server::grpc_server::merge_ecj_ids(
state,
m.vid,
m.data_dir.clone(),
ecj_path.to_string(),
ids,
)
.await
.map(|_| ()),
Ok((_, false)) => Ok(()),
Err(e) => Err(e),
};
if let Err(e) = merged {
if let Err(e) = drain_copy_stream(resp.into_inner(), ecj_path, true).await {
tracing::warn!(volume_id = m.vid.0, peer = %peer, "copy .ecj: {}", e);
let _ = fs::remove_file(ecj_path);
}
}
Err(e) => tracing::warn!(volume_id = m.vid.0, peer = %peer, "copy .ecj: {}", e),
@@ -1817,7 +1782,7 @@ async fn fetch_ec_index_from_one_peer(
match client.copy_file(copy_req(".vif", true)).await {
Ok(resp) => {
if let Err(e) = drain_copy_stream(resp.into_inner(), vif_path).await {
if let Err(e) = drain_copy_stream(resp.into_inner(), vif_path, false).await {
tracing::warn!(volume_id = m.vid.0, peer = %peer, "copy .vif: {}", e);
}
}
@@ -1827,14 +1792,22 @@ async fn fetch_ec_index_from_one_peer(
Ok(())
}
/// Drain a CopyFile stream into a local file, truncating it first.
/// Drain a CopyFile stream into a local file, appending or truncating.
async fn drain_copy_stream(
mut stream: tonic::Streaming<crate::pb::volume_server_pb::CopyFileResponse>,
dest_path: &str,
append: bool,
) -> io::Result<()> {
use std::io::Write;
let mut file = fs::File::create(dest_path)
.map_err(|e| io::Error::other(format!("create {}: {}", dest_path, e)))?;
let mut file = if append {
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)))?;
while let Some(chunk) = stream
.message()
.await
@@ -63,12 +63,6 @@ pub struct VolumeServerState {
pub file_size_limit_bytes: i64,
/// Default IO rate limit for maintenance copy/replication work.
pub maintenance_byte_per_second: i64,
/// Bounds concurrent vacuum commits; None means unlimited. Queued commits
/// wait on the semaphore, not on the store lock, so reads keep flowing.
/// Mirrors Go's `vacuumCommitSlots`.
pub vacuum_commit_slots: Option<tokio::sync::Semaphore>,
/// Vacuum commits currently waiting for a slot.
pub vacuum_commits_waiting: AtomicU32,
/// Whether the server is connected to master (heartbeat active).
pub is_heartbeating: AtomicBool,
/// Whether master addresses are configured.
@@ -120,21 +114,6 @@ pub struct VolumeServerState {
pub cli_white_list: Vec<String>,
/// Path to state.pb file for persisting VolumeServerState across restarts.
pub state_file_path: String,
/// Volumes with an EC decode in flight. A dropped request leaves the
/// blocking job running; this keeps a retry from racing it on the
/// same volume files.
pub ec_decodes_in_flight:
std::sync::Mutex<std::collections::HashSet<crate::storage::types::VolumeId>>,
/// Volumes whose EC decode is in its publishing tail (journal catch-up,
/// .idx write, compaction). Local .ecj appenders wait on
/// `ec_decode_tail_notify` while their vid is listed, so no committed
/// delete falls between the last catch_up and the .cpd/.cpx swap —
/// the per-volume slice of Go's EcVolume.ecjFileAccessLock.
pub ec_decode_tail:
std::sync::Mutex<std::collections::HashSet<crate::storage::types::VolumeId>>,
/// Wakes .ecj appenders waiting on `ec_decode_tail` when a decode's
/// publishing tail ends.
pub ec_decode_tail_notify: tokio::sync::Notify,
}
impl VolumeServerState {
@@ -150,40 +129,6 @@ impl VolumeServerState {
Ok(())
}
/// Wait for a vacuum commit slot without touching the store lock; the wait
/// counts against the caller's `grpc-timeout`. An already-expired deadline
/// is refused even when a slot is free, matching Go's post-acquire
/// `ctx.Err()` check in `acquireVacuumCommitSlot`.
pub async fn acquire_vacuum_commit_slot(
&self,
deadline: Option<tokio::time::Instant>,
) -> Result<Option<tokio::sync::SemaphorePermit<'_>>, tonic::Status> {
let Some(slots) = &self.vacuum_commit_slots else {
return Ok(None);
};
self.vacuum_commits_waiting.fetch_add(1, Ordering::Relaxed);
let acquired = match deadline {
Some(d) => match tokio::time::timeout_at(d, slots.acquire()).await {
Ok(permit) => permit.map_err(|e| tonic::Status::internal(e.to_string())),
Err(_) => Err(tonic::Status::deadline_exceeded(
"timed out waiting for a vacuum commit slot",
)),
},
None => slots
.acquire()
.await
.map_err(|e| tonic::Status::internal(e.to_string())),
};
self.vacuum_commits_waiting.fetch_sub(1, Ordering::Relaxed);
let permit = acquired?;
if deadline.is_some_and(|d| tokio::time::Instant::now() >= d) {
return Err(tonic::Status::deadline_exceeded(
"timed out waiting for a vacuum commit slot",
));
}
Ok(Some(permit))
}
/// Build the seed master set from a list of raw `host:port[.grpcPort]`
/// addresses, normalised the same way Go's `pb.ServerAddress.ToHttpAddress`
/// does (drop the `.grpcPort` suffix, preserve everything else).
-5
View File
@@ -206,8 +206,6 @@ mod tests {
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: AtomicBool::new(false),
has_master: false,
pre_stop_seconds: 0,
@@ -231,9 +229,6 @@ mod tests {
security_file: String::new(),
cli_white_list: vec![],
state_file_path: String::new(),
ec_decodes_in_flight: std::sync::Mutex::new(std::collections::HashSet::new()),
ec_decode_tail: std::sync::Mutex::new(std::collections::HashSet::new()),
ec_decode_tail_notify: tokio::sync::Notify::new(),
})
}
+10 -26
View File
@@ -18,9 +18,7 @@ use crate::storage::erasure_coding::ec_shard::{
DATA_SHARDS_COUNT, ERASURE_CODING_LARGE_BLOCK_SIZE, ERASURE_CODING_SMALL_BLOCK_SIZE,
EcVolumeShard, ShardId,
};
use crate::storage::erasure_coding::ec_volume::{
ECJ_COMPACT_TMP_EXT, EcVolume, is_usable_ecx_file,
};
use crate::storage::erasure_coding::ec_volume::{EcVolume, is_usable_ecx_file};
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::super_block::SUPER_BLOCK_SIZE;
use crate::storage::types::*;
@@ -462,16 +460,13 @@ impl DiskLocation {
let idx_base = volume_file_name(&self.idx_directory, collection, vid);
const MAX_SHARD_COUNT: usize = 32;
// Remove index files from idx directory (.ecx, .ecj, and a compaction
// tmp a crash may have left beside the .ecj)
// Remove index files from idx directory (.ecx, .ecj)
rm_if_present(format!("{}.ecx", idx_base))?;
rm_if_present(format!("{}.ecj", idx_base))?;
rm_if_present(format!("{}{}", idx_base, ECJ_COMPACT_TMP_EXT))?;
// Also try data directory in case .ecx/.ecj were created before -dir.idx was configured
if self.idx_directory != self.directory {
rm_if_present(format!("{}.ecx", base))?;
rm_if_present(format!("{}.ecj", base))?;
rm_if_present(format!("{}{}", base, ECJ_COMPACT_TMP_EXT))?;
}
// Remove all EC shard files (.ec00 ~ .ec31)
@@ -620,20 +615,11 @@ impl DiskLocation {
return Err(v.compacting_error());
}
if let Some(mut v) = self.volumes.remove(&vid) {
match v.destroy(only_empty, only_garbage, keep_remote_data) {
Ok(()) => {
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&v.collection, "volume"])
.dec();
Ok(())
}
Err(e) => {
// A failed destroy left the local files in place, so the
// replica stays registered rather than becoming a phantom.
self.volumes.insert(vid, v);
Err(e)
}
}
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&v.collection, "volume"])
.dec();
v.destroy(only_empty, only_garbage, keep_remote_data)?;
Ok(())
} else {
Err(VolumeError::NotFound)
}
@@ -650,13 +636,11 @@ impl DiskLocation {
for vid in vids {
if let Some(mut v) = self.volumes.remove(&vid) {
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&v.collection, "volume"])
.dec();
if let Err(e) = v.destroy(false, false, false) {
warn!(volume_id = vid.0, error = %e, "delete collection: failed to destroy volume");
self.volumes.insert(vid, v);
} else {
crate::metrics::VOLUME_GAUGE
.with_label_values(&[&v.collection, "volume"])
.dec();
}
}
}
@@ -97,97 +97,21 @@ pub fn read_ecj_deletions(
collection: &str,
volume_id: VolumeId,
) -> io::Result<HashSet<NeedleId>> {
Ok(EcjDeletions::read(dirs, collection, volume_id)?.ids)
}
/// The ids [`read_ecj_deletions`] returns, plus how far each journal was read
/// so that ids journaled later can be added.
pub struct EcjDeletions {
pub ids: HashSet<NeedleId>,
/// Each distinct journal path and the whole-record length read so far.
journals: Vec<(String, u64)>,
}
impl EcjDeletions {
pub fn read(dirs: &[&str], collection: &str, volume_id: VolumeId) -> io::Result<Self> {
let mut journals: Vec<(String, u64)> = Vec::new();
for dir in dirs {
let path = format!("{}.ecj", volume_file_name(dir, collection, volume_id));
if !journals.iter().any(|(p, _)| *p == path) {
journals.push((path, 0));
}
let mut ids = HashSet::new();
for (i, dir) in dirs.iter().enumerate() {
if dirs[..i].contains(dir) {
continue;
}
let mut deletions = EcjDeletions {
ids: HashSet::new(),
journals,
let path = format!("{}.ecj", volume_file_name(dir, collection, volume_id));
let file = match File::open(&path) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
};
deletions.catch_up()?;
Ok(deletions)
}
/// Adds the ids appended to each journal since the last read. A journal
/// that shrank is read again from the start — and the whole set rebuilt,
/// since ids already folded in from the truncated tail may have been a
/// rolled-back append. Non-regular journals (the FIFOs the tests stand
/// in for a blocking disk) stat empty and cannot be rolled back, so they
/// never count as shrunk.
pub fn catch_up(&mut self) -> io::Result<()> {
let mut shrank = false;
for (path, read_to) in &self.journals {
if *read_to == 0 {
continue;
}
match std::fs::metadata(path) {
Ok(m) => {
if m.is_file() && m.len() < *read_to {
shrank = true;
break;
}
}
// A journal that was read before and is now gone shrank to
// nothing (e.g. the volume was destroyed mid-scan) — the ids
// read from it no longer reflect committed content.
Err(e) if e.kind() == io::ErrorKind::NotFound => {
shrank = true;
break;
}
Err(e) => return Err(e),
}
}
if shrank {
self.ids.clear();
for (_, read_to) in &mut self.journals {
*read_to = 0;
}
}
for (path, read_to) in &mut self.journals {
let file = match File::open(&*path) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
};
let len = file.metadata()?.len();
if len < *read_to {
*read_to = 0;
}
read_ecj_ids(&file, *read_to, len, &mut self.ids)?;
*read_to = len - len % NEEDLE_ID_SIZE as u64;
}
Ok(())
}
/// Clears the set and re-reads every journal from the start. Run under
/// the caller's store read lock (no append in flight): the result is
/// then exactly the committed content — an earlier unlocked read may
/// have folded in bytes a rolled-back append later truncated, or missed
/// a record re-appended to the very offset a rollback freed.
pub fn rescan(&mut self) -> io::Result<()> {
self.ids.clear();
for (_, read_to) in &mut self.journals {
*read_to = 0;
}
self.catch_up()
let len = file.metadata()?.len();
read_ecj_ids(&file, len, &mut ids)?;
}
Ok(ids)
}
/// What it takes to rebuild a volume's .dat from its EC data shards.
@@ -388,30 +312,6 @@ pub fn write_dat_file_from_shards(spec: &DatRebuild<'_>) -> io::Result<()> {
write_result
}
/// Fails when the decoded `.dat` in `dat_dir` is shorter than the
/// `dat_file_size` bytes its EC index references: the caller deletes the
/// shards next, and they are the only other copy of the needles past the cut.
/// A longer file passes.
pub fn verify_decoded_dat_file(
dat_dir: &str,
collection: &str,
volume_id: VolumeId,
dat_file_size: i64,
) -> io::Result<()> {
let dat_path = format!("{}.dat", volume_file_name(dat_dir, collection, volume_id));
let size = std::fs::metadata(&dat_path)?.len();
if (size as i64) < dat_file_size {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"decoded {} is {} bytes, short of the {} its ec index references",
dat_path, size, dat_file_size
),
));
}
Ok(())
}
/// Write .idx file from .ecx index + .ecj deletion journal.
///
/// See [`write_idx_file_from_ec_index_with_dirs`]; everything lives in `dir`.
@@ -890,77 +790,4 @@ mod tests {
let expected: HashSet<NeedleId> = [1, 2, 3, 7, 9].into_iter().map(NeedleId).collect();
assert_eq!(ids, expected);
}
#[test]
fn test_verify_decoded_dat_file_rejects_a_short_dat() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let dat_path = format!("{dir}/1.dat");
let err = verify_decoded_dat_file(dir, "", VolumeId(1), 100).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
std::fs::write(&dat_path, vec![0u8; 99]).unwrap();
let err = verify_decoded_dat_file(dir, "", VolumeId(1), 100).unwrap_err();
assert!(err.to_string().contains("short of the 100"), "{err}");
std::fs::write(&dat_path, vec![0u8; 100]).unwrap();
verify_decoded_dat_file(dir, "", VolumeId(1), 100).unwrap();
std::fs::write(&dat_path, vec![0u8; 101]).unwrap();
verify_decoded_dat_file(dir, "", VolumeId(1), 100).unwrap();
}
/// Ids appended after the first read, including the rest of a record torn
/// at that point, are picked up; a journal that shrank is read again.
#[test]
fn test_ecj_deletions_catch_up_reads_appended_ids() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let ecj_path = format!("{dir}/1.ecj");
let entry = |id: u64| {
let mut buf = [0u8; NEEDLE_ID_SIZE];
NeedleId(id).to_bytes(&mut buf);
buf
};
let append = |bytes: &[u8]| {
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&ecj_path)
.unwrap();
f.write_all(bytes).unwrap();
};
let ids = |d: &EcjDeletions| {
let mut ids: Vec<u64> = d.ids.iter().map(|id| id.0).collect();
ids.sort();
ids
};
// No journal yet.
let mut deletions = EcjDeletions::read(&[dir, dir], "", VolumeId(1)).unwrap();
assert!(deletions.ids.is_empty());
append(&entry(1));
append(&entry(2)[..3]);
deletions.catch_up().unwrap();
assert_eq!(ids(&deletions), [1]);
append(&entry(2)[3..]);
append(&entry(3));
deletions.catch_up().unwrap();
assert_eq!(ids(&deletions), [1, 2, 3]);
// A shrunk journal is rebuilt from its surviving content: ids folded
// in from the truncated tail may have been rolled back and must not
// linger as phantom tombstones.
std::fs::write(&ecj_path, entry(9)).unwrap();
deletions.catch_up().unwrap();
assert_eq!(ids(&deletions), [9]);
// A journal removed since it was read is the extreme shrink: its
// earlier ids must go with it, not linger.
std::fs::remove_file(&ecj_path).unwrap();
deletions.catch_up().unwrap();
assert!(deletions.ids.is_empty());
}
}
File diff suppressed because it is too large Load Diff
@@ -1,290 +0,0 @@
//! Set-union merge of `.ecj` deletion journals for EC shard copy / index
//! recovery. Mirrors Go's `weed/storage/erasure_coding/ecj_merge.go`.
//!
//! An EC volume's deletion journal (`<vid>.ecj`) is a *set* of deleted needle
//! ids stored as 8-byte big-endian records. Shard copy and index recovery fold
//! a peer's journal into the local one; they must append only the ids the
//! local journal lacks, or every `ec_balance` round trip doubles the file.
//!
//! The journal is only ever appended to, never replaced: a mounted `EcVolume`
//! holds it open, and a rename would leave that handle writing to an unlinked
//! inode, losing every later delete at the next mount. A mounted volume merges
//! through [`EcVolume::merge_journal`](super::ec_volume::EcVolume::merge_journal);
//! [`append_ecj_ids`] is for a journal no volume has open.
use std::collections::HashSet;
use std::fs::{self, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::Path;
use crate::storage::types::{NEEDLE_ID_SIZE, NeedleId};
use crate::storage::volume::fsync_dir;
use crate::storage::volume_open::open_volume_file;
/// Bytes per read when scanning a journal; a multiple of `NEEDLE_ID_SIZE`.
const ECJ_READ_CHUNK_BYTES: usize = 1 << 20;
/// Decodes `.ecj` records from a byte stream split at arbitrary boundaries (a
/// CopyFile stream, chunked reads), collecting the distinct ids. Memory
/// follows the number of distinct ids, not the journal's length. A trailing
/// partial record is never decoded.
#[derive(Default)]
pub(crate) struct EcjIdDecoder {
ids: HashSet<NeedleId>,
partial: [u8; NEEDLE_ID_SIZE],
pending: usize,
}
impl EcjIdDecoder {
pub(crate) fn push(&mut self, mut bytes: &[u8]) {
if self.pending > 0 {
let n = (NEEDLE_ID_SIZE - self.pending).min(bytes.len());
self.partial[self.pending..self.pending + n].copy_from_slice(&bytes[..n]);
self.pending += n;
bytes = &bytes[n..];
if self.pending < NEEDLE_ID_SIZE {
return;
}
self.ids.insert(NeedleId::from_bytes(&self.partial));
self.pending = 0;
}
let mut records = bytes.chunks_exact(NEEDLE_ID_SIZE);
for record in &mut records {
self.ids.insert(NeedleId::from_bytes(record));
}
let rest = records.remainder();
self.partial[..rest.len()].copy_from_slice(rest);
self.pending = rest.len();
}
pub(crate) fn into_ids(self) -> HashSet<NeedleId> {
self.ids
}
}
/// Read the distinct ids of the journal at `path` in bounded chunks. A missing
/// file reads as empty. Also returns the whole-record length read; a torn
/// trailing partial record is excluded from it.
pub(crate) fn read_ecj_ids(path: &str) -> io::Result<(HashSet<NeedleId>, u64)> {
let mut file = match fs::File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok((HashSet::new(), 0)),
Err(e) => return Err(e),
};
let len = file.metadata()?.len();
let size = len - len % NEEDLE_ID_SIZE as u64;
let mut decoder = EcjIdDecoder::default();
let mut buf = vec![0u8; (ECJ_READ_CHUNK_BYTES as u64).min(size) as usize];
let mut off = 0u64;
while off < size {
let want = (buf.len() as u64).min(size - off) as usize;
file.read_exact(&mut buf[..want])?;
decoder.push(&buf[..want]);
off += want as u64;
}
Ok((decoder.into_ids(), size))
}
/// The ids of `incoming` that `has` does not report, ascending, so a merge
/// appends deterministic output.
pub(crate) fn ecj_delta(
incoming: &HashSet<NeedleId>,
has: impl Fn(&NeedleId) -> bool,
) -> Vec<NeedleId> {
let mut delta: Vec<NeedleId> = incoming.iter().copied().filter(|id| !has(id)).collect();
delta.sort_unstable();
delta
}
pub(crate) fn encode_ecj_ids(ids: &[NeedleId]) -> Vec<u8> {
let mut buf = vec![0u8; ids.len() * NEEDLE_ID_SIZE];
for (record, id) in buf.chunks_exact_mut(NEEDLE_ID_SIZE).zip(ids) {
id.to_bytes(record);
}
buf
}
/// Append to the journal at `path` the ids of `incoming` that `local` lacks,
/// in one write and one fsync, returning how many were added. `local` and
/// `size` come from [`read_ecj_ids`] on the same path: if the journal's
/// whole-record length is no longer `size`, returns `Ok(None)` so the caller
/// re-reads. A torn tail past `size` is truncated first so the new records
/// stay aligned.
pub(crate) fn append_ecj_ids(
path: &str,
local: &HashSet<NeedleId>,
incoming: &HashSet<NeedleId>,
size: u64,
) -> io::Result<Option<usize>> {
let delta = ecj_delta(incoming, |id| local.contains(id));
if delta.is_empty() {
return Ok(Some(0));
}
let created = !Path::new(path).exists();
let mut file = open_volume_file(OpenOptions::new().read(true).write(true).create(true), path)?;
let len = file.metadata()?.len();
if len - len % NEEDLE_ID_SIZE as u64 != size {
return Ok(None);
}
if len != size {
file.set_len(size)?;
}
let appended = file
.seek(SeekFrom::Start(size))
.and_then(|_| file.write_all(&encode_ecj_ids(&delta)))
.and_then(|_| file.sync_all());
if let Err(e) = appended {
let _ = file.set_len(size);
return Err(e);
}
if created {
fsync_dir(path)?;
}
Ok(Some(delta.len()))
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(v: &[u64]) -> HashSet<NeedleId> {
v.iter().map(|&id| NeedleId(id)).collect()
}
fn bytes(v: &[u64]) -> Vec<u8> {
encode_ecj_ids(&v.iter().map(|&id| NeedleId(id)).collect::<Vec<_>>())
}
fn records(path: &str) -> Vec<u64> {
let data = fs::read(path).expect("read ecj");
assert_eq!(data.len() % NEEDLE_ID_SIZE, 0, "journal must stay aligned");
data.chunks_exact(NEEDLE_ID_SIZE)
.map(|c| NeedleId::from_bytes(c).0)
.collect()
}
/// Run the unmounted merge the way the server does: read, then append.
fn merge_file(path: &str, incoming: &HashSet<NeedleId>) -> usize {
let (local, size) = read_ecj_ids(path).expect("read");
append_ecj_ids(path, &local, incoming, size)
.expect("append")
.expect("journal unchanged")
}
#[test]
fn decoder_handles_records_split_across_chunks() {
let mut stream = bytes(&[1, 2, 3, 2, 0x0102030405060708]);
stream.extend_from_slice(&[9, 9, 9]);
for chunk in 1..=stream.len() {
let mut d = EcjIdDecoder::default();
for piece in stream.chunks(chunk) {
d.push(piece);
}
assert_eq!(
d.into_ids(),
ids(&[1, 2, 3, 0x0102030405060708]),
"chunk {}",
chunk
);
}
}
#[test]
fn read_ecj_ids_dedups_and_ignores_torn_tail() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("missing.ecj");
let (got, size) = read_ecj_ids(missing.to_str().unwrap()).expect("read");
assert!(got.is_empty());
assert_eq!(size, 0);
let torn = dir.path().join("torn.ecj");
let mut data = bytes(&[1, 2, 1]);
data.extend_from_slice(&[7, 7, 7]);
fs::write(&torn, data).unwrap();
let (got, size) = read_ecj_ids(torn.to_str().unwrap()).expect("read");
assert_eq!(got, ids(&[1, 2]));
assert_eq!(size, 3 * NEEDLE_ID_SIZE as u64);
// A bloated journal repeating a few ids across several read chunks
// keeps only the distinct ids.
let bloated = dir.path().join("bloated.ecj");
let data: Vec<u8> = (0..300_000u64).flat_map(|i| bytes(&[i % 3])).collect();
fs::write(&bloated, &data).unwrap();
let (got, size) = read_ecj_ids(bloated.to_str().unwrap()).expect("read");
assert_eq!(got, ids(&[0, 1, 2]));
assert_eq!(size, data.len() as u64);
}
#[test]
fn appends_only_missing_ids() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("vol.ecj");
let path = path.to_str().unwrap();
fs::write(path, bytes(&[1, 2, 3])).unwrap();
assert_eq!(merge_file(path, &ids(&[3, 4])), 1);
assert_eq!(records(path), vec![1, 2, 3, 4]);
}
#[test]
fn round_trip_stays_constant() {
// A->B->A->B 20 times: the old append path doubled the journal each trip.
let dir = tempfile::tempdir().expect("tempdir");
let a = dir.path().join("a.ecj");
let b = dir.path().join("b.ecj");
let (a, b) = (a.to_str().unwrap(), b.to_str().unwrap());
fs::write(a, bytes(&[1, 2])).unwrap();
fs::write(b, bytes(&[2, 3])).unwrap();
for i in 0..20 {
let (src, dst) = if i % 2 == 0 { (a, b) } else { (b, a) };
let (incoming, _) = read_ecj_ids(src).expect("read");
merge_file(dst, &incoming);
}
for path in [a, b] {
let mut got = records(path);
got.sort_unstable();
assert_eq!(got, vec![1, 2, 3]);
}
}
#[test]
fn nothing_new_leaves_journal_alone() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("missing.ecj");
assert_eq!(merge_file(missing.to_str().unwrap(), &ids(&[])), 0);
assert!(
!missing.exists(),
"an empty merge must not create a journal"
);
let path = dir.path().join("vol.ecj");
let path = path.to_str().unwrap();
fs::write(path, bytes(&[1, 2])).unwrap();
assert_eq!(merge_file(path, &ids(&[2, 1])), 0);
assert_eq!(records(path), vec![1, 2]);
}
#[test]
fn repairs_torn_tail() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("vol.ecj");
let path = path.to_str().unwrap();
let mut data = bytes(&[1, 2]);
data.extend_from_slice(&[9, 9, 9]);
fs::write(path, data).unwrap();
assert_eq!(merge_file(path, &ids(&[3])), 1);
assert_eq!(records(path), vec![1, 2, 3]);
}
#[test]
fn rejects_changed_journal() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("vol.ecj");
let path = path.to_str().unwrap();
fs::write(path, bytes(&[1])).unwrap();
let (local, size) = read_ecj_ids(path).expect("read");
fs::write(path, bytes(&[1, 5])).unwrap();
let outcome = append_ecj_ids(path, &local, &ids(&[2]), size).expect("append");
assert_eq!(outcome, None);
assert_eq!(records(path), vec![1, 5]);
}
}
@@ -1,326 +0,0 @@
//! Process-wide coordination of everything that touches one `.ecj` path.
//!
//! Mount-time compaction replaces a deletion journal with a new inode. That is
//! only safe while nothing else in this process can write the old one:
//!
//! - **Holders** are mounted `EcVolume`s with an append handle on the path. A
//! store holds one `EcVolume` per disk location, and a shard mount or a
//! cross-disk reconcile can point one disk's volume at another disk's
//! `.ecj`, so several holders of one path are normal. A holder that keeps
//! appending to a replaced inode acknowledges deletes that are gone at the
//! next mount.
//! - **Writers** append to or replace the path by name without holding it
//! open across calls: `ReceiveFile` of an EC `.ecj`, and the unmounted
//! append in `merge_ec_journal`, which `VolumeEcShardsCopy` and EC index
//! recovery funnel a peer's journal through. Bytes they write after the
//! compactor sized the journal would be dropped by the rename.
//!
//! Compaction therefore runs only while its caller is the sole holder and no
//! writer is active, and while it runs no holder may open the path and no
//! writer may start. Both wait instead; a compaction rewrites only the distinct
//! id set, so the wait is short.
//!
//! No writer active at the reservation is not enough: one that ran while the
//! holder loaded the journal, or after, and has finished may have rewritten it
//! in place to the same length (`ReceiveFile` truncates and refills), which
//! the inode-and-size re-check cannot see. So each writer bumps the path's
//! write generation as it starts, and a holder may compact only if no writer
//! was active when it registered and the generation has not moved since.
//!
//! Paths are keyed by their canonical parent directory, so two disk locations
//! that spell one directory differently still meet here.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Condvar, LazyLock, Mutex, MutexGuard};
#[derive(Default)]
struct PathState {
holders: usize,
writers: usize,
compacting: bool,
/// Writers that have started on the path. Lives as long as the entry,
/// which a registered holder keeps.
write_gen: u64,
}
impl PathState {
fn idle(&self) -> bool {
self.holders == 0 && self.writers == 0 && !self.compacting
}
}
struct Registry {
paths: Mutex<HashMap<PathBuf, PathState>>,
changed: Condvar,
}
static REGISTRY: LazyLock<Registry> = LazyLock::new(|| Registry {
paths: Mutex::new(HashMap::new()),
changed: Condvar::new(),
});
fn lock() -> MutexGuard<'static, HashMap<PathBuf, PathState>> {
// The critical sections only adjust counters and cannot panic midway, so
// a poisoned lock still guards consistent state.
REGISTRY.paths.lock().unwrap_or_else(|e| e.into_inner())
}
/// Canonical key for `path`: its resolved parent directory joined with the
/// file name. The file itself may not exist yet (a copy creates it), so only
/// the directory is resolved.
fn key_for(path: &str) -> PathBuf {
let p = Path::new(path);
let (Some(parent), Some(name)) = (p.parent(), p.file_name()) else {
return std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
};
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
let dir = std::fs::canonicalize(parent)
.or_else(|_| std::path::absolute(parent))
.unwrap_or_else(|_| parent.to_path_buf());
dir.join(name)
}
/// Block until no compaction is running on `key`, then apply `f` to its state.
fn update_when_not_compacting<R>(key: &Path, f: impl FnOnce(&mut PathState) -> R) -> R {
let mut paths = lock();
while paths.get(key).is_some_and(|s| s.compacting) {
paths = REGISTRY
.changed
.wait(paths)
.unwrap_or_else(|e| e.into_inner());
}
f(paths.entry(key.to_path_buf()).or_default())
}
fn release(key: &Path, f: impl FnOnce(&mut PathState)) {
let mut paths = lock();
if let Some(state) = paths.get_mut(key) {
f(state);
if state.idle() {
paths.remove(key);
}
}
drop(paths);
REGISTRY.changed.notify_all();
}
/// A mounted `EcVolume`'s registration as a holder of its `.ecj`. Taken before
/// the journal is opened and released when dropped.
pub(crate) struct EcjHold {
key: PathBuf,
/// The path's write generation when the hold was taken, and whether a
/// writer was active then. Taken before the journal is opened and loaded,
/// so they cover every write the load might have missed.
write_gen: u64,
writer_at_start: bool,
}
impl EcjHold {
/// Register as a holder of `ecj_path`, first waiting out any compaction in
/// progress so the handle opened afterwards is on the final inode.
pub(crate) fn acquire(ecj_path: &str) -> Self {
let key = key_for(ecj_path);
let (write_gen, writer_at_start) = update_when_not_compacting(&key, |s| {
s.holders += 1;
(s.write_gen, s.writers > 0)
});
EcjHold {
key,
write_gen,
writer_at_start,
}
}
/// Reserve the path for a compaction, or `None` when another holder or an
/// active writer could still reach the current inode, or when a writer
/// has run on the path since the hold was taken, so the journal may no
/// longer be what the holder loaded.
pub(crate) fn try_begin_compaction(&self) -> Option<EcjCompaction> {
let mut paths = lock();
let state = paths.get_mut(&self.key)?;
if state.holders != 1 || state.writers != 0 || state.compacting {
return None;
}
if self.writer_at_start || state.write_gen != self.write_gen {
return None;
}
state.compacting = true;
Some(EcjCompaction {
key: self.key.clone(),
})
}
}
impl Drop for EcjHold {
fn drop(&mut self) {
release(&self.key, |s| s.holders = s.holders.saturating_sub(1));
}
}
/// An exclusive reservation of a `.ecj` path for compaction. Holders and
/// writers wait until it is dropped.
pub(crate) struct EcjCompaction {
key: PathBuf,
}
impl Drop for EcjCompaction {
fn drop(&mut self) {
release(&self.key, |s| s.compacting = false);
}
}
/// An out-of-band writer (shard copy, index recovery, `ReceiveFile`) on a
/// `.ecj` path.
/// Compaction does not start while one is alive.
pub(crate) struct EcjWrite {
key: PathBuf,
}
impl Drop for EcjWrite {
fn drop(&mut self) {
release(&self.key, |s| s.writers = s.writers.saturating_sub(1));
}
}
/// Register as a writer of `ecj_path`, waiting out any compaction in progress.
/// Blocks; async callers use [`begin_ecj_write_async`].
pub(crate) fn begin_ecj_write(ecj_path: &str) -> EcjWrite {
let key = key_for(ecj_path);
update_when_not_compacting(&key, |s| {
s.writers += 1;
s.write_gen += 1;
});
EcjWrite { key }
}
/// [`begin_ecj_write`] for async handlers: the wait runs on the blocking pool
/// so a compaction in progress never stalls a runtime worker.
pub(crate) async fn begin_ecj_write_async(ecj_path: &str) -> EcjWrite {
let path = ecj_path.to_string();
match tokio::task::spawn_blocking(move || begin_ecj_write(&path)).await {
Ok(write) => write,
// Only a panic inside the registry lands here, and it leaves no count
// behind; registering inline is still correct, merely blocking.
Err(_) => begin_ecj_write(ecj_path),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::time::Duration;
use tempfile::TempDir;
fn ecj(dir: &TempDir) -> String {
dir.path().join("1.ecj").to_str().unwrap().to_string()
}
#[test]
fn sole_holder_may_compact() {
let dir = TempDir::new().unwrap();
let hold = EcjHold::acquire(&ecj(&dir));
assert!(hold.try_begin_compaction().is_some());
}
#[test]
fn second_holder_blocks_compaction() {
let dir = TempDir::new().unwrap();
let a = EcjHold::acquire(&ecj(&dir));
let b = EcjHold::acquire(&ecj(&dir));
assert!(a.try_begin_compaction().is_none());
drop(b);
assert!(a.try_begin_compaction().is_some());
}
#[test]
fn active_writer_blocks_compaction() {
let dir = TempDir::new().unwrap();
let hold = EcjHold::acquire(&ecj(&dir));
let w = begin_ecj_write(&ecj(&dir));
assert!(hold.try_begin_compaction().is_none());
drop(w);
// This hold loaded before the write; a later one may compact.
drop(hold);
let hold = EcjHold::acquire(&ecj(&dir));
assert!(hold.try_begin_compaction().is_some());
}
/// A writer that ran after the hold was taken, or was already running
/// then, may have changed the journal the holder loaded, even though it
/// has finished by the time compaction asks.
#[test]
fn finished_writer_since_hold_blocks_compaction() {
let dir = TempDir::new().unwrap();
let path = ecj(&dir);
let hold = EcjHold::acquire(&path);
drop(begin_ecj_write(&path));
assert!(
hold.try_begin_compaction().is_none(),
"a writer that started after the hold",
);
drop(hold);
let w = begin_ecj_write(&path);
let hold = EcjHold::acquire(&path);
drop(w);
assert!(
hold.try_begin_compaction().is_none(),
"a writer active when the hold was taken",
);
drop(hold);
let hold = EcjHold::acquire(&path);
assert!(
hold.try_begin_compaction().is_some(),
"no writer since the hold",
);
}
#[test]
fn differently_spelled_paths_share_one_key() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
let plain = ecj(&dir);
let dotted = dir
.path()
.join("sub")
.join("..")
.join("1.ecj")
.to_str()
.unwrap()
.to_string();
let a = EcjHold::acquire(&plain);
let _b = EcjHold::acquire(&dotted);
assert!(a.try_begin_compaction().is_none());
}
#[test]
fn writer_waits_for_compaction_to_finish() {
let dir = TempDir::new().unwrap();
let path = ecj(&dir);
let hold = EcjHold::acquire(&path);
let compaction = hold.try_begin_compaction().unwrap();
let (tx, rx) = mpsc::channel();
let p = path.clone();
let t = std::thread::spawn(move || {
let _w = begin_ecj_write(&p);
tx.send(()).unwrap();
});
assert!(
rx.recv_timeout(Duration::from_millis(100)).is_err(),
"a writer must not start while a compaction holds the path",
);
drop(compaction);
rx.recv_timeout(Duration::from_secs(5))
.expect("writer must proceed once the compaction ends");
t.join().unwrap();
}
}
@@ -9,8 +9,6 @@ pub mod ec_encoder;
pub mod ec_locate;
pub mod ec_shard;
pub mod ec_volume;
pub mod ecj_merge;
pub(crate) mod ecj_registry;
pub use ec_shard::{
DATA_SHARDS_COUNT, EcVolumeShard, MAX_SHARD_COUNT, MIN_TOTAL_DISKS, PARITY_SHARDS_COUNT,
-1
View File
@@ -6,7 +6,6 @@ pub(crate) mod io_error;
pub mod needle;
pub mod needle_map;
pub mod store;
pub mod store_ec_journal;
pub mod store_ec_mirror;
pub mod store_ec_reconcile;
pub mod super_block;
+12 -98
View File
@@ -195,12 +195,7 @@ impl NeedleMapKind {
// ============================================================================
/// Trait for appending to an index file.
///
/// The file is opened without append mode and each row is written at the
/// current `idx_file_offset` — the same positioned-write model the Go
/// server uses — because an append-mode handle cannot truncate on Windows,
/// where the std library keeps it strictly append-only.
pub trait IdxFileWriter: Write + Seek + Send + Sync {
pub trait IdxFileWriter: Write + Send + Sync {
fn sync_all(&self) -> io::Result<()>;
/// Truncate the file to `len` bytes. Used to remove an orphan .idx row
/// left by a failed redb commit so `idx_file_offset` stays a contiguous
@@ -229,9 +224,6 @@ pub struct CompactNeedleMap {
metric: NeedleMapMetric,
idx_file: Option<Box<dyn IdxFileWriter>>,
idx_file_offset: u64,
/// The file holds bytes past `idx_file_offset` that must be trimmed
/// before another row can land aligned.
idx_torn: bool,
}
impl Default for CompactNeedleMap {
@@ -248,7 +240,6 @@ impl CompactNeedleMap {
metric: NeedleMapMetric::default(),
idx_file: None,
idx_file_offset: 0,
idx_torn: false,
}
}
@@ -273,7 +264,6 @@ impl CompactNeedleMap {
pub fn set_idx_file(&mut self, file: Box<dyn IdxFileWriter>, offset: u64) {
self.idx_file = Some(file);
self.idx_file_offset = offset;
self.idx_torn = false;
}
/// True when an .idx file writer is attached. A read-only load leaves
@@ -288,8 +278,8 @@ impl CompactNeedleMap {
/// Insert or update an entry. Appends to .idx file if present.
pub fn put(&mut self, key: NeedleId, offset: Offset, size: Size) -> io::Result<()> {
// Persist to idx file BEFORE mutating in-memory state for crash consistency
self.append_to_index_file(key, offset, size)?;
if self.idx_file.is_some() {
if let Some(ref mut idx_file) = self.idx_file {
idx::write_index_entry(idx_file, key, offset, size)?;
self.idx_file_offset += NEEDLE_MAP_ENTRY_SIZE as u64;
}
@@ -299,41 +289,6 @@ impl CompactNeedleMap {
Ok(())
}
/// Write one row to the .idx file at `idx_file_offset`. A row left
/// half-written by a failed write is trimmed back to the offset so the
/// next row still lands aligned; while the trim keeps failing no row is
/// written at all, or it would sit off alignment and parse as garbage on
/// load. The offset itself is advanced by the caller once the row counts.
fn append_to_index_file(
&mut self,
key: NeedleId,
offset: Offset,
size: Size,
) -> io::Result<()> {
let Some(idx_file) = self.idx_file.as_mut() else {
return Ok(());
};
if self.idx_torn {
match idx_file.truncate_to(self.idx_file_offset) {
Ok(()) => self.idx_torn = false,
Err(e) => {
return Err(io::Error::other(format!(
"index file still holds a torn row: {e}"
)));
}
}
}
idx_file.seek(io::SeekFrom::Start(self.idx_file_offset))?;
if let Err(e) = idx::write_index_entry(idx_file, key, offset, size) {
if let Err(te) = idx_file.truncate_to(self.idx_file_offset) {
self.idx_torn = true;
tracing::warn!("failed to trim torn .idx row: {}", te);
}
return Err(e);
}
Ok(())
}
/// Look up a needle.
pub fn get(&self, key: NeedleId) -> Option<NeedleValue> {
self.map.get(key)
@@ -358,8 +313,8 @@ impl CompactNeedleMap {
}
// Always write tombstone to idx file (matching Go)
self.append_to_index_file(key, offset, TOMBSTONE_FILE_SIZE)?;
if self.idx_file.is_some() {
if let Some(ref mut idx_file) = self.idx_file {
idx::write_index_entry(idx_file, key, offset, TOMBSTONE_FILE_SIZE)?;
self.idx_file_offset += NEEDLE_MAP_ENTRY_SIZE as u64;
}
@@ -509,9 +464,6 @@ pub struct RedbNeedleMap {
metric: NeedleMapMetric,
idx_file: Option<Box<dyn IdxFileWriter>>,
idx_file_offset: u64,
/// The file holds bytes past `idx_file_offset` that must be trimmed
/// before another row can land aligned.
idx_torn: bool,
/// Puts/deletes since the last durable checkpoint.
writes_since_checkpoint: u32,
}
@@ -618,7 +570,6 @@ impl RedbNeedleMap {
metric: NeedleMapMetric::default(),
idx_file: None,
idx_file_offset: 0,
idx_torn: false,
writes_since_checkpoint: 0,
})
}
@@ -716,7 +667,6 @@ impl RedbNeedleMap {
metric: NeedleMapMetric::default(),
idx_file: None,
idx_file_offset: 0,
idx_torn: false,
writes_since_checkpoint: 0,
};
@@ -908,7 +858,6 @@ impl RedbNeedleMap {
pub fn set_idx_file(&mut self, file: Box<dyn IdxFileWriter>, offset: u64) {
self.idx_file = Some(file);
self.idx_file_offset = offset;
self.idx_torn = false;
}
/// True when an .idx file writer is attached. See CompactNeedleMap.
@@ -925,7 +874,9 @@ impl RedbNeedleMap {
// commit leaves an orphan row in .idx that redb doesn't reflect, and
// advancing the offset here would let a later checkpoint record it as
// reflected, making the reload skip it permanently.
self.append_to_index_file(key, offset, size)?;
if let Some(ref mut idx_file) = self.idx_file {
idx::write_index_entry(idx_file, key, offset, size)?;
}
let key_u64: u64 = key.into();
let packed = pack_needle_value(&NeedleValue { offset, size });
@@ -996,41 +947,6 @@ impl RedbNeedleMap {
Ok(())
}
/// Write one row to the .idx file at `idx_file_offset`. A row left
/// half-written by a failed write is trimmed back to the offset so the
/// next row still lands aligned; while the trim keeps failing no row is
/// written at all, or it would sit off alignment and parse as garbage on
/// load. The offset itself is advanced by the caller once the row counts.
fn append_to_index_file(
&mut self,
key: NeedleId,
offset: Offset,
size: Size,
) -> io::Result<()> {
let Some(idx_file) = self.idx_file.as_mut() else {
return Ok(());
};
if self.idx_torn {
match idx_file.truncate_to(self.idx_file_offset) {
Ok(()) => self.idx_torn = false,
Err(e) => {
return Err(io::Error::other(format!(
"index file still holds a torn row: {e}"
)));
}
}
}
idx_file.seek(io::SeekFrom::Start(self.idx_file_offset))?;
if let Err(e) = idx::write_index_entry(idx_file, key, offset, size) {
if let Err(te) = idx_file.truncate_to(self.idx_file_offset) {
self.idx_torn = true;
tracing::warn!("failed to trim torn .idx row: {}", te);
}
return Err(e);
}
Ok(())
}
/// Look up a needle. A redb failure is an ERROR, not an absent needle:
/// answering "not found" would turn a database problem into a read miss
/// and let a delete report success without recording a tombstone.
@@ -1077,7 +993,9 @@ impl RedbNeedleMap {
return Ok(None);
};
self.append_to_index_file(key, offset, TOMBSTONE_FILE_SIZE)?;
if let Some(ref mut idx_file) = self.idx_file {
idx::write_index_entry(idx_file, key, offset, TOMBSTONE_FILE_SIZE)?;
}
let deleted_nv = NeedleValue {
offset: old.offset,
@@ -1167,13 +1085,10 @@ impl RedbNeedleMap {
/// a failed redb commit. Without this the next successful write appends
/// after the orphan, `idx_file_offset` advances past it, and a later
/// checkpoint records an offset that makes the reload skip the orphan.
/// When the trim fails the file is latched torn so no later row lands
/// after bytes the map does not reflect.
fn truncate_idx_to_offset(&mut self) {
if let Some(ref mut idx_file) = self.idx_file
&& let Err(e) = idx_file.truncate_to(self.idx_file_offset)
{
self.idx_torn = true;
tracing::warn!("failed to truncate orphan .idx row: {}", e);
}
}
@@ -1224,7 +1139,6 @@ impl RedbNeedleMap {
self.db = reopened.db;
self.metric = reopened.metric;
self.idx_file_offset = actual_idx_size;
self.idx_torn = reopened.idx_torn;
// The reopen replayed all rows since the last durable checkpoint
// non-durably; start the counter fresh.
self.writes_since_checkpoint = 0;
@@ -1777,7 +1691,7 @@ mod tests {
)
.unwrap();
let writer = std::fs::OpenOptions::new()
.write(true)
.append(true)
.open(&idx_path)
.unwrap();
nm.set_idx_file(Box::new(writer), idx_size);
+39 -189
View File
@@ -13,45 +13,13 @@ use crate::config::MinFreeSpace;
use crate::pb::master_pb;
use crate::storage::disk_location::DiskLocation;
use crate::storage::erasure_coding::ec_shard::{EcVolumeShard, MAX_SHARD_COUNT, ShardId};
use crate::storage::erasure_coding::ec_volume::{
ECJ_COMPACT_TMP_EXT, EcVolume, is_usable_ecx_file,
};
use crate::storage::erasure_coding::ec_volume::{EcVolume, is_usable_ecx_file};
use crate::storage::needle::needle::{Needle, get_actual_size};
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::super_block::{ReplicaPlacement, SUPER_BLOCK_SIZE};
use crate::storage::types::*;
use crate::storage::volume::{CompactionJob, VifVolumeInfo, Volume, VolumeError, VolumeSpec};
/// Mirrors Go's ensureCompactVolumeSpace, per filesystem: the new .dat lands
/// next to the old one and the new .idx next to the old index, so when the
/// index directory is on another filesystem each disk answers for its own
/// share, while two directories on one filesystem must cover the sum.
fn ensure_compact_volume_space(v: &Volume, preallocate: u64) -> Result<(), VolumeError> {
let (data_bytes, index_bytes) = compaction_space_needed(v, preallocate);
let (dir, dir_idx) = (v.dir(), v.dir_idx());
let check = |dir: &str, needed: u64| -> Result<(), VolumeError> {
let (_, free) = crate::storage::disk_location::get_disk_stats(dir);
if free < needed {
return Err(VolumeError::InsufficientSpace {
vid: v.id,
required: needed,
free,
});
}
Ok(())
};
if dir_idx.is_empty() || dir_idx == dir {
check(dir, data_bytes + index_bytes)
} else if !same_filesystem(dir, dir_idx) {
check(dir, data_bytes)?;
check(dir_idx, index_bytes)
} else {
check(dir, data_bytes + index_bytes)?;
check(dir_idx, index_bytes)
}
}
/// Top-level storage manager containing all disk locations and their volumes.
pub struct Store {
pub locations: Vec<DiskLocation>,
@@ -736,20 +704,6 @@ impl Store {
vol.read_needle(n)
}
/// Read a needle from a volume using the given read option.
///
/// On return, `read_option.is_meta_only` tells the caller whether the
/// payload was skipped.
pub fn read_volume_needle_with_option(
&self,
vid: VolumeId,
n: &mut Needle,
read_option: &mut ReadOption,
) -> Result<i32, VolumeError> {
let (_, vol) = self.find_volume(vid).ok_or(VolumeError::NotFound)?;
vol.read_needle_with_option(n, read_option)
}
/// Read a needle from a volume, optionally reading deleted needles.
pub fn read_volume_needle_opt(
&self,
@@ -772,6 +726,17 @@ impl Store {
vol.needle_read_plan(id, read_deleted)
}
/// Re-lookup a needle's data-file offset after compaction may have moved it.
/// Returns `(new_data_file_offset, current_compaction_revision)`.
pub fn re_lookup_needle_data_offset(
&self,
vid: VolumeId,
needle_id: NeedleId,
) -> Result<(u64, u16), VolumeError> {
let (_, vol) = self.find_volume(vid).ok_or(VolumeError::NotFound)?;
vol.re_lookup_needle_data_offset(needle_id)
}
/// Write a needle to a volume. With `fsync` the volume flushes its .dat
/// before returning, so the caller can ack a durable write.
pub fn write_volume_needle(
@@ -1480,12 +1445,10 @@ impl Store {
crate::storage::volume::volume_file_name(&loc.directory, collection, vid);
let _ = std::fs::remove_file(format!("{}.ecx", idx_base));
let _ = std::fs::remove_file(format!("{}.ecj", idx_base));
let _ = std::fs::remove_file(format!("{}{}", idx_base, ECJ_COMPACT_TMP_EXT));
// Also try data directory in case .ecx/.ecj were created before -dir.idx
if loc.idx_directory != loc.directory {
let _ = std::fs::remove_file(format!("{}.ecx", data_base));
let _ = std::fs::remove_file(format!("{}.ecj", data_base));
let _ = std::fs::remove_file(format!("{}{}", data_base, ECJ_COMPACT_TMP_EXT));
}
// A shard-only disk also drops its stale .vif (Go
// removeEcSharedIndexFiles): a live .idx means this disk still
@@ -1601,10 +1564,33 @@ impl Store {
vid: VolumeId,
preallocate: u64,
) -> Result<Option<CompactionJob>, VolumeError> {
let (_, v) = self
.find_volume(vid)
.ok_or(VolumeError::VolumeNotFound(vid))?;
ensure_compact_volume_space(v, preallocate)?;
// Required space matches Go's CompactVolume check: the larger of the
// requested preallocation and the estimated compacted size — the live
// needles, not the .dat the garbage already occupies, so a full disk
// can still be reclaimed.
let (loc_idx, space_needed) = {
let (loc_idx, v) = self
.find_volume(vid)
.ok_or(VolumeError::VolumeNotFound(vid))?;
let live_count = (v.file_count() - v.deleted_count()).max(0) as u64;
let live_bytes = v.content_size().saturating_sub(v.deleted_size());
let per_needle = (get_actual_size(Size(0), v.version())
+ NEEDLE_PADDING_SIZE as i64
+ NEEDLE_MAP_ENTRY_SIZE as i64) as u64;
let estimated = SUPER_BLOCK_SIZE as u64 + live_count * per_needle + live_bytes;
let space_needed = std::cmp::max(preallocate, estimated);
(loc_idx, space_needed + space_needed / 10)
};
let dir = self.locations[loc_idx].directory.clone();
let (_, free) = crate::storage::disk_location::get_disk_stats(&dir);
if free < space_needed {
return Err(VolumeError::InsufficientSpace {
vid,
required: space_needed,
free,
});
}
let (_, v) = self
.find_volume_mut(vid)
@@ -1612,37 +1598,6 @@ impl Store {
v.begin_compact_by_index()
}
/// Rewrite the volume in `dir`/`dir_idx`, which is not mounted, with its
/// live needles only. Go's `Store.CompactVolumeFiles`.
pub fn compact_volume_files(
dir: &str,
dir_idx: &str,
collection: &str,
vid: VolumeId,
needle_map_kind: NeedleMapKind,
) -> Result<(), VolumeError> {
let spec = VolumeSpec {
collection,
..VolumeSpec::default()
};
let mut v = Volume::new(dir, dir_idx, vid, needle_map_kind, &spec)?;
let mut compact = || -> Result<(), VolumeError> {
ensure_compact_volume_space(&v, 0)?;
v.compact_by_index(0, 0, |_| true)?;
v.commit_compact()
};
let result = compact();
if result.is_err() {
// A failed commit may have swapped only one of .dat/.idx;
// reconcile rolls a decided swap forward or removes orphan
// temp files before this volume can mount a mismatched pair.
let _ = v.reconcile_compact_state();
let _ = v.cleanup_compact();
}
v.close();
result
}
/// Commit a completed compaction: swap files and reload.
pub fn commit_compact_volume(&mut self, vid: VolumeId) -> Result<(bool, u64), VolumeError> {
let (_, v) = self
@@ -1807,65 +1762,6 @@ fn owned_ec_shard_count(loc: &DiskLocation, vid: VolumeId, shard_ids: &[ShardId]
.count()
}
/// Mirrors Go's compactionSpaceNeeded: what compaction will write, split into
/// the new .dat and rebuilt .idx shares, each capped at its current file.
fn compaction_space_needed(v: &Volume, preallocate: u64) -> (u64, u64) {
let mut data_bytes = v.current_dat_file_size().unwrap_or(0);
let mut index_bytes = v.idx_file_size();
let live_count = v.file_count() - v.deleted_count();
let live_content = v.content_size() as i64 - v.deleted_size() as i64;
// Unknown or inconsistent deleted sizes: the whole volume stays the
// estimate.
let deleted_size_known = v.deleted_count() == 0 || v.deleted_size() > 0;
if deleted_size_known && live_count >= 0 && live_content >= 0 {
// Empty-needle framing plus a padding unit covers the worst case.
let per_needle =
(get_actual_size(Size(0), v.version()) + NEEDLE_PADDING_SIZE as i64) as u64;
let estimate = with_headroom(
SUPER_BLOCK_SIZE as u64 + live_content as u64 + live_count as u64 * per_needle,
);
if estimate < data_bytes {
data_bytes = estimate;
}
let estimate = with_headroom(live_count as u64 * NEEDLE_MAP_ENTRY_SIZE as u64);
if estimate < index_bytes {
index_bytes = estimate;
}
}
if preallocate > data_bytes {
data_bytes = preallocate;
}
(data_bytes, index_bytes)
}
/// Headroom for Bloom-filter false positives in the live/deleted counters.
fn with_headroom(estimate: u64) -> u64 {
estimate + estimate / 16
}
/// Whether two directories draw on the same free-space pool; in doubt, yes.
fn same_filesystem(a: &str, b: &str) -> bool {
if a == b {
return true;
}
same_filesystem_impl(a, b)
}
#[cfg(unix)]
fn same_filesystem_impl(a: &str, b: &str) -> bool {
use std::os::unix::fs::MetadataExt;
match (std::fs::metadata(a), std::fs::metadata(b)) {
(Ok(ma), Ok(mb)) => ma.dev() == mb.dev(),
_ => true,
}
}
#[cfg(not(unix))]
fn same_filesystem_impl(_a: &str, _b: &str) -> bool {
true
}
// ============================================================================
// Tests
// ============================================================================
@@ -2587,52 +2483,6 @@ mod tests {
assert_eq!(selected, Some(0));
}
// VolumeCopy picks a disk before deleting the replica it replaces, so only
// the location actually holding that replica may count its slot as free.
#[test]
fn test_find_free_location_predicate_credits_only_the_holding_location() {
let tmp1 = TempDir::new().unwrap();
let dir1 = tmp1.path().to_str().unwrap();
let tmp2 = TempDir::new().unwrap();
let dir2 = tmp2.path().to_str().unwrap();
let mut store = Store::new(NeedleMapKind::InMemory);
for dir in [dir1, dir2] {
store
.add_location(
dir,
dir,
1,
DiskType::HardDrive,
MinFreeSpace::Percent(0.0),
Vec::new(),
)
.unwrap();
}
for vid in [81, 82] {
store
.add_volume(VolumeId(vid), DiskType::HardDrive, &VolumeSpec::default())
.unwrap();
}
let loc_of = |vid| store.find_volume(VolumeId(vid)).unwrap().0;
assert_ne!(loc_of(81), loc_of(82), "fixture must fill both locations");
let hdd = |loc: &DiskLocation| loc.disk_type == DiskType::HardDrive;
assert_eq!(store.find_free_location_predicate(hdd, None), None);
assert_eq!(
store.find_free_location_predicate(hdd, Some(VolumeId(99))),
None
);
assert_eq!(
store.find_free_location_predicate(hdd, Some(VolumeId(81))),
Some(loc_of(81))
);
assert_eq!(
store.find_free_location_predicate(hdd, Some(VolumeId(82))),
Some(loc_of(82))
);
}
#[test]
fn test_delete_expired_ec_volumes_removes_expired_entries() {
let tmp = TempDir::new().unwrap();
@@ -1,374 +0,0 @@
//! Merging a peer's `.ecj` deletion ids into a local EC journal. Mirrors Go's
//! `Store.MergeEcJournal` (`weed/storage/store_ec_journal.go`).
use std::collections::HashSet;
use std::io;
use std::path::Path;
use std::sync::RwLock;
use crate::storage::erasure_coding::ecj_merge::{append_ecj_ids, read_ecj_ids};
use crate::storage::store::Store;
use crate::storage::types::{NeedleId, VolumeId};
/// How often an unmounted merge re-reads a journal that changed under it. Only
/// a mount-delete-unmount or a concurrent merge between the read and the
/// append changes it, so one retry is nearly always enough.
const ECJ_MERGE_ATTEMPTS: usize = 5;
/// Fold a peer's deletion `ids` into the local journal of EC volume `vid` on
/// the receiving disk, the one whose data directory is `data_dir`; `ecj_path`
/// is that journal's path in the disk's index directory. Appends only the ids
/// the journal lacks and returns how many it added. Blocking: call it from
/// `spawn_blocking`.
///
/// A mounted volume owns its journal: the merge goes through its open handle
/// and in-memory set. That is the receiving disk's own runtime for `vid`,
/// wherever its journal lives (it may sit in the data dir rather than
/// `ecj_path`'s index dir), else a sibling runtime journaling into `ecj_path`
/// itself: disks sharing one index directory, or reconciliation mounting `vid`
/// on a disk that journals into another's (#9212). Otherwise `ecj_path` is
/// appended to under the store write lock, which mounts take, so no mount can
/// open it mid-append. The read that computes the delta runs outside the lock,
/// and a journal that changed in between — including by a concurrent merge —
/// is re-read.
pub fn merge_ec_journal(
store: &RwLock<Store>,
vid: VolumeId,
data_dir: &str,
ecj_path: &str,
ids: &HashSet<NeedleId>,
) -> io::Result<usize> {
merge_ec_journal_with(store, vid, data_dir, ecj_path, ids, read_ecj_ids)
}
/// `merge_ec_journal` with the unlocked journal read injected, so a test can
/// mount the volume between that read and the append.
fn merge_ec_journal_with(
store: &RwLock<Store>,
vid: VolumeId,
data_dir: &str,
ecj_path: &str,
ids: &HashSet<NeedleId>,
mut read: impl FnMut(&str) -> io::Result<(HashSet<NeedleId>, u64)>,
) -> io::Result<usize> {
for _ in 0..ECJ_MERGE_ATTEMPTS {
{
let mut store = store
.write()
.map_err(|_| io::Error::other("store lock poisoned"))?;
if let Some(primary) = mounted_ec_journal(&store, vid, data_dir, ecj_path)? {
let ecv = store.locations[primary]
.find_ec_volume_mut(vid)
.expect("mounted journal runtime");
let journal_path = ecv.ecj_file_name();
let added = ecv.merge_journal(ids)?;
// Publish to the holders of the file the merge wrote to — the
// picked runtime's journal may live outside ecj_path, and a
// holder of a different file must not claim ids it lacks.
publish_to_journal_siblings(&store, primary, vid, &journal_path, ids);
return Ok(added);
}
}
let (local, size) = read(ecj_path)?;
let mut store = store
.write()
.map_err(|_| io::Error::other("store lock poisoned"))?;
if let Some(primary) = mounted_ec_journal(&store, vid, data_dir, ecj_path)? {
// Mounted since the read: its handle owns the journal now.
let ecv = store.locations[primary]
.find_ec_volume_mut(vid)
.expect("mounted journal runtime");
let journal_path = ecv.ecj_file_name();
let added = ecv.merge_journal(ids)?;
publish_to_journal_siblings(&store, primary, vid, &journal_path, ids);
return Ok(added);
}
// The path append registers as a writer so a mount compacting this
// journal cannot swap its inode underneath it (the write itself is
// already serialized with mounts by the store lock).
let _ecj_write =
crate::storage::erasure_coding::ecj_registry::begin_ecj_write(ecj_path);
if let Some(added) = append_ecj_ids(ecj_path, &local, ids, size)? {
return Ok(added);
}
}
Err(io::Error::other(format!(
"ec volume {}: journal {} kept changing during merge",
vid.0, ecj_path
)))
}
/// The disk index of the runtime holding `ecj_path` open, if any: the disk at
/// `data_dir`'s own, else the first sibling journaling into it.
fn mounted_ec_journal(
store: &Store,
vid: VolumeId,
data_dir: &str,
ecj_path: &str,
) -> io::Result<Option<usize>> {
let owner = store
.locations
.iter()
.position(|loc| Path::new(&loc.directory) == Path::new(data_dir))
.ok_or_else(|| {
io::Error::other(format!(
"ec volume {}: no disk at {} owns journal {}",
vid.0, data_dir, ecj_path
))
})?;
let runtime = if store.locations[owner].has_ec_volume(vid) {
Some(owner)
} else {
store.locations.iter().position(|loc| {
loc.find_ec_volume(vid)
.is_some_and(|ecv| Path::new(&ecv.ecj_file_name()) == Path::new(ecj_path))
})
};
Ok(runtime)
}
/// Publishes merged ids into every other runtime journaling into `ecj_path`.
fn publish_to_journal_siblings(
store: &Store,
primary: usize,
vid: VolumeId,
ecj_path: &str,
ids: &HashSet<NeedleId>,
) {
for (i, loc) in store.locations.iter().enumerate() {
if i == primary {
continue;
}
if let Some(ecv) = loc.find_ec_volume(vid) {
if Path::new(&ecv.ecj_file_name()) == Path::new(ecj_path) {
ecv.publish_merged_ids(ids);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MinFreeSpace;
use crate::storage::needle_map::NeedleMapKind;
use crate::storage::types::DiskType;
use crate::storage::volume::{VifEcShardConfig, VifVolumeInfo};
use tempfile::TempDir;
const COLLECTION: &str = "c";
const VID: VolumeId = VolumeId(9);
/// A store with one disk per entry of `data`, all sharing `idx` when given,
/// else each indexing into its own data dir.
fn make_store(tmp: &TempDir, data: &[&str], idx: Option<&str>) -> RwLock<Store> {
let mut store = Store::new(NeedleMapKind::InMemory);
for d in data {
let dir = tmp.path().join(d).to_string_lossy().into_owned();
let idx_dir = idx
.map(|i| tmp.path().join(i).to_string_lossy().into_owned())
.unwrap_or_else(|| dir.clone());
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&idx_dir).unwrap();
store
.add_location(
&dir,
&idx_dir,
100,
DiskType::HardDrive,
MinFreeSpace::Percent(0.0),
Vec::new(),
)
.unwrap();
}
RwLock::new(store)
}
fn dir(tmp: &TempDir, d: &str) -> String {
tmp.path().join(d).to_string_lossy().into_owned()
}
fn records(ids: &[u64]) -> Vec<u8> {
let ids: Vec<NeedleId> = ids.iter().copied().map(NeedleId).collect();
crate::storage::erasure_coding::ecj_merge::encode_ecj_ids(&ids)
}
fn id_set(ids: &[u64]) -> HashSet<NeedleId> {
ids.iter().copied().map(NeedleId).collect()
}
/// Shard 0 of `VID` and its `.vif` in `data_dir`.
fn write_shard0(data_dir: &str) {
let base = format!("{}/{}_{}", data_dir, COLLECTION, VID.0);
std::fs::write(format!("{}.ec00", base), b"shard data nonempty").unwrap();
let vif = VifVolumeInfo {
version: 3,
ec_shard_config: Some(VifEcShardConfig {
data_shards: 10,
parity_shards: 4,
..Default::default()
}),
..Default::default()
};
std::fs::write(
format!("{}.vif", base),
serde_json::to_string(&vif).unwrap(),
)
.unwrap();
}
/// `VID`'s `.ecx` and a `.ecj` holding `deleted` in `dir`; returns the
/// journal path.
fn write_index(dir: &str, deleted: &[u64]) -> String {
let base = format!("{}/{}_{}", dir, COLLECTION, VID.0);
std::fs::write(format!("{}.ecx", base), vec![0u8; 16]).unwrap();
let ecj = format!("{}.ecj", base);
std::fs::write(&ecj, records(deleted)).unwrap();
ecj
}
fn deleted_on(store: &RwLock<Store>, disk: usize, id: u64) -> bool {
store.read().unwrap().locations[disk]
.find_ec_volume(VID)
.expect("mounted")
.is_needle_deleted(NeedleId(id))
}
/// Disks sharing one index directory all hold the same journal path.
/// Copying shards onto a disk that has not mounted `vid` must still reach
/// the sibling runtime holding that journal open.
#[test]
fn shared_index_dir_reaches_sibling_mount() {
let tmp = TempDir::new().unwrap();
let store = make_store(&tmp, &["d0", "d1"], Some("idx"));
write_shard0(&dir(&tmp, "d0"));
let ecj = write_index(&dir(&tmp, "idx"), &[1]);
store.write().unwrap().locations[0]
.mount_ec_shards(VID, COLLECTION, &[0], "")
.unwrap();
assert_eq!(
store.read().unwrap().locations[0]
.find_ec_volume(VID)
.unwrap()
.ecj_file_name(),
ecj
);
let added =
merge_ec_journal(&store, VID, &dir(&tmp, "d1"), &ecj, &id_set(&[1, 2])).unwrap();
assert_eq!(added, 1);
assert!(
deleted_on(&store, 0, 2),
"the mounted sibling must see id 2"
);
assert_eq!(std::fs::read(&ecj).unwrap(), records(&[1, 2]));
}
/// Every runtime holding the journal open must see merged ids in memory.
#[test]
fn shared_journal_reaches_every_holder() {
let tmp = TempDir::new().unwrap();
let store = make_store(&tmp, &["d0", "d1"], Some("idx"));
write_shard0(&dir(&tmp, "d0"));
write_shard0(&dir(&tmp, "d1"));
let ecj = write_index(&dir(&tmp, "idx"), &[1]);
for i in 0..2 {
store.write().unwrap().locations[i]
.mount_ec_shards(VID, COLLECTION, &[0], "")
.unwrap();
}
let added =
merge_ec_journal(&store, VID, &dir(&tmp, "d1"), &ecj, &id_set(&[1, 2])).unwrap();
assert_eq!(added, 1);
assert!(
deleted_on(&store, 0, 2) && deleted_on(&store, 1, 2),
"every journal holder must see the merged id"
);
assert_eq!(std::fs::read(&ecj).unwrap(), records(&[1, 2]));
}
/// The picked runtime may journal to a different file than the copied
/// one — its index lives in its data directory while a sibling's lives
/// in the index directory. The ids must be published only to holders of
/// the file they were written to.
#[test]
fn publishes_to_actual_journal_holders() {
let tmp = TempDir::new().unwrap();
let store = make_store(&tmp, &["d0", "d1"], Some("idx"));
write_shard0(&dir(&tmp, "d0"));
write_shard0(&dir(&tmp, "d1"));
let data_ecj = write_index(&dir(&tmp, "d0"), &[1]);
let idx_ecj = write_index(&dir(&tmp, "idx"), &[1]);
for i in 0..2 {
store.write().unwrap().locations[i]
.mount_ec_shards(VID, COLLECTION, &[0], "")
.unwrap();
}
assert_eq!(
store.read().unwrap().locations[0]
.find_ec_volume(VID)
.unwrap()
.ecj_file_name(),
data_ecj
);
let added =
merge_ec_journal(&store, VID, &dir(&tmp, "d0"), &idx_ecj, &id_set(&[1, 2])).unwrap();
assert_eq!(added, 1);
assert_eq!(std::fs::read(&data_ecj).unwrap(), records(&[1, 2]));
assert_eq!(std::fs::read(&idx_ecj).unwrap(), records(&[1]));
assert!(deleted_on(&store, 0, 2));
assert!(
!deleted_on(&store, 1, 2),
"a different journal's holder must not claim the merged id"
);
}
/// A sibling disk can mount `vid` from the receiving disk's index (#9212)
/// while the merge reads the journal unlocked. The merge must go through
/// that mount and report what it added.
#[test]
fn mount_during_read_is_merged_through_and_counted() {
let tmp = TempDir::new().unwrap();
let store = make_store(&tmp, &["d0", "d1"], None);
let owner = dir(&tmp, "d0");
let ecj = write_index(&owner, &[1]);
write_shard0(&dir(&tmp, "d1"));
let mut mounted = false;
let read = |path: &str| {
let read = read_ecj_ids(path);
if !mounted {
store.write().unwrap().locations[1]
.mount_ec_shards_with_idx_dir(VID, COLLECTION, &[0], &owner, "")
.unwrap();
mounted = true;
}
read
};
let added =
merge_ec_journal_with(&store, VID, &owner, &ecj, &id_set(&[1, 2, 3]), read).unwrap();
assert_eq!(added, 2, "the ids merged through the new mount are counted");
assert!(deleted_on(&store, 1, 2) && deleted_on(&store, 1, 3));
assert_eq!(std::fs::read(&ecj).unwrap(), records(&[1, 2, 3]));
}
/// An unmounted journal is merged on disk and a repeat adds nothing; a
/// data dir that is no disk is refused.
#[test]
fn unmounted_journal_is_idempotent() {
let tmp = TempDir::new().unwrap();
let store = make_store(&tmp, &["d0"], Some("idx"));
let ecj = write_index(&dir(&tmp, "idx"), &[1, 2]);
let d0 = dir(&tmp, "d0");
for want in [2, 0, 0] {
let added = merge_ec_journal(&store, VID, &d0, &ecj, &id_set(&[2, 3, 4])).unwrap();
assert_eq!(added, want);
}
assert_eq!(std::fs::read(&ecj).unwrap(), records(&[1, 2, 3, 4]));
assert!(
merge_ec_journal(&store, VID, &dir(&tmp, "elsewhere"), &ecj, &id_set(&[1])).is_err()
);
}
}
File diff suppressed because it is too large Load Diff
-5
View File
@@ -107,12 +107,7 @@ fn build_test_state(
rack: String::new(),
file_size_limit_bytes: 0,
maintenance_byte_per_second: 0,
vacuum_commit_slots: None,
vacuum_commits_waiting: std::sync::atomic::AtomicU32::new(0),
is_heartbeating: std::sync::atomic::AtomicBool::new(true),
ec_decodes_in_flight: std::sync::Mutex::new(std::collections::HashSet::new()),
ec_decode_tail: std::sync::Mutex::new(std::collections::HashSet::new()),
ec_decode_tail_notify: tokio::sync::Notify::new(),
has_master: false,
pre_stop_seconds: 0,
volume_state_notify: tokio::sync::Notify::new(),
+5 -14
View File
@@ -485,7 +485,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
"zeroize",
]
@@ -5125,7 +5124,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted 0.9.0",
"untrusted",
"windows-sys 0.52.0",
]
@@ -5189,9 +5188,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"log",
@@ -5261,7 +5260,7 @@ dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted 0.9.0",
"untrusted",
]
[[package]]
@@ -5343,9 +5342,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"aws-lc-rs",
"axum",
"base64 0.22.1",
"prometheus",
"prost",
"prost-types",
@@ -5784,7 +5781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -6216,12 +6213,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
version = "0.9.0"
-4
View File
@@ -12,10 +12,6 @@ name = "seaweed_worker_core"
# Helpers the Rust volume server needs as well. A path dependency because
# the two trees are separate cargo workspaces with no common root manifest.
seaweed-common = { path = "../../../seaweed-common" }
# Already in the graph through tonic's `tls-aws-lc` feature; named here so the
# admin token can sign with the same HMAC the Go worker uses.
aws-lc-rs = "1"
base64 = "0.22"
anyhow.workspace = true
async-trait.workspace = true
prost.workspace = true
-11
View File
@@ -17,15 +17,6 @@ pub struct WorkerOptions {
/// section of security.toml. None means plaintext, which is the default the
/// Go worker also takes when no certificates are configured.
pub tls: Option<TlsOptions>,
/// The shared secret the admin stream requires when the cluster configures
/// `jwt.filer_signing.key`; mirrored from that config value. Empty means no
/// token is attached, which only works against admins that either bound
/// their worker listener to loopback or authenticate with mTLS instead.
pub signing_key: Option<String>,
/// Seconds the minted admin token lives, mirroring
/// `jwt.filer_signing.expires_after_seconds`. Zero mints a token without an
/// expiry, matching the Go default.
pub signing_key_expires_after_seconds: u64,
}
/// Certificates for the control stream. All three are required together: the
@@ -53,8 +44,6 @@ impl Default for WorkerOptions {
max_detection_concurrency: 1,
max_execution_concurrency: 1,
tls: None,
signing_key: None,
signing_key_expires_after_seconds: 0,
}
}
}
-1
View File
@@ -13,7 +13,6 @@ pub mod metrics;
pub mod registry;
pub mod senders;
pub mod stream;
pub mod token;
/// Generated plugin.proto types.
pub mod pb {
+4 -13
View File
@@ -176,19 +176,10 @@ async fn serve_once(
metadata: Default::default(),
}))?;
let mut request = tonic::Request::new(UnboundedReceiverStream::new(rx));
if let Some(token) = crate::token::admin_token(
options.signing_key.as_deref().unwrap_or("").as_bytes(),
options.signing_key_expires_after_seconds,
) {
request.metadata_mut().insert(
"authorization",
format!("Bearer {token}")
.parse()
.context("minted admin token is not valid metadata")?,
);
}
let mut inbound = client.worker_stream(request).await?.into_inner();
let mut inbound = client
.worker_stream(UnboundedReceiverStream::new(rx))
.await?
.into_inner();
let heartbeat = spawn_heartbeat(
sender.clone(),
-61
View File
@@ -1,61 +0,0 @@
use std::time::{SystemTime, UNIX_EPOCH};
use aws_lc_rs::hmac;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
/// Mints the HS256 Bearer token the admin control stream requires when
/// jwt.filer_signing.key is configured, matching the SeaweedFilerAdminClaims
/// the Go worker sends: registered claims only, no session id.
pub fn admin_token(signing_key: &[u8], expires_after_secs: u64) -> Option<String> {
if signing_key.is_empty() {
return None;
}
let claims = if expires_after_secs > 0 {
let exp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
+ expires_after_secs;
format!("{{\"exp\":{exp}}}")
} else {
"{}".to_string()
};
let signing_input = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}"),
URL_SAFE_NO_PAD.encode(claims.as_bytes())
);
let key = hmac::Key::new(hmac::HMAC_SHA256, signing_key);
let signature = hmac::sign(&key, signing_input.as_bytes());
Some(format!(
"{signing_input}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
#[cfg(test)]
mod tests {
use super::admin_token;
#[test]
fn no_key_no_token() {
assert!(admin_token(b"", 0).is_none());
}
#[test]
fn minted_token_verifies_shape() {
let token = admin_token(b"secret", 60).unwrap();
let parts: Vec<&str> = token.split('.').collect();
assert_eq!(parts.len(), 3);
assert!(!parts.iter().any(|p| p.is_empty()));
}
#[test]
fn no_expiry_has_empty_claims() {
let token = admin_token(b"secret", 0).unwrap();
let payload = token.split('.').nth(1).unwrap();
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let claims = URL_SAFE_NO_PAD.decode(payload).unwrap();
assert_eq!(claims, b"{}");
}
}
-14
View File
@@ -61,18 +61,6 @@ struct Args {
#[arg(long)]
tls_server_name: Option<String>,
/// Shared secret for the admin control stream, the `jwt.filer_signing.key`
/// value the Go worker reads from its config file. Required by admins that
/// authenticate the worker stream with a signing key.
#[arg(long, env = "WEED_FILER_SIGNING_KEY")]
filer_signing_key: Option<String>,
/// Lifetime of the token minted from the signing key, mirroring
/// `jwt.filer_signing.expires_after_seconds`. Zero mints a token with no
/// expiry.
#[arg(long, default_value = "0")]
filer_signing_key_expires_after_seconds: u64,
/// Serve /health, /ready and /metrics on this port, the way
/// `weed worker -metricsPort` does. Zero, the default, serves nothing.
#[arg(long, default_value = "0", env = "WEED_METRICS_PORT")]
@@ -143,8 +131,6 @@ async fn main() -> Result<()> {
max_detection_concurrency: args.max_concurrency,
max_execution_concurrency: args.max_concurrency,
tls,
signing_key: args.filer_signing_key,
signing_key_expires_after_seconds: args.filer_signing_key_expires_after_seconds,
..Default::default()
};
@@ -1,5 +1,3 @@
//go:build linux || darwin
package fuse_dlm
import (
-2
View File
@@ -1,5 +1,3 @@
//go:build linux || darwin
package fuse_dlm
import (
-2
View File
@@ -1,5 +1,3 @@
//go:build linux || darwin
package fuse_dlm
import (
-2
View File
@@ -1,5 +1,3 @@
//go:build linux || darwin
package fuse_p2p
import (
+1 -1
View File
@@ -16,7 +16,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<spark.version>3.5.8</spark.version>
<spark.version>3.5.7</spark.version>
<hadoop.version>3.3.6</hadoop.version>
<scala.binary.version>2.12</scala.binary.version>
<junit.version>4.13.2</junit.version>
+10 -7
View File
@@ -7,20 +7,20 @@ require (
github.com/linkedin/goavro/v2 v2.15.0
github.com/seaweedfs/seaweedfs v0.0.0-00010101000000-000000000000
github.com/segmentio/kafka-go v0.4.49
github.com/stretchr/testify v1.12.1
github.com/stretchr/testify v1.11.1
google.golang.org/grpc v1.85.0-dev.0.20260915183914-4e49413dcab7
)
replace github.com/seaweedfs/seaweedfs => ../../
require (
github.com/andybalholm/brotli v1.2.3 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/aws/aws-sdk-go v1.55.8 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cognusion/imaging v1.0.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eapache/go-resiliency v1.7.0 // indirect
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect
github.com/eapache/queue v1.1.0 // indirect
@@ -61,15 +61,17 @@ require (
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/pierrec/lz4/v4 v4.1.29 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.71.0 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
github.com/rdleal/intervalst v1.5.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/seaweedfs/goexif v1.0.3 // indirect
github.com/seaweedfs/goexif v2.0.0+incompatible // indirect
github.com/shirou/gopsutil/v4 v4.26.7 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
@@ -89,8 +91,8 @@ require (
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.57.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.56.0 // indirect
golang.org/x/image v0.46.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sync v0.23.0 // indirect
@@ -99,4 +101,5 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/grpc/security/advancedtls v1.0.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+20 -16
View File
@@ -10,8 +10,8 @@ github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZ
github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -28,8 +28,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA=
github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho=
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws=
@@ -174,8 +174,8 @@ github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59u
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
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/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
@@ -184,10 +184,12 @@ github.com/rdleal/intervalst v1.5.0 h1:SEB9bCFz5IqD1yhfH1Wv8IBnY/JQxDplwkxHjT6ha
github.com/rdleal/intervalst v1.5.0/go.mod h1:xO89Z6BC+LQDH+IPQQw/OESt5UADgFD41tYMUINGpxQ=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/seaweedfs/goexif v1.0.3 h1:ve/OjI7dxPW8X9YQsv3JuVMaxEyF9Rvfd04ouL+Bz30=
github.com/seaweedfs/goexif v1.0.3/go.mod h1:Oni780Z236sXpIQzk1XoJlTwqrJ02smEin9zQeff7Fk=
github.com/seaweedfs/goexif v2.0.0+incompatible h1:x8pckiT12QQhifwhDQpeISgDfsqmQ6VR4LFPQ64JRps=
github.com/seaweedfs/goexif v2.0.0+incompatible/go.mod h1:Oni780Z236sXpIQzk1XoJlTwqrJ02smEin9zQeff7Fk=
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
@@ -214,8 +216,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965 h1:1oFLiOyVl+W7bnBzGhf7BbIv9loSFQcieWWYIjLqcAw=
@@ -261,15 +263,15 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
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.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
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/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -286,8 +288,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -349,6 +351,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-105
View File
@@ -1,105 +0,0 @@
# S3/IAM error response compatibility test harness
.PHONY: all test help build-weed check-deps start-server stop-server test-errors test-with-server clean logs
WEED_BINARY := ../../../weed/weed_binary
S3_PORT := 8334
TEST_TIMEOUT := 10m
SERVER_DIR := ./test-volume-data
S3_CONFIG := ./test_s3.json
.DEFAULT_GOAL := help
all: test
test: test-with-server
help:
@echo "S3/IAM error response compatibility test harness"
@echo ""
@echo "Available targets:"
@echo " build-weed - Build the SeaweedFS binary"
@echo " start-server - Start SeaweedFS mini with S3 and embedded IAM enabled"
@echo " stop-server - Stop the test server"
@echo " test-errors - Run the error compatibility checks"
@echo " test-with-server - Start server, run checks, stop server"
@echo " logs - Show server logs"
@echo " clean - Remove test artifacts"
build-weed:
@echo "Building SeaweedFS binary..."
@cd ../../../weed && go build -o weed_binary .
@chmod +x $(WEED_BINARY)
check-deps: build-weed
@command -v go >/dev/null 2>&1 || (echo "Go is required but not installed" && exit 1)
@command -v python3 >/dev/null 2>&1 || (echo "python3 is required but not installed" && exit 1)
@python3 -c "import botocore" 2>/dev/null || (echo "botocore is required: pip install botocore" && exit 1)
@test -f $(WEED_BINARY) || (echo "SeaweedFS binary not found at $(WEED_BINARY)" && exit 1)
@test -f $(S3_CONFIG) || (echo "S3 config not found at $(S3_CONFIG)" && exit 1)
start-server: check-deps
@echo "Starting SeaweedFS mini for error compatibility tests..."
@rm -f weed-server.pid
@mkdir -p $(SERVER_DIR)
@if curl -sS -o /dev/null http://127.0.0.1:$(S3_PORT)/ >/dev/null 2>&1; then \
echo "port $(S3_PORT) is already served by another process; refusing to test against it"; \
exit 1; \
fi
@$(WEED_BINARY) mini \
-dir=$(SERVER_DIR) \
-s3 \
-s3.port=$(S3_PORT) \
-s3.config=$(S3_CONFIG) \
-s3.iam.readOnly=false \
-master.peers=none \
> weed-test.log 2>&1 & echo $$! > weed-server.pid
@for i in $$(seq 1 60); do \
kill -0 $$(cat weed-server.pid) 2>/dev/null || { \
echo "SeaweedFS server exited during startup"; \
echo "=== Server logs ==="; \
test -f weed-test.log && cat weed-test.log || true; \
exit 1; \
}; \
if curl -sS -o /dev/null http://127.0.0.1:$(S3_PORT)/ >/dev/null 2>&1; then \
echo "SeaweedFS S3 server is ready on port $(S3_PORT)"; \
sleep 3; \
exit 0; \
fi; \
sleep 1; \
done; \
echo "SeaweedFS S3 server failed to start"; \
echo "=== Server logs ==="; \
test -f weed-test.log && cat weed-test.log || true; \
$(MAKE) --no-print-directory stop-server; \
exit 1
stop-server:
@if [ -f weed-server.pid ]; then \
pid=$$(cat weed-server.pid); \
if ps -p $$pid -o comm= 2>/dev/null | grep -q weed; then \
echo "Stopping SeaweedFS server..."; \
kill $$pid 2>/dev/null || true; \
sleep 2; \
fi; \
rm -f weed-server.pid; \
fi
test-errors:
@echo "Running S3/IAM error compatibility checks..."
python3 s3_error_compat_test.py \
--endpoint http://127.0.0.1:$(S3_PORT) \
--access-key admin \
--secret-key admin
test-with-server:
@$(MAKE) start-server; START_RESULT=$$?; \
if [ $$START_RESULT -eq 0 ]; then $(MAKE) test-errors; TEST_RESULT=$$?; else TEST_RESULT=$$START_RESULT; fi; \
$(MAKE) stop-server; exit $$TEST_RESULT
logs:
@test -f weed-test.log && tail -100 weed-test.log || echo "No log file found"
clean: stop-server
@rm -rf $(SERVER_DIR) weed-test.log weed-server.pid
@echo "Cleaned up test artifacts"
@@ -1,216 +0,0 @@
#!/usr/bin/env python3
"""Verify S3/IAM error responses match AWS for common client mistakes.
Needs only botocore (pip install botocore). Sends raw SigV4 requests so the exact
status and response body are visible, compares each with what AWS returns, then
cleans up everything it created (one bucket, one IAM user).
Usage:
python3 s3_error_compat_test.py --endpoint http://127.0.0.1:8333 \
--access-key <admin key> --secret-key <admin secret>
The key needs Admin rights, and IAM writes must be enabled (-s3.iam.readOnly=false).
"""
import argparse, atexit, base64, datetime, hashlib, http.client, re, sys, time, urllib.parse, uuid
from botocore.auth import S3SigV4Auth, SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.credentials import Credentials
ap = argparse.ArgumentParser()
ap.add_argument('--endpoint', default='http://127.0.0.1:8333')
ap.add_argument('--access-key', required=True)
ap.add_argument('--secret-key', required=True)
ap.add_argument('--region', default='us-east-1')
args = ap.parse_args()
EP = args.endpoint.rstrip('/')
U = urllib.parse.urlsplit(EP)
ADMIN = Credentials(args.access_key, args.secret_key)
BAD_KEY = Credentials('AKIAREPRONOSUCHKEY00', 'x' * 40)
BAD_SECRET = Credentials(args.access_key, 'y' * 40)
B = 'errorcompat-' + uuid.uuid4().hex[:8]
S3DOC = 'https://docs.aws.amazon.com/AmazonS3/latest/API/'
IAMDOC = 'https://docs.aws.amazon.com/IAM/latest/APIReference/'
rows = []
def send(method, path, body=b'', headers=None, creds=ADMIN, service='s3', mutate=None, sign=True,
keep_sha256_header=False):
headers = dict(headers or {})
url = EP + path
if sign:
req = AWSRequest(method=method, url=url, data=body, headers=headers)
if service == 's3':
if 'X-Amz-Content-SHA256' not in req.headers:
req.headers['X-Amz-Content-SHA256'] = hashlib.sha256(body).hexdigest()
# S3SigV4Auth always recomputes X-Amz-Content-SHA256 from the body; the
# generic SigV4Auth signs a caller-supplied value instead, which is how
# a mismatched declared hash can be produced on the wire.
signer = SigV4Auth if keep_sha256_header else S3SigV4Auth
signer(creds, 's3', args.region).add_auth(req)
else:
SigV4Auth(creds, service, args.region).add_auth(req)
headers = dict(req.headers.items())
if mutate:
headers = mutate(headers)
conn = http.client.HTTPConnection(U.hostname, U.port, timeout=30)
conn.request(method, path, body=body, headers=headers)
r = conn.getresponse()
data = r.read().decode('utf-8', 'replace')
conn.close()
return r.status, data
def shape(data):
"""Root element and <Code> of an XML error body."""
body = re.sub(r'<\?xml[^>]*\?>', '', data).strip()
root = re.match(r'<(\w+)', body)
code = re.search(r'<Code>([^<]*)</Code>', data)
return (root.group(1) if root else ('-' if not body else 'non-xml')), (code.group(1) if code else '')
def iam(params, creds=ADMIN):
p = dict(params, Version='2010-05-08')
return dict(method='POST', path='/', body=urllib.parse.urlencode(p).encode(), creds=creds, service='iam',
headers={'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'})
def case(group, name, expect, ref, req, check=None):
"""expect = (status, root element or None, code or None); check() adds a side-effect note."""
st, data = send(**req)
root, code = shape(data)
note = check() if check else ''
ok = st == expect[0] and expect[1] in (None, root) and expect[2] in (None, code) and not note
rows.append((group, name, '%s %s %s' % (st, root, code), '%s %s %s' % (expect[0], expect[1] or '', expect[2] or ''), note, ok, ref))
def exists(key):
return lambda: 'object %r now exists' % key if send('HEAD', '/%s/%s' % (B, key))[0] == 200 else ''
def S3(method, path, **kw):
return dict(method=method, path=path, **kw)
# ---------------- cleanup (atexit so failed runs still clean up) ----------------
user = USER = upload_id = None
def cleanup():
try:
if user and USER:
send(**iam({'Action': 'DeleteAccessKey', 'UserName': user, 'AccessKeyId': USER.access_key}))
if user:
send(**iam({'Action': 'DeleteUser', 'UserName': user}))
if upload_id:
send('DELETE', '/%s/mp?uploadId=%s' % (B, upload_id))
st, d = send('GET', '/%s?list-type=2' % B)
for k in re.findall(r'<Key>([^<]+)</Key>', d):
send('DELETE', '/%s/%s' % (B, urllib.parse.quote(k)))
send('DELETE', '/' + B)
except Exception as e:
print('cleanup failed: %s' % e)
atexit.register(cleanup)
# ---------------- setup ----------------
st, d = send('PUT', '/' + B)
if st != 200:
sys.exit('cannot create bucket %s: %s %s' % (B, st, d[:200]))
send('PUT', '/%s/obj' % B, body=b'hello')
st, d = send('POST', '/%s/mp?uploads' % B)
upload_id = re.search(r'<UploadId>([^<]+)', d).group(1)
user = 'errorcompat-' + uuid.uuid4().hex[:6]
send(**iam({'Action': 'CreateUser', 'UserName': user}))
st, d = send(**iam({'Action': 'CreateAccessKey', 'UserName': user}))
USER = Credentials(re.search(r'<AccessKeyId>([^<]+)', d).group(1), re.search(r'<SecretAccessKey>([^<]+)', d).group(1))
for _ in range(15): # wait until the new key is accepted
if 'InvalidAccessKeyId' not in send('GET', '/', creds=USER)[1]:
break
time.sleep(1)
# ---------------- 1. requests that run a different operation ----------------
for sub in ('logging', 'notification', 'accelerate', 'website', 'replication',
'analytics&id=a', 'inventory&id=a', 'metrics&id=a', 'intelligent-tiering&id=a'):
case(1, 'PUT ?%s' % sub, (501, 'Error', 'NotImplemented'), S3DOC + 'API_Error.html#:~:text=Code%3A%20NotImplemented',
S3('PUT', '/%s?%s' % (B, sub), body=b'<X/>'))
case(1, 'DELETE ?logging', (501, 'Error', 'NotImplemented'), S3DOC + 'API_Error.html#:~:text=Code%3A%20NotImplemented',
S3('DELETE', '/%s?logging' % B),
lambda: '' if send('HEAD', '/' + B)[0] == 200 else 'BUCKET DELETED')
case(1, 'CopyObject, x-amz-copy-source without "/"', (400, 'Error', 'InvalidArgument'),
S3DOC + 'API_CopyObject.html#AmazonS3-CopyObject-request-header-CopySource',
S3('PUT', '/%s/copy-dst' % B, headers={'x-amz-copy-source': 'nobucketonly'}), exists('copy-dst'))
case(1, 'UploadPart partNumber=abc', (400, 'Error', 'InvalidArgument'),
S3DOC + 'API_UploadPart.html#AmazonS3-UploadPart-request-uri-querystring-PartNumber',
S3('PUT', '/%s/mp?partNumber=abc&uploadId=%s' % (B, upload_id), body=b'part-body'), exists('mp'))
case(1, 'PutObject, x-amz-content-sha256 != body', (400, 'Error', 'XAmzContentSHA256Mismatch'),
'undocumented code; ' + S3DOC + 'API_UploadPart.html#API_UploadPart_RequestSyntax',
S3('PUT', '/%s/sha-mismatch' % B, body=b'abc',
headers={'X-Amz-Content-SHA256': hashlib.sha256(b'zzz').hexdigest()}, keep_sha256_header=True),
exists('sha-mismatch'))
case(1, 'PutObject, x-amz-content-sha256 not hex', (400, 'Error', None), 'undocumented',
S3('PUT', '/%s/sha-nothex' % B, body=b'abc',
headers={'X-Amz-Content-SHA256': 'nothex'}, keep_sha256_header=True), exists('sha-nothex'))
# ---------------- 2. IAM failures in the S3 <Error> envelope ----------------
case(2, 'IAM ListUsers, unknown access key', (403, 'ErrorResponse', 'InvalidClientTokenId'),
'checked against iam.amazonaws.com (docs list UnrecognizedClientException); ' + IAMDOC + 'CommonErrors.html#CommonErrors-UnrecognizedClientException', iam({'Action': 'ListUsers'}, creds=BAD_KEY))
case(2, 'IAM ListUsers, wrong secret', (403, 'ErrorResponse', None), 'undocumented for IAM',
iam({'Action': 'ListUsers'}, creds=BAD_SECRET))
case(2, 'IAM ListUsers, non-admin user', (403, 'ErrorResponse', 'AccessDenied'),
IAMDOC + 'CommonErrors.html#CommonErrors-AccessDeniedException', iam({'Action': 'ListUsers'}, creds=USER))
case(2, 'IAM CreateUser, non-admin user', (403, 'ErrorResponse', 'AccessDenied'),
IAMDOC + 'CommonErrors.html#CommonErrors-AccessDeniedException', iam({'Action': 'CreateUser', 'UserName': user + 'x'}, creds=USER))
# ---------------- 3. wrong status or code ----------------
tags = [('Action', 'TagUser'), ('UserName', user)] + [('Tags.member.%d.%s' % (i, k), 'k%d' % i if k == 'Key' else 'v')
for i in range(1, 52) for k in ('Key', 'Value')]
case(3, 'IAM TagUser with 51 tags', (409, 'ErrorResponse', 'LimitExceeded'), IAMDOC + 'API_TagUser.html#API_TagUser_Errors',
iam(dict(tags)))
case(3, 'IAM unknown Action', (404, 'ErrorResponse', 'InvalidAction'), 'checked against iam.amazonaws.com',
iam({'Action': 'NoSuchActionZZ'}))
def no_date(h):
return {k: v for k, v in h.items() if k.lower() != 'x-amz-date'}
def drop(field):
return lambda h: dict(h, Authorization=re.sub(r',? *%s=[^,]*' % field, '', h['Authorization']))
case(3, 'S3 request without x-amz-date', (403, 'Error', 'AccessDenied'), 'checked against s3.amazonaws.com; ' + S3DOC + 'API_Error.html#:~:text=Code%3A%20AccessDenied',
S3('GET', '/' + B, mutate=no_date))
case(3, 'Authorization without Signature=', (400, 'Error', 'AuthorizationHeaderMalformed'),
'checked against s3.amazonaws.com; ' + S3DOC + 'API_Error.html#:~:text=Code%3A%20AuthorizationHeaderMalformed', S3('GET', '/' + B, mutate=drop('Signature')))
case(3, 'Authorization without Credential=', (400, 'Error', 'InvalidArgument'),
'checked against s3.amazonaws.com ("Unsupported Authorization Type")', S3('GET', '/' + B, mutate=drop('Credential')))
for m, exp, chk in (('GET', (400, 'Error', 'InvalidArgument'), 'checked against s3.amazonaws.com ("Invalid version id specified"); '),
('HEAD', (400, None, None), 'checked against s3.amazonaws.com; '),
('DELETE', (400, None, None), 'unverified (needs write access); ')):
case(3, '%s ?versionId=bogus, unversioned bucket' % m, exp,
chk + S3DOC + 'API_GetObject.html#AmazonS3-GetObject-request-uri-querystring-VersionId',
S3(m, '/%s/obj?versionId=bogus' % B))
for pn in ('0', '10001'):
case(3, 'UploadPart partNumber=%s' % pn, (400, 'Error', 'InvalidArgument'),
S3DOC + 'API_UploadPart.html#AmazonS3-UploadPart-request-uri-querystring-PartNumber',
S3('PUT', '/%s/mp?partNumber=%s&uploadId=%s' % (B, pn, upload_id), body=b'x'))
case(3, 'DeleteObjects with 1001 keys', (400, 'Error', 'MalformedXML'), S3DOC + 'API_DeleteObjects.html#API_DeleteObjects_RequestBody',
S3('POST', '/%s?delete' % B, body=('<Delete>' + ''.join('<Object><Key>k%d</Key></Object>' % i for i in range(1001)) + '</Delete>').encode()))
# last: this one deletes the user when it should be refused
case(3, 'IAM DeleteUser while it has an access key', (409, 'ErrorResponse', 'DeleteConflict'),
IAMDOC + 'API_DeleteUser.html#API_DeleteUser_Errors', iam({'Action': 'DeleteUser', 'UserName': user}))
# ---------------- report ----------------
w = max(len(r[1]) for r in rows)
print('%-4s %-*s %-46s %-40s %s' % ('', w, 'case', 'got (status root code)', 'expected (AWS)', 'side effect'))
for g, name, got, exp, note, ok, ref in rows:
print('%-4s %-*s %-46s %-40s %s' % ('ok' if ok else 'DIFF', w, name, got, exp, note))
print('\nreferences:')
for g, name, got, exp, note, ok, ref in rows:
print(' %-*s %s' % (w, name, ref))
diff = sum(not r[5] for r in rows)
print('\n%d cases, %d differ from AWS' % (len(rows), diff))
sys.exit(1 if diff else 0)
-20
View File
@@ -1,20 +0,0 @@
{
"identities": [
{
"name": "admin",
"credentials": [
{
"accessKey": "admin",
"secretKey": "admin"
}
],
"actions": [
"Admin",
"Read",
"List",
"Tagging",
"Write"
]
}
]
}
+2 -2
View File
@@ -114,7 +114,7 @@ func TestS3Integration(t *testing.T) {
// findAvailablePort finds an available port by binding to port 0
// startMiniCluster starts a weed mini instance directly without exec.
// Extra flags can be appended via extraArgs.
// Extra flags (e.g. "-s3.allowDeleteBucketNotEmpty=false") can be appended via extraArgs.
func startMiniCluster(t *testing.T, extraArgs ...string) (*TestCluster, error) {
// Allocate non-colliding ports (including gRPC offsets) for weed mini.
ports := testutil.MustFreeMiniPorts(t, []string{"Master", "Volume", "Filer", "S3"})
@@ -872,7 +872,7 @@ func TestS3DeleteBucketNotEmpty(t *testing.T) {
t.Skip("Skipping integration test in short mode")
}
cluster, err := startMiniCluster(t)
cluster, err := startMiniCluster(t, "-s3.allowDeleteBucketNotEmpty=false")
require.NoError(t, err)
defer cluster.Stop()
+2 -2
View File
@@ -228,8 +228,8 @@ test-with-server: start-server
# Test with different configurations
test-versioning-with-configs: check-deps
@echo "Testing with different S3 configurations..."
@echo "Testing with delete bucket not empty allowed..."
@$(WEED_BINARY) mini -s3.port=$(S3_PORT) -s3.allowDeleteBucketNotEmpty=true > weed-test-config1.log 2>&1 & echo $$! > weed-config1.pid
@echo "Testing with empty folder allowed..."
@$(WEED_BINARY) mini -s3.port=$(S3_PORT) > weed-test-config1.log 2>&1 & echo $$! > weed-config1.pid
@sleep 5
@go test -v -timeout=5m -run "TestVersioningBasicWorkflow" . || true
@if [ -f weed-config1.pid ]; then kill -TERM $$(cat weed-config1.pid) 2>/dev/null || true; rm -f weed-config1.pid; fi
@@ -26,10 +26,6 @@ const (
ucAPIBase = "/api/2.1/unity-catalog"
ucWarehouse = "lakehouse"
ucWarehouseKey = "warehouse"
// A non-empty s3.sessionToken.N puts UC's credential vendor on its
// StaticAwsCredentialGenerator path: it vends the configured access key,
// secret key and this token verbatim instead of calling AWS STS.
ucVendedSessionToken = "unity-catalog-vended-session-token"
// Role used by the master-role STS-vended variant of the test. The trust
// policy is wide open so any caller can assume it; in production UC
@@ -69,9 +65,6 @@ type ucServerOpts struct {
// MasterRoleArn populates aws.masterRoleArn. Empty means UC falls back to
// static aws.accessKey / aws.secretKey for storage operations.
MasterRoleArn string
// SessionToken populates s3.sessionToken.0, selecting UC's static
// credential generator for the per-bucket config.
SessionToken string
// ExtraEnv adds environment variables to the UC container, useful for
// AWS_ENDPOINT_URL_STS-style overrides.
ExtraEnv map[string]string
@@ -227,7 +220,6 @@ func (env *testEnv) startUnityCatalog(t *testing.T, ctx context.Context, opts uc
fmt.Sprintf("s3.awsRoleArn.0=%s", opts.MasterRoleArn),
fmt.Sprintf("s3.accessKey.0=%s", env.accessKey),
fmt.Sprintf("s3.secretKey.0=%s", env.secretKey),
fmt.Sprintf("s3.sessionToken.0=%s", opts.SessionToken),
fmt.Sprintf("s3.endpoint.0=%s", s3EndpointForContainer),
"",
}, "\n")
@@ -8,7 +8,6 @@ package unity_catalog
import (
"context"
"fmt"
"strings"
"testing"
"time"
@@ -47,7 +46,7 @@ func TestUnityCatalogDeltaIntegration(t *testing.T) {
}
t.Log(">>> starting Unity Catalog server (static keys)...")
env.startUnityCatalog(t, ctx, ucServerOpts{SessionToken: ucVendedSessionToken})
env.startUnityCatalog(t, ctx, ucServerOpts{})
t.Log(">>> Unity Catalog ready")
uc := newUCClient(fmt.Sprintf("http://127.0.0.1:%d", env.ucHostPort))
@@ -146,41 +145,28 @@ func TestUnityCatalogDeltaIntegration(t *testing.T) {
}
})
t.Run("TemporaryTableCredentials", func(t *testing.T) {
// s3.sessionToken.0 is set, so UC's AwsCredentialVendor takes the
// StaticAwsCredentialGenerator path and vends the configured keys
// verbatim, token included — no STS call at all. SeaweedFS authenticates
// the request on the static access key and tolerates the foreign token.
t.Run("TemporaryTableCredentialsRejected", func(t *testing.T) {
// With aws.masterRoleArn empty AND no s3.sessionToken.0 set, UC OSS
// always tries to AssumeRole via its internal StsClient (see
// AwsCredentialVendor.createPerBucketCredentialGenerator). Against a
// non-AWS endpoint, that call doesn't reach a real STS, so UC returns
// "S3 bucket configuration not found." or an STS-side error. This is
// the gap users hit at <https://github.com/data-engineering-helpers/mds-in-a-box/blob/main/unitycatalog-playground/etc/conf/server.properties#L45>:
// "with simple S3 access and secret keys, Unity Catalog does not seem
// to work."
//
// The assertion is therefore inverted: we expect a non-nil error from
// /temporary-table-credentials with this configuration. A future
// variant can pin s3.sessionToken.0 (UC's StaticAwsCredentialGenerator
// path) once SeaweedFS' SigV4 path tolerates the vended session token.
if createdTable.TableID == "" {
t.Fatalf("created table has empty table_id; cannot request temporary credentials")
}
creds, err := uc.generateTemporaryTableCredentials(ctx, createdTable.TableID, "READ_WRITE")
if err != nil {
t.Fatalf("temporary-table-credentials: %v", err)
}
awsCreds := creds.AwsTempCredentials
if awsCreds == nil || awsCreds.AccessKeyID == "" || awsCreds.SessionToken == "" {
t.Fatalf("expected aws_temp_credentials with a session_token, got %+v", creds)
}
if awsCreds.AccessKeyID != env.accessKey || awsCreds.SessionToken != ucVendedSessionToken {
t.Fatalf("expected UC to vend the configured static credential, got %+v", awsCreds)
}
s3v := env.newHostS3ClientWithCreds(t, ctx, awsCreds.AccessKeyID, awsCreds.SecretAccessKey, awsCreds.SessionToken)
probeKey := fmt.Sprintf("%s/%s/%s/vended.txt", ucWarehouseKey, schemaName, tableName)
if _, err := s3v.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(ucWarehouse),
Key: aws.String(probeKey),
Body: strings.NewReader("ok"),
}); err != nil {
t.Fatalf("PutObject with UC-vended credentials: %v", err)
}
if _, err := s3v.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(ucWarehouse),
Key: aws.String(probeKey),
}); err != nil {
t.Fatalf("DeleteObject with UC-vended credentials: %v", err)
_, err := uc.generateTemporaryTableCredentials(ctx, createdTable.TableID, "READ_WRITE")
if err == nil {
t.Fatalf("expected /temporary-table-credentials to fail with the static-key playground configuration; it succeeded unexpectedly")
}
t.Logf("expected failure (UC static-key path requires AWS STS): %v", err)
})
t.Run("DeleteTableSchemaCatalog", func(t *testing.T) {
-27
View File
@@ -3,7 +3,6 @@ package sftp
import (
"bytes"
"io"
"os"
"path"
"testing"
@@ -603,32 +602,6 @@ func TestFileContent(t *testing.T) {
sftpClient.Remove("/binary.bin")
})
t.Run("WriteAtOffsetKeepsExistingBytes", func(t *testing.T) {
file, err := sftpClient.Create("/resume.txt")
require.NoError(t, err)
_, err = file.Write([]byte("hello world"))
require.NoError(t, err)
require.NoError(t, file.Close())
// Reopen without O_TRUNC and write past the start, as a resumed upload does
file, err = sftpClient.OpenFile("/resume.txt", os.O_WRONLY)
require.NoError(t, err)
_, err = file.WriteAt([]byte("WORLD!"), 6)
require.NoError(t, err)
require.NoError(t, file.Close())
readFile, err := sftpClient.Open("/resume.txt")
require.NoError(t, err)
content, err := io.ReadAll(readFile)
require.NoError(t, err)
readFile.Close()
require.Equal(t, "hello WORLD!", string(content))
// Clean up
sftpClient.Remove("/resume.txt")
})
t.Run("EmptyFile", func(t *testing.T) {
file, err := sftpClient.Create("/empty.txt")
require.NoError(t, err)
@@ -1,5 +1,3 @@
//go:build !windows && !openbsd && !netbsd && !plan9 && !solaris
package main
import (
-2
View File
@@ -14,7 +14,6 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
adminplugin "github.com/seaweedfs/seaweedfs/weed/admin/plugin"
"github.com/seaweedfs/seaweedfs/weed/cluster"
@@ -1156,7 +1155,6 @@ func (s *AdminServer) SetBucketLifecycle(bucketName string, rules []BucketLifecy
bucketPath := filerConfig.BucketsPath + "/" + bucketName
resp, err := client.ObjectTransaction(context.Background(), &filer_pb.ObjectTransactionRequest{
LockKey: bucketPath,
RequestId: uuid.NewString(),
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + bucketPath,
Mutations: []*filer_pb.ObjectMutation{bucketLifecycleMutation(filerConfig.BucketsPath, bucketName, lifecycleXML)},
})
-2
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"github.com/google/uuid"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
@@ -127,7 +126,6 @@ func (s *AdminServer) writeBucketPolicy(bucketName string, policyJSON []byte) er
bucketPath := filerConfig.BucketsPath + "/" + bucketName
resp, err := client.ObjectTransaction(context.Background(), &filer_pb.ObjectTransactionRequest{
LockKey: bucketPath,
RequestId: uuid.NewString(),
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + bucketPath,
Mutations: []*filer_pb.ObjectMutation{bucketPolicyMutation(filerConfig.BucketsPath, bucketName, policyJSON)},
})
+3 -3
View File
@@ -895,7 +895,7 @@ func (cp *ConfigPersistence) ListTaskDetails() ([]string, error) {
return taskIDs, nil
}
// CleanupCompletedTasks removes old terminal task files beyond the retention limit
// CleanupCompletedTasks removes old completed tasks beyond the retention limit
func (cp *ConfigPersistence) CleanupCompletedTasks() error {
cp.tasksMu.Lock()
defer cp.tasksMu.Unlock()
@@ -914,10 +914,10 @@ func (cp *ConfigPersistence) CleanupCompletedTasks() error {
return fmt.Errorf("failed to load tasks for cleanup: %w", err)
}
// Filter completed and failed tasks, sort by completion time
var completedTasks []*maintenance.MaintenanceTask
for _, task := range allTasks {
switch task.Status {
case maintenance.TaskStatusCompleted, maintenance.TaskStatusFailed, maintenance.TaskStatusCancelled:
if (task.Status == maintenance.TaskStatusCompleted || task.Status == maintenance.TaskStatusFailed) && task.CompletedAt != nil {
completedTasks = append(completedTasks, task)
}
}
-9
View File
@@ -98,10 +98,6 @@ func (cp *ConfigPersistence) ApplyMaintenanceConfigFromToml(v TomlConfig) error
ecConf.ReplicaPlacement = v.GetString(k)
ecChanged = true
}
if k := "maintenance.erasure_coding.strict_placement"; v.IsSet(k) {
ecConf.StrictPlacement = v.GetBool(k)
ecChanged = true
}
if !maintenanceChanged && !vacuumChanged && !balanceChanged && !ecChanged {
return nil
@@ -228,7 +224,6 @@ var pluginConfigSections = []pluginConfigSection{
"min_size_mb": int64Value,
"preferred_tags": stringListValue,
"replica_placement": stringValue,
"strict_placement": boolValue,
},
// workers read collection_filter from the admin values, not the worker values
adminKeys: map[string]func(v TomlConfig, key string) *plugin_pb.ConfigValue{
@@ -245,10 +240,6 @@ func int64Value(v TomlConfig, key string) *plugin_pb.ConfigValue {
return &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(v.GetInt(key))}}
}
func boolValue(v TomlConfig, key string) *plugin_pb.ConfigValue {
return &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: v.GetBool(key)}}
}
func stringValue(v TomlConfig, key string) *plugin_pb.ConfigValue {
return &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_StringValue{StringValue: v.GetString(key)}}
}
@@ -1,108 +0,0 @@
package dash
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
)
func countTaskStateFiles(t *testing.T, dir string) int {
t.Helper()
entries, err := os.ReadDir(filepath.Join(dir, TasksSubdir))
if os.IsNotExist(err) {
return 0
}
if err != nil {
t.Fatalf("read tasks dir: %v", err)
}
count := 0
for _, entry := range entries {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".pb" {
count++
}
}
return count
}
// TestCancelledTaskFilesDoNotAccumulate reproduces issue #11595: every scan
// cycle cancels pending tasks of each detected type and re-detects them, so a
// cancelled task file per candidate volume accumulates on disk forever.
func TestCancelledTaskFilesDoNotAccumulate(t *testing.T) {
dir := t.TempDir()
cp := NewConfigPersistence(dir)
queue := maintenance.NewMaintenanceQueue(nil)
queue.SetPersistence(cp)
for cycle := 0; cycle < 3; cycle++ {
queue.AddTask(&maintenance.MaintenanceTask{
ID: fmt.Sprintf("ec_vol_%d_cycle_%d", cycle, cycle),
Type: "erasure_coding",
VolumeID: uint32(cycle + 1),
Server: "server1",
})
if cancelled := queue.CancelPendingTasksByType("erasure_coding"); cancelled != 1 {
t.Fatalf("cycle %d: cancelled %d tasks, want 1", cycle, cancelled)
}
}
if n := countTaskStateFiles(t, dir); n != 0 {
t.Errorf("%d task files on disk after %d cancel cycles, want 0", n, 3)
}
}
// TestManuallyCancelledTaskFileIsRemoved covers the CancelTask path used by
// the UI: a cancelled pending task must not leave its file behind, where a
// restart would resurrect it as pending.
func TestManuallyCancelledTaskFileIsRemoved(t *testing.T) {
dir := t.TempDir()
cp := NewConfigPersistence(dir)
manager := maintenance.NewMaintenanceManager(nil, nil, cp)
queue := manager.GetQueue()
queue.SetPersistence(cp)
queue.AddTask(&maintenance.MaintenanceTask{
ID: "manual_1",
Type: "vacuum",
VolumeID: 7,
Server: "server1",
})
if n := countTaskStateFiles(t, dir); n != 1 {
t.Fatalf("%d task files after AddTask, want 1", n)
}
if err := manager.CancelTask("manual_1"); err != nil {
t.Fatalf("CancelTask: %v", err)
}
if n := countTaskStateFiles(t, dir); n != 0 {
t.Errorf("%d task files on disk after CancelTask, want 0", n)
}
}
// TestCleanupCompletedTasksBoundsCancelledFiles checks retention covers
// cancelled files, e.g. ones written by older versions.
func TestCleanupCompletedTasksBoundsCancelledFiles(t *testing.T) {
dir := t.TempDir()
cp := NewConfigPersistence(dir)
for i := 0; i < MaxCompletedTasks+5; i++ {
if err := cp.SaveTaskState(&maintenance.MaintenanceTask{
ID: fmt.Sprintf("old_cancelled_%02d", i),
Type: "erasure_coding",
Status: maintenance.TaskStatusCancelled,
}); err != nil {
t.Fatalf("save task state: %v", err)
}
}
if err := cp.CleanupCompletedTasks(); err != nil {
t.Fatalf("CleanupCompletedTasks: %v", err)
}
if n := countTaskStateFiles(t, dir); n > MaxCompletedTasks {
t.Errorf("%d task files after cleanup, want at most %d", n, MaxCompletedTasks)
}
}
-4
View File
@@ -188,10 +188,6 @@ func (s *WorkerGrpcServer) Stop() error {
func (s *WorkerGrpcServer) WorkerStream(stream worker_pb.WorkerService_WorkerStreamServer) error {
ctx := stream.Context()
if err := security.CheckGrpcAdminPeer(ctx, security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key"))); err != nil {
return err
}
// get client address
address := findClientAddress(ctx)
+21 -33
View File
@@ -452,7 +452,6 @@ func (mm *MaintenanceManager) performCleanup() {
removedTasks := mm.queue.CleanupOldTasks(taskRetention)
removedWorkers := mm.queue.RemoveStaleWorkers(workerTimeout)
mm.queue.cleanupCompletedTasks()
// Clean up stale pending operations (operations running for more than 4 hours)
staleOperationTimeout := 4 * time.Hour
@@ -617,48 +616,37 @@ func (mm *MaintenanceManager) saveTaskConfigsFromPolicy(policy *worker_pb.Mainte
// CancelTask cancels a pending task
func (mm *MaintenanceManager) CancelTask(taskID string) error {
mm.queue.mutex.Lock()
defer mm.queue.mutex.Unlock()
task, exists := mm.queue.tasks[taskID]
if !exists {
mm.queue.mutex.Unlock()
return fmt.Errorf("task %s not found", taskID)
}
if task.Status != TaskStatusPending {
status := task.Status
mm.queue.mutex.Unlock()
return fmt.Errorf("task %s cannot be cancelled (status: %s)", taskID, status)
}
task.Status = TaskStatusCancelled
completedTime := time.Now()
task.CompletedAt = &completedTime
cancelledSnapshot := snapshotTask(task)
if task.Status == TaskStatusPending {
task.Status = TaskStatusCancelled
task.CompletedAt = &[]time.Time{time.Now()}[0]
// Remove from pending tasks
for i, pendingTask := range mm.queue.pendingTasks {
if pendingTask.ID == taskID {
mm.queue.pendingTasks = append(mm.queue.pendingTasks[:i], mm.queue.pendingTasks[i+1:]...)
break
// Remove from pending tasks
for i, pendingTask := range mm.queue.pendingTasks {
if pendingTask.ID == taskID {
mm.queue.pendingTasks = append(mm.queue.pendingTasks[:i], mm.queue.pendingTasks[i+1:]...)
break
}
}
// Notify ActiveTopology to release capacity
if mm.scanner != nil && mm.scanner.integration != nil {
if at := mm.scanner.integration.GetActiveTopology(); at != nil {
_ = at.CompleteTask(taskID)
}
}
glog.V(2).Infof("Cancelled task %s", taskID)
return nil
}
// Notify ActiveTopology to release capacity
if mm.scanner != nil && mm.scanner.integration != nil {
if at := mm.scanner.integration.GetActiveTopology(); at != nil {
_ = at.CompleteTask(taskID)
}
}
mm.queue.mutex.Unlock()
if mm.queue.persistence != nil {
mm.queue.persistMu.Lock()
if mm.queue.deleteTaskStateLocked(taskID) != nil {
mm.queue.saveTaskStateLocked(cancelledSnapshot)
}
mm.queue.persistMu.Unlock()
}
glog.V(2).Infof("Cancelled task %s", taskID)
return nil
return fmt.Errorf("task %s cannot be cancelled (status: %s)", taskID, task.Status)
}
// RegisterWorker registers a new worker
+15 -56
View File
@@ -97,53 +97,21 @@ func (mq *MaintenanceQueue) LoadTasksFromPersistence() error {
return nil
}
// isTerminalStatus reports whether the status is a terminal task state.
func isTerminalStatus(status MaintenanceTaskStatus) bool {
return status == TaskStatusCompleted || status == TaskStatusFailed || status == TaskStatusCancelled
}
// saveTaskState saves a task to persistent storage. persistMu orders the
// status check and the write against the cancel paths' deletes, so a stale
// non-terminal file cannot resurrect the task on the next restart.
// saveTaskState saves a task to persistent storage
func (mq *MaintenanceQueue) saveTaskState(task *MaintenanceTask) {
if mq.persistence == nil {
return
}
mq.persistMu.Lock()
defer mq.persistMu.Unlock()
mq.saveTaskStateLocked(task)
}
// saveTaskStateLocked must be called with persistMu held.
func (mq *MaintenanceQueue) saveTaskStateLocked(task *MaintenanceTask) {
mq.mutex.RLock()
live, ok := mq.tasks[task.ID]
stale := !isTerminalStatus(task.Status) && (!ok || isTerminalStatus(live.Status))
mq.mutex.RUnlock()
if stale {
return
}
if err := mq.persistence.SaveTaskState(task); err != nil {
glog.Errorf("Failed to save task state for %s: %v", task.ID, err)
if mq.persistence != nil {
if err := mq.persistence.SaveTaskState(task); err != nil {
glog.Errorf("Failed to save task state for %s: %v", task.ID, err)
}
}
}
func (mq *MaintenanceQueue) deleteTaskState(taskID string) error {
if mq.persistence == nil {
return nil
func (mq *MaintenanceQueue) deleteTaskState(taskID string) {
if mq.persistence != nil {
if err := mq.persistence.DeleteTaskState(taskID); err != nil {
glog.V(2).Infof("Failed to delete task state for %s: %v", taskID, err)
}
}
mq.persistMu.Lock()
defer mq.persistMu.Unlock()
return mq.deleteTaskStateLocked(taskID)
}
// deleteTaskStateLocked must be called with persistMu held.
func (mq *MaintenanceQueue) deleteTaskStateLocked(taskID string) error {
if err := mq.persistence.DeleteTaskState(taskID); err != nil {
glog.Warningf("Failed to delete task state for %s: %v", taskID, err)
return err
}
return nil
}
// cleanupCompletedTasks removes old completed tasks beyond the retention limit
@@ -329,18 +297,9 @@ func (mq *MaintenanceQueue) CancelPendingTasksByType(taskType MaintenanceTaskTyp
mq.pendingTasks = remaining
mq.mutex.Unlock()
// Cancelled is terminal: drop the file like CompleteTask does instead of
// leaving one orphaned .pb per cancelled task per scan cycle. If removal
// fails, persist the cancelled state so a restart drops it instead of
// re-queueing it.
if mq.persistence != nil {
mq.persistMu.Lock()
for _, snapshot := range cancelledSnapshots {
if mq.deleteTaskStateLocked(snapshot.ID) != nil {
mq.saveTaskStateLocked(snapshot)
}
}
mq.persistMu.Unlock()
// Persist cancelled state outside the lock to avoid blocking
for _, snapshot := range cancelledSnapshots {
mq.saveTaskState(snapshot)
}
return cancelled
}
@@ -963,7 +922,7 @@ func generateTaskID() string {
return fmt.Sprintf("%s-%04d", string(b), timestamp)
}
// CleanupOldTasks removes old terminal tasks from memory
// CleanupOldTasks removes old completed and failed tasks
func (mq *MaintenanceQueue) CleanupOldTasks(retention time.Duration) int {
mq.mutex.Lock()
defer mq.mutex.Unlock()
@@ -972,7 +931,7 @@ func (mq *MaintenanceQueue) CleanupOldTasks(retention time.Duration) int {
removed := 0
for id, task := range mq.tasks {
if (task.Status == TaskStatusCompleted || task.Status == TaskStatusFailed || task.Status == TaskStatusCancelled) &&
if (task.Status == TaskStatusCompleted || task.Status == TaskStatusFailed) &&
task.CompletedAt != nil &&
task.CompletedAt.Before(cutoff) {
delete(mq.tasks, id)
@@ -218,10 +218,6 @@ type MaintenanceQueue struct {
policy *MaintenancePolicy
integration *MaintenanceIntegration
persistence TaskPersistence // Interface for task persistence
// persistMu serializes the check+write in saveTaskState against the
// deletes in the cancel paths, so a stale pending save cannot land on
// disk after the task's file was removed.
persistMu sync.Mutex
}
// MaintenanceScanner analyzes the cluster and generates maintenance tasks
-66
View File
@@ -1,66 +0,0 @@
package plugin
import (
"strings"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/protobuf/proto"
)
// JobReplicaHosts are the volume-replica hosts a job will read from.
// Used so executor pick prefers a worker on one of those machines.
// Sources with ShardIds are EC-cleanup rows, not encode replicas.
func JobReplicaHosts(job *plugin_pb.JobSpec) []string {
if job == nil {
return nil
}
var addrs []string
addrs = append(addrs, pluginworker.ReadStringListConfig(job.Parameters, "replica_servers")...)
if src := strings.TrimSpace(pluginworker.ReadStringConfig(job.Parameters, "source_server", "")); src != "" {
addrs = append(addrs, src)
}
if payload := pluginworker.ReadBytesConfig(job.Parameters, "task_params_pb"); len(payload) > 0 {
params := &worker_pb.TaskParams{}
if err := proto.Unmarshal(payload, params); err == nil {
for _, s := range params.GetSources() {
if s == nil || s.Node == "" || len(s.ShardIds) > 0 {
continue
}
addrs = append(addrs, s.Node)
}
}
}
return util.HostKeysFromAddresses(addrs)
}
// partitionByPreferredHost splits candidates into (host matches, others).
// No preferred hosts, or no matches, returns (nil, all) so callers keep
// the existing slots+round-robin path.
func partitionByPreferredHost(candidates []*WorkerSession, preferredHosts []string) (pref, rest []*WorkerSession) {
want := util.HostKeysFromAddresses(preferredHosts)
if len(want) == 0 || len(candidates) == 0 {
return nil, candidates
}
set := make(map[string]struct{}, len(want))
for _, h := range want {
set[h] = struct{}{}
}
for _, c := range candidates {
if c == nil {
continue
}
if _, ok := set[util.HostKey(c.Address)]; ok {
pref = append(pref, c)
continue
}
rest = append(rest, c)
}
if len(pref) == 0 {
return nil, candidates
}
return pref, rest
}
+1 -7
View File
@@ -14,8 +14,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -269,10 +267,6 @@ func (r *Plugin) Shutdown() {
}
func (r *Plugin) WorkerStream(stream plugin_pb.PluginControlService_WorkerStreamServer) error {
if err := security.CheckGrpcAdminPeer(stream.Context(), security.SigningKey(util.GetViper().GetString("jwt.filer_signing.key"))); err != nil {
return err
}
first, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
@@ -615,7 +609,7 @@ func (r *Plugin) ExecuteJob(
releaseGate := r.acquireDetectionExecutionGate(jobType, true)
defer releaseGate()
executor, err := r.registry.PickExecutor(jobType, JobReplicaHosts(job)...)
executor, err := r.registry.PickExecutor(jobType)
if err != nil {
return nil, err
}
-31
View File
@@ -887,37 +887,6 @@ func (r *Plugin) trackExecutionQueued(job *plugin_pb.JobSpec) {
})
}
// bindQueuedJobWorker records the home worker while the job stays pending/queued.
func (r *Plugin) bindQueuedJobWorker(job *plugin_pb.JobSpec, workerID string) {
if job == nil || strings.TrimSpace(job.JobId) == "" || strings.TrimSpace(workerID) == "" {
return
}
now := time.Now().UTC()
r.jobsMu.Lock()
tracked := r.jobs[job.JobId]
if tracked == nil {
r.jobsMu.Unlock()
return
}
if isTerminalTrackedJobState(tracked.State) {
r.jobsMu.Unlock()
return
}
tracked.WorkerID = workerID
tracked.Message = "queued on worker " + workerID
tracked.UpdatedAt = timeToPtr(now)
snap := cloneTrackedJob(*tracked)
r.dirtyJobs = true
r.jobsMu.Unlock()
r.persistJobDetailSnapshot(job.JobId, func(detail *TrackedJob) {
detail.WorkerID = snap.WorkerID
detail.Message = snap.Message
detail.UpdatedAt = snap.UpdatedAt
})
}
func (r *Plugin) cancelQueuedJob(job *plugin_pb.JobSpec, cause error) {
reason := "job canceled"
if cause != nil {
+120 -201
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
@@ -29,18 +28,12 @@ const (
defaultScheduledMaxResults int32 = 1000
defaultScheduledExecutionConcurrency = 1
defaultScheduledPerWorkerConcurrency = 1
// Hold a job on replica-host workers until their reservation load
// reaches this; 0 disables the pin and spills immediately.
defaultLocalQueuePin = 5
// Wait this long after a home worker disappears before stealing
// its queue; 0 steals immediately. Covers a reconnect blink.
defaultOrphanRehomeGrace = 30 * time.Second
maxScheduledExecutionConcurrency = 128
defaultScheduledRetryBackoff = 5 * time.Second
defaultClusterContextTimeout = 10 * time.Second
defaultWaitingBacklogFloor = 8
defaultWaitingBacklogMultiplier = 4
maxEstimatedRuntimeCap = 8 * time.Hour
maxScheduledExecutionConcurrency = 128
defaultScheduledRetryBackoff = 5 * time.Second
defaultClusterContextTimeout = 10 * time.Second
defaultWaitingBacklogFloor = 8
defaultWaitingBacklogMultiplier = 4
maxEstimatedRuntimeCap = 8 * time.Hour
// How long started jobs may drain past the run window close.
scheduledExecutionDrainGrace = 30 * time.Minute
)
@@ -56,12 +49,6 @@ type schedulerPolicy struct {
PerWorkerConcurrency int
RetryLimit int
ExecutorReserveBackoff time.Duration
// LocalQueuePin is K: do not spill off replica-host workers while
// min(load(pref)) < K. Zero is the old immediate-spill path.
LocalQueuePin int
// OrphanRehomeGrace is how long a missing home's queue is held
// before steal-without-K. Zero steals as soon as the worker is gone.
OrphanRehomeGrace time.Duration
}
// laneSchedulerLoop is the main scheduling goroutine for a single lane.
@@ -479,8 +466,6 @@ func (r *Plugin) loadSchedulerPolicy(jobType string) (schedulerPolicy, bool, err
PerWorkerConcurrency: int(adminRuntime.PerWorkerExecutionConcurrency),
RetryLimit: int(adminRuntime.RetryLimit),
ExecutorReserveBackoff: 200 * time.Millisecond,
LocalQueuePin: defaultLocalQueuePin,
OrphanRehomeGrace: defaultOrphanRehomeGrace,
}
if policy.DetectionInterval < r.schedulerTick {
@@ -511,9 +496,6 @@ func (r *Plugin) loadSchedulerPolicy(jobType string) (schedulerPolicy, bool, err
if policy.ExecutionTimeout < defaultScheduledExecutionTimeout {
policy.ExecutionTimeout = defaultScheduledExecutionTimeout
}
if policy.LocalQueuePin < 0 {
policy.LocalQueuePin = 0
}
return policy, true, nil
}
@@ -831,22 +813,19 @@ func (r *Plugin) dispatchScheduledProposals(
// whole dispatch lets manual shell operations interleave between jobs.
jobsRequireLock := LaneRequiresLock(JobTypeLane(jobType))
jobs := make([]*plugin_pb.JobSpec, 0, len(proposals))
jobQueue := make(chan *plugin_pb.JobSpec, len(proposals))
for index, proposal := range proposals {
select {
case <-r.shutdownCh:
for _, job := range jobs {
r.cancelQueuedJob(job, errSchedulerShutdown)
}
return 0, 0, len(jobs)
default:
}
job := buildScheduledJobSpec(jobType, proposal, index)
r.trackExecutionQueued(job)
jobs = append(jobs, job)
select {
case <-r.shutdownCh:
close(jobQueue)
return 0, 0, 0
default:
jobQueue <- job
}
}
queues := r.assignJobsToWorkerQueues(jobType, jobs, policy)
close(jobQueue)
var wg sync.WaitGroup
var statsMu sync.Mutex
@@ -863,110 +842,130 @@ func (r *Plugin) dispatchScheduledProposals(
wg.Add(1)
go func() {
defer wg.Done()
for {
jobLoop:
for job := range jobQueue {
select {
case <-r.shutdownCh:
return
default:
}
if ctx.Err() != nil {
return
}
if queues.isEmpty() {
return
}
item, targetID := r.takeQueuedJob(queues, jobType, policy)
if item == nil {
if queues.isEmpty() {
return
}
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
return
}
if ctx.Err() != nil {
r.cancelQueuedJob(job, ctx.Err())
statsMu.Lock()
canceledCount++
statsMu.Unlock()
continue
}
if ctx.Err() != nil {
queues.pushFront(item)
return
}
executor, release, reserveErr := r.reserveExecutorByID(jobType, policy, targetID)
if reserveErr != nil {
queues.pushFront(item)
for {
select {
case <-r.shutdownCh:
return
default:
}
if ctx.Err() != nil {
return
r.cancelQueuedJob(job, ctx.Err())
statsMu.Lock()
canceledCount++
statsMu.Unlock()
continue jobLoop
}
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
return
}
continue
}
var releaseJobLock func()
if jobsRequireLock {
var lockErr error
releaseJobLock, lockErr = r.acquireAdminLock(fmt.Sprintf("plugin scheduler %s job %s", jobType, item.job.JobId))
if lockErr != nil {
release()
executor, release, reserveErr := r.reserveScheduledExecutor(ctx, jobType, policy)
if reserveErr != nil {
if ctx.Err() != nil {
r.cancelQueuedJob(job, ctx.Err())
statsMu.Lock()
canceledCount++
statsMu.Unlock()
continue jobLoop
}
statsMu.Lock()
errorCount++
statsMu.Unlock()
r.appendActivity(JobActivity{
JobID: item.job.JobId,
JobType: item.job.JobType,
JobType: jobType,
Source: "admin_scheduler",
Message: fmt.Sprintf("scheduled execution lock failed: %v", lockErr),
Message: fmt.Sprintf("scheduled execution reservation failed: %v", reserveErr),
Stage: "failed",
OccurredAt: timeToPtr(time.Now().UTC()),
})
continue
break
}
if ctx.Err() != nil {
var releaseJobLock func()
if jobsRequireLock {
var lockErr error
releaseJobLock, lockErr = r.acquireAdminLock(fmt.Sprintf("plugin scheduler %s job %s", jobType, job.JobId))
if lockErr != nil {
release()
statsMu.Lock()
errorCount++
statsMu.Unlock()
r.appendActivity(JobActivity{
JobID: job.JobId,
JobType: job.JobType,
Source: "admin_scheduler",
Message: fmt.Sprintf("scheduled execution lock failed: %v", lockErr),
Stage: "failed",
OccurredAt: timeToPtr(time.Now().UTC()),
})
break
}
// The lock may have been held by an operator for a
// while; re-check the execution window.
if ctx.Err() != nil {
releaseJobLock()
release()
r.cancelQueuedJob(job, ctx.Err())
statsMu.Lock()
canceledCount++
statsMu.Unlock()
continue jobLoop
}
}
err := r.executeScheduledJobWithExecutor(ctx, executor, job, clusterContext, policy)
if releaseJobLock != nil {
releaseJobLock()
release()
queues.pushFront(item)
return
}
}
err := r.executeScheduledJobWithExecutor(ctx, executor, item.job, clusterContext, policy)
if releaseJobLock != nil {
releaseJobLock()
}
release()
if errors.Is(err, errExecutorAtCapacity) {
queues.pushFront(item)
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
return
}
continue
}
if err != nil {
if ctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
r.cancelQueuedJob(item.job, err)
statsMu.Lock()
canceledCount++
statsMu.Unlock()
release()
if errors.Is(err, errExecutorAtCapacity) {
r.trackExecutionQueued(job)
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
return
}
continue
}
statsMu.Lock()
errorCount++
statsMu.Unlock()
r.appendActivity(JobActivity{
JobID: item.job.JobId,
JobType: item.job.JobType,
Source: "admin_scheduler",
Message: fmt.Sprintf("scheduled execution failed: %v", err),
Stage: "failed",
OccurredAt: timeToPtr(time.Now().UTC()),
})
continue
}
if err != nil {
if ctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
r.cancelQueuedJob(job, err)
statsMu.Lock()
canceledCount++
statsMu.Unlock()
continue jobLoop
}
statsMu.Lock()
errorCount++
statsMu.Unlock()
r.appendActivity(JobActivity{
JobID: job.JobId,
JobType: job.JobType,
Source: "admin_scheduler",
Message: fmt.Sprintf("scheduled execution failed: %v", err),
Stage: "failed",
OccurredAt: timeToPtr(time.Now().UTC()),
})
break
}
statsMu.Lock()
successCount++
statsMu.Unlock()
statsMu.Lock()
successCount++
statsMu.Unlock()
break
}
}
}()
}
@@ -977,8 +976,8 @@ func (r *Plugin) dispatchScheduledProposals(
if drainErr == nil {
drainErr = errSchedulerShutdown
}
for _, item := range queues.drain() {
r.cancelQueuedJob(item.job, drainErr)
for job := range jobQueue {
r.cancelQueuedJob(job, drainErr)
canceledCount++
}
@@ -989,7 +988,6 @@ func (r *Plugin) reserveScheduledExecutor(
ctx context.Context,
jobType string,
policy schedulerPolicy,
preferredHosts []string,
) (*WorkerSession, func(), error) {
if ctx == nil {
ctx = context.Background()
@@ -1017,7 +1015,7 @@ func (r *Plugin) reserveScheduledExecutor(
return nil, nil, fmt.Errorf("timed out waiting for executor capacity for %s", jobType)
}
executors, err := r.registry.ListExecutors(jobType, preferredHosts...)
executors, err := r.registry.ListExecutors(jobType)
if err != nil {
if !waitForShutdownOrTimerWithContext(r.shutdownCh, ctx, policy.ExecutorReserveBackoff) {
if ctx.Err() != nil {
@@ -1028,19 +1026,11 @@ func (r *Plugin) reserveScheduledExecutor(
continue
}
pref, rest := partitionByPreferredHost(executors, preferredHosts)
lane := JobTypeLane(jobType)
loadOf := func(e *WorkerSession) int { return r.executorReservationLoad(e, lane) }
if len(pref) > 0 {
sortExecutorsByLoad(pref, loadOf)
if executor, release, ok := r.tryReserveFirst(pref, jobType, policy); ok {
return executor, release, nil
for _, executor := range executors {
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
if !ok {
continue
}
}
sortExecutorsByLoad(rest, loadOf)
if executor, release, ok := r.tryReserveFirst(rest, jobType, policy); ok {
return executor, release, nil
}
@@ -1061,75 +1051,6 @@ func (r *Plugin) tryReserveExecutorCapacity(
return r.tryReserveExecutorCapacityForLane(executor, jobType, policy, JobTypeLane(jobType))
}
func (r *Plugin) tryReserveFirst(executors []*WorkerSession, jobType string, policy schedulerPolicy) (*WorkerSession, func(), bool) {
for _, executor := range executors {
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
if ok {
return executor, release, true
}
}
return nil, nil, false
}
func (r *Plugin) reserveExecutorByID(jobType string, policy schedulerPolicy, workerID string) (*WorkerSession, func(), error) {
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return nil, nil, fmt.Errorf("executor id is empty")
}
executors, err := r.registry.ListExecutors(jobType)
if err != nil {
return nil, nil, err
}
for _, executor := range executors {
if executor == nil || executor.WorkerID != workerID {
continue
}
release, ok := r.tryReserveExecutorCapacity(executor, jobType, policy)
if !ok {
return nil, nil, errExecutorAtCapacity
}
return executor, release, nil
}
return nil, nil, fmt.Errorf("executor %s is not available for %s", workerID, jobType)
}
func (r *Plugin) executorReservationLoad(executor *WorkerSession, lane SchedulerLane) int {
if executor == nil {
return 0
}
load := 0
if executor.Heartbeat != nil && executor.Heartbeat.ExecutionSlotsUsed > 0 {
load = int(executor.Heartbeat.ExecutionSlotsUsed)
}
workerID := strings.TrimSpace(executor.WorkerID)
if ls := r.lanes[lane]; ls != nil {
ls.execMu.Lock()
load += ls.execRes[workerID]
ls.execMu.Unlock()
return load
}
r.schedulerExecMu.Lock()
load += r.schedulerExecReservations[workerID]
r.schedulerExecMu.Unlock()
return load
}
func sortExecutorsByLoad(executors []*WorkerSession, load func(*WorkerSession) int) {
sort.SliceStable(executors, func(i, j int) bool {
if executors[i] == nil {
return false
}
if executors[j] == nil {
return true
}
li, lj := load(executors[i]), load(executors[j])
if li != lj {
return li < lj
}
return executors[i].WorkerID < executors[j].WorkerID
})
}
// tryReserveExecutorCapacityForLane reserves an execution slot on the
// per-lane reservation pool so that lanes cannot starve each other.
func (r *Plugin) tryReserveExecutorCapacityForLane(
@@ -1656,8 +1577,6 @@ func (r *Plugin) DispatchProposals(
ExecutionTimeout: defaultScheduledExecutionTimeout,
RetryBackoff: defaultScheduledRetryBackoff,
ExecutorReserveBackoff: 200 * time.Millisecond,
LocalQueuePin: defaultLocalQueuePin,
OrphanRehomeGrace: defaultOrphanRehomeGrace,
}
}

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