mirror of
https://github.com/pgsty/minio.git
synced 2026-11-10 17:45:32 +03:00
Compare commits
280 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 099b7ddf37 | |||
| c054569b9f | |||
| af3064f2b5 | |||
| 9d2be5daaa | |||
| 1f3377862b | |||
| 16738c2301 | |||
| 53a8f7583f | |||
| 657fc5e827 | |||
| 6a7beb8a41 | |||
| bd39bab339 | |||
| 04d7d91973 | |||
| 7a13780d81 | |||
| b8ebaffd2e | |||
| 5cb91e7d74 | |||
| 07b89b98fa | |||
| 378f281bef | |||
| 5fb983d3d2 | |||
| 9f9398a1a3 | |||
| 1516273162 | |||
| 210fd568cc | |||
| f3e84d54a9 | |||
| 479a49b28b | |||
| 8cc2360c3a | |||
| 41b71a8bc1 | |||
| 00270e9f61 | |||
| c78ef6a6f6 | |||
| e9da6f91f4 | |||
| 6434d768de | |||
| b524fc4fb7 | |||
| bdd17b0f47 | |||
| ade15ea5d0 | |||
| 98fd7004e8 | |||
| 96b3a19d0a | |||
| db0c0ffedd | |||
| 7af07298ce | |||
| b10fd0e9c0 | |||
| 852e2a6707 | |||
| 04c657d03f | |||
| 3268fbea7e | |||
| 94d91703b5 | |||
| 1eee720f74 | |||
| 1780eb8ffc | |||
| b32a389e9d | |||
| 2a66ab65d7 | |||
| 0c6aaca6e2 | |||
| 1bd0b1b04d | |||
| d56b1063b9 | |||
| a4453a1a55 | |||
| 617d8ed67b | |||
| 928c827508 | |||
| f4b6c04c9f | |||
| 54186040ca | |||
| 69a4f938a2 | |||
| 3e954ad06e | |||
| c45b26a75c | |||
| 77ad2fc12b | |||
| adc824087b | |||
| 21b6711562 | |||
| 9fe32b7743 | |||
| ae54718d84 | |||
| 303e340292 | |||
| a717711b11 | |||
| 57c2b0fb76 | |||
| 9fefb756ad | |||
| a7e7682e1b | |||
| 09d6a22993 | |||
| b7062cae75 | |||
| e5bcbc7d82 | |||
| b3a5307980 | |||
| 45b1e4bc00 | |||
| 14d49f14f7 | |||
| 584bb3cc53 | |||
| f85d4a5fa1 | |||
| 67a6532dec | |||
| 28a9e67ac7 | |||
| 7fac646923 | |||
| 32ab506924 | |||
| 7f38bf6282 | |||
| 2b228f678c | |||
| 73025824a3 | |||
| 0fa4a8c503 | |||
| dd0a2da55e | |||
| c8dd5678bb | |||
| 9f7b49369d | |||
| ed05d982a1 | |||
| f321f7f1c7 | |||
| 82dd12faf8 | |||
| 3ddf83182f | |||
| 374b9ce309 | |||
| 0c5a309507 | |||
| f83d02831c | |||
| 496eab4b76 | |||
| add36e7052 | |||
| ea483e453e | |||
| ce60cc0c84 | |||
| 997846f413 | |||
| 7c9b6386c6 | |||
| ba0b59d0b2 | |||
| d9837fb319 | |||
| 8579466557 | |||
| 4f72d0ea94 | |||
| 3c4980eb80 | |||
| 50a234d6c3 | |||
| a02ce222f0 | |||
| 8ae19de7a8 | |||
| 7a4f0b5e69 | |||
| 5ac27f7533 | |||
| 3c8699e216 | |||
| fa42a09826 | |||
| fcbf2c7d49 | |||
| 18ba0ef32a | |||
| 65c59dd860 | |||
| e7103d77cb | |||
| 167b95dfd8 | |||
| 3a1e132f16 | |||
| 30588eb830 | |||
| 393a84076f | |||
| 69fcf50cd2 | |||
| a21f420de3 | |||
| 6da8036eb3 | |||
| dcad2ae3e7 | |||
| 9897589bc5 | |||
| 525ab5fa40 | |||
| d4e7716085 | |||
| a803027b09 | |||
| 001078e9ca | |||
| 8c1473a618 | |||
| 347d9763fd | |||
| 0fc5a0b527 | |||
| a47bacbc0f | |||
| 0a546c7a76 | |||
| 8dac7f7c14 | |||
| 4cc3bc1de6 | |||
| 0cfca29cb2 | |||
| a7310d59fe | |||
| a995921e57 | |||
| 19910230aa | |||
| 7ce7fc8d1e | |||
| fb1a619ee1 | |||
| 3f13c26eed | |||
| dd905b29f4 | |||
| 2f728c7643 | |||
| c3ad836aed | |||
| dc6dc3d806 | |||
| dcc2f20ba8 | |||
| a13efd5760 | |||
| 39278e068e | |||
| 263ee0510d | |||
| 641eb51a40 | |||
| 45d6e5d065 | |||
| dabc894132 | |||
| fb08ca7b6a | |||
| 41822710e4 | |||
| 034f7bbf73 | |||
| 79eca5e920 | |||
| aec7ddac46 | |||
| 475640e7e1 | |||
| 62124c779c | |||
| 030c6d740e | |||
| 5f1f4178ae | |||
| 0828861bd0 | |||
| 6921f8c186 | |||
| 25d928c9df | |||
| ee3e7ea716 | |||
| 2a04d5f485 | |||
| 9449e6a4a8 | |||
| 9c4bb39571 | |||
| 86ce8d6012 | |||
| b15aa97c94 | |||
| f54c04846e | |||
| 6fdea3b8d9 | |||
| 380fcbfe47 | |||
| a02fa9644c | |||
| 652a1c09d3 | |||
| c86df14f4d | |||
| 03875d61e3 | |||
| 1865985aa1 | |||
| 076a13f9d2 | |||
| 9a4e089f56 | |||
| 9255f443a6 | |||
| ea7c794a94 | |||
| 16c21428d9 | |||
| 7e83f9950a | |||
| 1e3ab56b06 | |||
| 5ad1f344b7 | |||
| 8a4097309f | |||
| 0501a198e3 | |||
| d2eb0ade91 | |||
| 5c074efd1d | |||
| 03fe5e9c21 | |||
| 0b1c21cb4b | |||
| b55495aef0 | |||
| d8b77e5f3b | |||
| cd1f6cec3c | |||
| 4f17a23e33 | |||
| be44ff2669 | |||
| d16f5ed9c8 | |||
| 6d890fc860 | |||
| aafe4f8886 | |||
| a2073d2514 | |||
| 3c82748e14 | |||
| 1e94c5a30d | |||
| 000e653038 | |||
| 945dc16060 | |||
| a30270d9ba | |||
| 1841fce4c4 | |||
| f2b8d4b9cb | |||
| 8ed5c90dcd | |||
| 789a2327ba | |||
| e3e0c6215d | |||
| b91e1c6f2a | |||
| 969a6a42d3 | |||
| 1b5477f3df | |||
| a07d655dc6 | |||
| d43adc0f2b | |||
| 3df51549bb | |||
| 324bb308dd | |||
| 625c11e7e1 | |||
| 4059c25e22 | |||
| 011b0df5f5 | |||
| 9faeb7a114 | |||
| 856f8f4c55 | |||
| 0cd388c666 | |||
| 1748f62c7e | |||
| 60c7179ddb | |||
| 7007c164a5 | |||
| 51b7de6a0d | |||
| 4733e1423c | |||
| 4189869e43 | |||
| 0f9199e0c3 | |||
| bb60871f57 | |||
| 2c820895d7 | |||
| 2080b3d031 | |||
| 9954392605 | |||
| 3dd53eb81f | |||
| 86fb2b833f | |||
| fbcb511978 | |||
| 87c1c6b3dc | |||
| 1e0e5ac53c | |||
| 7e9454456e | |||
| 4ed86dfe62 | |||
| 4e55fc7db4 | |||
| 2616d43fbc | |||
| 2a4db6a4f9 | |||
| e90cb2067d | |||
| 00a375c4e2 | |||
| ab4c97e607 | |||
| 721dbfd1b7 | |||
| 2926474688 | |||
| a7370aa9ac | |||
| a592d69f45 | |||
| bfc7906064 | |||
| eeff112491 | |||
| a70156e45b | |||
| 99245e3ef8 | |||
| 7a3ca0f821 | |||
| d94b3b3c92 | |||
| 86c5a92eef | |||
| cdc3b8cb10 | |||
| 0d3ba5d4cb | |||
| 71a1631f13 | |||
| 2d57b975d1 | |||
| 14042b5aab | |||
| 38a5ab549c | |||
| fa565ec9fc | |||
| 3196480da9 | |||
| cb85461c42 | |||
| ba6e8c8d69 | |||
| a54d0c4354 | |||
| 00d4a206f7 | |||
| f480086b6b | |||
| 968c30238c | |||
| fec1e0804a | |||
| bcb5fc9566 | |||
| 394d9db212 | |||
| 8c784f6133 | |||
| 258b167b1c | |||
| 69d5e92791 | |||
| efd5698597 | |||
| 2fde3cf535 |
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
# Dockerfile.goreleaser and Dockerfile.distroless pin their bases by digest
|
||||
# so a release tag rebuilds from the same bytes. Without this the pins would
|
||||
# silently rot and stop receiving base-image CVE fixes.
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
commit-message:
|
||||
prefix: build
|
||||
@@ -27,6 +27,10 @@ archives:
|
||||
ids:
|
||||
- silo
|
||||
name_template: "silo_{{ .Env.PKG_VERSION }}_{{ .Os }}_{{ .Arch }}"
|
||||
formats: ["tar.gz"]
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: ["zip"]
|
||||
# Explicit so the license materials cannot silently drop out of the
|
||||
# binary archives: GoReleaser's default file globs would miss NOTICE.
|
||||
# CREDITS stays out deliberately -- at 1.8MB it would dominate the
|
||||
|
||||
@@ -148,7 +148,9 @@ jobs:
|
||||
for rpm_file in finalize/packages/*.rpm; do
|
||||
signature_output="$(sudo rpmkeys --checksig --verbose "${rpm_file}")"
|
||||
printf '%s\n' "${signature_output}"
|
||||
if ! printf '%s\n' "${signature_output}" | tr '[:upper:]' '[:lower:]' | grep -q "key id ${key_id}: ok"; then
|
||||
normalized_signature="$(printf '%s\n' "${signature_output}" | tr '[:upper:]' '[:lower:]')"
|
||||
full_fingerprint="$(printf '%s' "${expected_fingerprint}" | tr '[:upper:]' '[:lower:]')"
|
||||
if ! printf '%s\n' "${normalized_signature}" | grep -Eq "key id ${key_id}: ok|key fingerprint: ${full_fingerprint}: ok"; then
|
||||
echo "Signature verification failed for ${rpm_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -90,11 +90,33 @@ jobs:
|
||||
- name: Run S3 Select tests under race detector
|
||||
run: go test -race ./internal/s3select/... -count=1
|
||||
|
||||
- name: Run conditional PUT tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(PoolsConditionalPut|SinglePoolConditionalPutHTTP)' -count=1 -timeout=5m
|
||||
- name: Run conditional PUT and multipart tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(PoolsConditionalPut|SinglePoolConditionalPutHTTP|PoolsMultipartCondition)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run multipart listing and cancellation tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(MultipartListing|MultipartAbort|PaginateMultipartUploads|ListMultipartUploads)' -count=1 -timeout=5m
|
||||
- name: Run multi-pool delete consistency tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(Pools(Delete|BulkDelete|RetiringCopyPreservesSharedTierObject)|BulkDeleteQuorumEvidence)' -count=1 -timeout=5m
|
||||
|
||||
# Build tags: kqueue selects the kqueue backend of rjeczalik/notify on
|
||||
# BSD and macOS and changes nothing on Linux. dev came with upstream's
|
||||
# 2024-06 Makefile change for development builds; no file in this
|
||||
# repository or its dependencies carries a dev build constraint, so it
|
||||
# selects nothing and is kept only so that the documented command
|
||||
# lines (make test, make install-race) stay valid.
|
||||
- name: Run lifecycle expiry and native restore tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(PoolsLifecycle|PoolsMigrationLifecycle|ExpireRestored|NativeRestore)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run multipart listing, cancellation and cleanup tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race ./cmd -run '^Test(ObjectCompleteMultipartUpload|Multipart|PaginateMultipartUploads|ListMultipartUploads|CleanupStaleUploads|ExtractModTime)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run listing metadata recovery, pagination and cancellation tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(ListFallback|ListingCache|ListingMerge|ListingRecovery|ListingPending|ListingEntry|ListingSecondLock|StreamMetadataParts|LocalLockerListing|DistributedListing|MergeXLV2LatestNullCrossParity|CountXLV2MixedECGroups|ListCrossParity|ListObjectVersionsCrossParityNull|MigrationCrossParity)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run lock cancellation and late-grant cleanup tests under race detector
|
||||
run: go test -race ./internal/dsync ./internal/lsync -run '^Test(DRWMutex|LRWMutex)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run CPU metrics tests under race detector
|
||||
run: go test -race ./cmd -run '^TestLoadCPUMetrics' -count=1 -timeout=5m
|
||||
@@ -102,6 +124,22 @@ jobs:
|
||||
- name: Run tag replication tests under race detector
|
||||
run: go test -race ./cmd -run '^TestAPITagging' -count=1 -timeout=5m
|
||||
|
||||
- name: Run Object Lock authorization and overwrite tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(APIPutObjectRetention|APIVersionAddressedWrite|PutObjectRetention|DirectoryObjectLock|ObjectLockOverwrite|APICopyObjectPreservesVersionAfterSuspension|MultipartNullObjectLock|PoolsObjectLockOverwrite|MultipartVersioningTransitions|PlanReview|RecoveredMultipartUpload)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run maintenance storage timeout and compatibility tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race -tags kqueue,dev ./cmd ./internal/grid ./internal/ioutil -run '^Test(StorageTimeout|StorageRollback|StoragePrerelease|MaintenancePartialFailedOverwriteAvailability|SingleRequest|WithDeadline)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run logger target lifecycle tests under race detector
|
||||
run: go test -race ./cmd -run '^TestDynamicWebhookOutlivesConfigContext' -count=1 -timeout=5m
|
||||
|
||||
- name: Compile deferred consistency diagnostics
|
||||
run: go test -tags kqueue,dev,consistency_strict ./cmd -run '^$'
|
||||
|
||||
crosscompile:
|
||||
name: Cross Compile
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -70,6 +70,12 @@ jobs:
|
||||
buildscripts/verify-rebrand.sh
|
||||
dockerscripts/docker-entrypoint_test.sh
|
||||
|
||||
# Keep this gate aligned with the Object Lock race regression in go.yml.
|
||||
- name: Gate release on Object Lock authorization and overwrite tests under race detector
|
||||
env:
|
||||
MINIO_API_REQUESTS_MAX: "10000"
|
||||
run: go test -race -tags kqueue,dev ./cmd -run '^Test(APIPutObjectRetention|APIVersionAddressedWrite|PutObjectRetention|DirectoryObjectLock|ObjectLockOverwrite|APICopyObjectPreservesVersionAfterSuspension|MultipartNullObjectLock|PoolsObjectLockOverwrite|MultipartVersioningTransitions|PlanReview|RecoveredMultipartUpload)' -count=1 -timeout=5m
|
||||
|
||||
- name: Compute release variables
|
||||
env:
|
||||
# Passed through the environment, never interpolated into the script
|
||||
@@ -134,7 +140,7 @@ jobs:
|
||||
- name: Build Draft release with GoReleaser
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
version: v2.18.1
|
||||
version: v2.18.3
|
||||
args: release --clean --skip=validate --config .github/goreleaser.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -150,7 +156,7 @@ jobs:
|
||||
- name: Install nFPM
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0
|
||||
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.48.0
|
||||
echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Build nFPM packages
|
||||
|
||||
@@ -34,10 +34,11 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: python -m pip install -r buildscripts/repository-cards/requirements.txt
|
||||
- name: Use the runner's Python in an isolated environment
|
||||
run: |
|
||||
python3 -m venv "$RUNNER_TEMP/repository-cards-venv"
|
||||
"$RUNNER_TEMP/repository-cards-venv/bin/python" -m pip install -r buildscripts/repository-cards/requirements.txt
|
||||
echo "$RUNNER_TEMP/repository-cards-venv/bin" >> "$GITHUB_PATH"
|
||||
- run: python -m unittest discover -s buildscripts/repository-cards -p 'test_*.py' -v
|
||||
|
||||
publish:
|
||||
@@ -54,10 +55,11 @@ jobs:
|
||||
OUTPUT_BRANCH: codex/repository-cards
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: python -m pip install -r buildscripts/repository-cards/requirements.txt
|
||||
- name: Use the runner's Python in an isolated environment
|
||||
run: |
|
||||
python3 -m venv "$RUNNER_TEMP/repository-cards-venv"
|
||||
"$RUNNER_TEMP/repository-cards-venv/bin/python" -m pip install -r buildscripts/repository-cards/requirements.txt
|
||||
echo "$RUNNER_TEMP/repository-cards-venv/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Load the generated-assets branch
|
||||
shell: bash
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
docker build --build-arg TARGETARCH=${{ matrix.arch }} \
|
||||
--target curl-runtime -f Dockerfile.goreleaser -t silo-curl-test .
|
||||
docker run --rm silo-curl-test --version | tee curl-version.txt
|
||||
grep -F 'curl 8.22.0 ' curl-version.txt
|
||||
grep -F 'curl 8.23.0 ' curl-version.txt
|
||||
grep -F 'HTTP2' curl-version.txt
|
||||
docker run --rm silo-curl-test --fail --silent --show-error \
|
||||
--connect-timeout 15 --max-time 60 https://curl.se/robots.txt
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: GoReleaser config check
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
version: v2.18.1
|
||||
version: v2.18.3
|
||||
args: check --config .github/goreleaser.yml
|
||||
|
||||
- name: Validate Helm chart and legacy upgrade identity
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
- name: Build snapshot artifacts
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
version: v2.18.1
|
||||
version: v2.18.3
|
||||
# A pull-request snapshot has no trusted release identity. Exercise
|
||||
# the SBOM/checksum pipeline here, and reserve keyless signing for
|
||||
# the tag-triggered release workflow with GitHub OIDC.
|
||||
@@ -120,13 +120,34 @@ jobs:
|
||||
LDFLAGS: ${{ env.LDFLAGS }}
|
||||
PKG_VERSION: ${{ env.PKG_VERSION }}
|
||||
|
||||
- name: Verify archive SBOM and checksum coverage
|
||||
- name: Verify archive formats, documentation, SBOMs, and checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t archives < <(find dist -maxdepth 1 -type f -name 'silo_*.tar.gz' | sort)
|
||||
mapfile -t sboms < <(find dist -maxdepth 1 -type f -name 'silo_*.tar.gz.sbom.json' | sort)
|
||||
mapfile -t archives < <(find dist -maxdepth 1 -type f \( -name 'silo_*.tar.gz' -o -name 'silo_*.zip' \) | sort)
|
||||
mapfile -t sboms < <(find dist -maxdepth 1 -type f \( -name 'silo_*.tar.gz.sbom.json' -o -name 'silo_*.zip.sbom.json' \) | sort)
|
||||
test "${#archives[@]}" -eq 6
|
||||
test "${#sboms[@]}" -eq 6
|
||||
for os in linux darwin windows; do
|
||||
format=tar.gz
|
||||
binary=silo
|
||||
if [ "${os}" = windows ]; then
|
||||
format=zip
|
||||
binary=silo.exe
|
||||
fi
|
||||
for arch in amd64 arm64; do
|
||||
archive="dist/silo_${PKG_VERSION}_${os}_${arch}.${format}"
|
||||
test -s "${archive}"
|
||||
jq -e '.spdxVersion | startswith("SPDX-")' "${archive}.sbom.json" >/dev/null
|
||||
if [ "${format}" = zip ]; then
|
||||
listing="$(unzip -Z1 "${archive}")"
|
||||
else
|
||||
listing="$(tar -tzf "${archive}")"
|
||||
fi
|
||||
for member in "${binary}" LICENSE NOTICE README.md; do
|
||||
grep -Fx "${member}" <<< "${listing}"
|
||||
done
|
||||
done
|
||||
done
|
||||
manifest="dist/silo_${PKG_VERSION}_checksums.txt"
|
||||
test -s "${manifest}"
|
||||
(
|
||||
@@ -143,7 +164,7 @@ jobs:
|
||||
- name: Install package validation tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0
|
||||
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.48.0
|
||||
echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}"
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes rpm binutils
|
||||
@@ -461,6 +482,17 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
# Both variants must trust the same roots: the UBI bundle with the
|
||||
# RHSB-2026-012 blocklist applied, not Debian's bundle.
|
||||
tar -xOf "${ctx}/rootfs.tar" etc/ssl/certs/ca-certificates.crt > "${ctx}/ca.pem"
|
||||
openssl crl2pkcs7 -nocrl -certfile "${ctx}/ca.pem" \
|
||||
| openssl pkcs7 -print_certs -noout > "${ctx}/ca-subjects.txt"
|
||||
test "$(grep -c '^subject=' "${ctx}/ca-subjects.txt")" -gt 100
|
||||
if grep -E '^subject=.*(AAA Certificate Services|emSign ECC Root CA - G3)' "${ctx}/ca-subjects.txt"; then
|
||||
echo "distroless CA bundle still trusts a blocklisted root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The baked-in healthcheck must drive Docker's health state on its
|
||||
# own, the probe binary must be directly exec-able without any
|
||||
# shell, and SIGTERM must still reach PID 1 (the server binary is
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
- name: Install govulncheck
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@v1.8.0
|
||||
go install golang.org/x/vuln/cmd/govulncheck@v1.9.0
|
||||
echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Run govulncheck
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright 2026 PGSTY contributors.
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
FROM golang:1.27.3-bookworm
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
GOTOOLCHAIN=local \
|
||||
CGO_ENABLED=0 \
|
||||
MINIO_API_REQUESTS_MAX=10000
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git jq openssl python3 procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Cache dependencies in the final image: subsequent analysis runs offline.
|
||||
# Honor go.mod's PGSTY replacements and leave the module files unchanged.
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download && go mod verify
|
||||
|
||||
COPY . /src
|
||||
|
||||
# Keep normal Go debug information; the release Makefile strips it.
|
||||
RUN go build -tags kqueue -o /usr/local/bin/silo . \
|
||||
&& silo --version
|
||||
|
||||
# Compile test packages while online, including the server and internal code.
|
||||
# Retain both the module and build caches so new reproducers work offline.
|
||||
RUN go test -tags kqueue,dev -run '^$' -timeout=10m ./cmd ./internal/...
|
||||
|
||||
ENV GOPROXY=off \
|
||||
GOSUMDB=off
|
||||
|
||||
CMD ["bash"]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Override the release .dockerignore: keep source, CI files and all test fixtures.
|
||||
# The scanner adds Git history separately after building the image.
|
||||
.git
|
||||
.bin
|
||||
.claude
|
||||
.codex
|
||||
.idea
|
||||
.release-sign
|
||||
.vscode
|
||||
_bmad
|
||||
_bmad-output
|
||||
dist
|
||||
vendor
|
||||
**/node_modules
|
||||
**/*.test
|
||||
**/.DS_Store
|
||||
default.etcd
|
||||
docs/investigations
|
||||
docs/security
|
||||
docs/rebranding.md
|
||||
|
||||
# Local server and debugging binaries.
|
||||
minio
|
||||
silo
|
||||
hash-set
|
||||
healing-bin
|
||||
inspect
|
||||
pprofgoparser
|
||||
reorder-disks
|
||||
s3-check-md5
|
||||
s3-verify
|
||||
xattr
|
||||
xl-meta
|
||||
docs/debugging/s3-verify/s3-verify
|
||||
docs/debugging/xl-meta/xl-meta
|
||||
docs/debugging/s3-check-md5/s3-check-md5
|
||||
docs/debugging/hash-set/hash-set
|
||||
docs/debugging/healing-bin/healing-bin
|
||||
docs/debugging/inspect/inspect
|
||||
docs/debugging/pprofgoparser/pprofgoparser
|
||||
docs/debugging/reorder-disks/reorder-disks
|
||||
docs/debugging/populate-hard-links/populate-hardlinks
|
||||
docs/debugging/xattr/xattr
|
||||
@@ -0,0 +1,131 @@
|
||||
# PGSTY Silo security scope
|
||||
|
||||
Silo is PGSTY's independently maintained S3-compatible object-storage server,
|
||||
derived from MinIO. It serves untrusted network requests and stores user data,
|
||||
including PostgreSQL backups. Scan the current `pgsty/silo` `main` branch.
|
||||
Read `/src/SECURITY.md`, `/src/VULNERABILITY_REPORT.md`, and `/src/CHANGELOG.md`.
|
||||
|
||||
## Repository boundaries and lineage
|
||||
|
||||
The Go module intentionally remains `github.com/minio/minio`. Historical
|
||||
`MINIO_*` environment variables, S3 headers, `/minio/*` routes, and `.minio.sys`
|
||||
storage names are compatibility interfaces, not evidence that upstream MinIO
|
||||
is the target. Use this checkout's code and dependency versions for reproducers.
|
||||
|
||||
This enrollment covers the server code, including inherited code still present
|
||||
in the supported branch and its PGSTY changes. The maintained Console, client,
|
||||
and shared packages appear as dependencies in `go.mod`. Do not replace them
|
||||
with upstream releases. They are not additional independent scan targets under
|
||||
this enrollment. A server-side integration defect is in scope; clearly identify
|
||||
a dependency-only root cause rather than attributing it to this repository.
|
||||
|
||||
## Attackers and important surfaces
|
||||
|
||||
- Unauthenticated S3/HTTP clients, including hostile object names, headers,
|
||||
query parameters, XML/JSON bodies, signatures, and multipart data.
|
||||
- Authenticated users, service accounts, and temporary STS credentials whose
|
||||
authority must remain bounded by the effective IAM and bucket policies.
|
||||
- Bucket owners and delegated administrators with explicitly limited actions;
|
||||
distinguish them from the unrestricted deployment/root administrator.
|
||||
- Inputs from configured identity providers, replication/tiering targets, and
|
||||
webhooks where the configuration and permissions make an attack reachable.
|
||||
|
||||
Prioritize authentication/signature verification, IAM/STS and policy
|
||||
composition, administrative API authorization, object/version isolation,
|
||||
Object Lock retention and legal holds, multipart operations, replication,
|
||||
lifecycle/tiering, and multi-pool deletion/migration. Include archive, XML,
|
||||
JSON and S3 Select parsing when remotely reachable. Investigate credential
|
||||
exposure, path traversal, unauthorized object access, SSRF across a meaningful
|
||||
boundary, and attacks that corrupt or irreversibly delete other users' data.
|
||||
|
||||
The deployment administrator controls root credentials, storage directories,
|
||||
trusted peers, and identity/KMS/replication endpoint configuration. Host root
|
||||
access, stolen root credentials, or a malicious replacement of the server
|
||||
binary do not by themselves demonstrate a vulnerability. An unprivileged
|
||||
account gaining those capabilities does. An administrator's documented ability
|
||||
to configure an outbound endpoint is not sufficient evidence of SSRF; show
|
||||
an unauthorized actor, forbidden destination, or confused-deputy effect.
|
||||
|
||||
Object Lock has explicit retention semantics. Do not discard a retention bypass
|
||||
just because the caller has some administrative permission: describe the mode,
|
||||
version, legal hold, and exact IAM actions, and compare with the intended rules.
|
||||
Likewise, do not assume bucket access implies access to other buckets or users.
|
||||
|
||||
Known consistency limitations are described in `CONTRIBUTING.md` and the
|
||||
changelog. Tests tagged `consistency_strict` exercise stronger guarantees
|
||||
outside the current maintenance contract and can fail. A failing diagnostic
|
||||
alone is not a new vulnerability; demonstrate a reachable security impact.
|
||||
These limitations do not excuse an authorization or retention bypass.
|
||||
|
||||
## Offline environment
|
||||
|
||||
Source is in `/src`; the unstripped server is `/usr/local/bin/silo`. Go, the
|
||||
module cache, and the test build cache are retained. The scanner adds Git
|
||||
history to `/src/.git` after building this image; a plain Docker build omits it.
|
||||
The Dockerfile compiles the server and tests with the selected PGSTY dependency
|
||||
replacements. `GOTOOLCHAIN=local` and `GOPROXY=off` prevent surprise downloads.
|
||||
CGO is disabled as in the normal server/unit-test CI; enable it explicitly for
|
||||
race tests when needed (the Go base image includes a C compiler).
|
||||
|
||||
Useful offline checks:
|
||||
|
||||
```sh
|
||||
cd /src
|
||||
silo --version
|
||||
go test -tags kqueue,dev ./internal/auth ./internal/config/identity/... ./internal/s3select/... -count=1 -timeout=10m
|
||||
go test -tags kqueue,dev ./cmd -run '^(TestGetRequestAuthType|TestS3SupportedAuthType|TestIsReqAuthenticated|TestCheckAdminRequestAuthType|TestValidateAdminSignature|TestDoesPolicySignatureMatch|TestDoesPresignedSignatureMatch|TestDoesPresignedV2SignatureMatch|TestSTSLDAPLogin.*|TestGetSTSLDAPLoginSourceIP.*)$' -count=1 -timeout=10m
|
||||
```
|
||||
|
||||
For HTTP reproducers, start a disposable loopback server with synthetic
|
||||
credentials and a fresh data directory:
|
||||
|
||||
```sh
|
||||
MINIO_ROOT_USER=scanneradmin MINIO_ROOT_PASSWORD=scanner-local-test-only \
|
||||
silo server /tmp/silo-scanner-data \
|
||||
--address 127.0.0.1:9000 --console-address 127.0.0.1:9001
|
||||
```
|
||||
|
||||
The broader suites are `go test -tags kqueue,dev ./internal/... -timeout=15m`
|
||||
and `go test -tags kqueue,dev ./cmd -timeout=30m`. Some integration suites need
|
||||
LDAP, OIDC, etcd, KMS, multiple servers, or IPv6; those services are not started
|
||||
by this Dockerfile. Do not treat a missing external service as a security
|
||||
finding. The focused commands above do not require those providers.
|
||||
|
||||
## Reports and severity
|
||||
|
||||
Include the commit, exact attacker privileges/policy, relevant deployment
|
||||
settings, API requests or a standalone test, observed impact, and affected
|
||||
versions where established. For data-loss or cross-pool reports, include the
|
||||
object/version state and a deterministic sequence. Deduplicate by root cause
|
||||
and distinguish a new flaw from an already documented limitation or fixed bug.
|
||||
|
||||
Unauthenticated code execution or broad credential/authorization compromise
|
||||
can be critical. Cross-user object access, unauthorized destructive operations,
|
||||
and retention bypass can be high or critical according to their demonstrated
|
||||
reach. Rate availability issues by practical cost, scope and recovery; a
|
||||
single malformed request and an authorized full-disk upload are different
|
||||
threats. Do not claim exploitability solely from a static pattern.
|
||||
|
||||
Prefer minimal patches and regression tests that preserve S3/storage format
|
||||
compatibility. Use only synthetic data and local test instances; do not test
|
||||
against public Silo/MinIO deployments or real backup repositories.
|
||||
|
||||
## Maintaining the scanner image
|
||||
|
||||
Build from the repository root, then run the checks above without networking:
|
||||
|
||||
```sh
|
||||
docker build -f .oss-scanner/Dockerfile -t silo-scanner:local .
|
||||
docker run --rm -it --network=none silo-scanner:local bash
|
||||
```
|
||||
|
||||
Keep the Go image version aligned with `go.mod`. The adjacent
|
||||
`Dockerfile.dockerignore` preserves compressed test fixtures that the release
|
||||
image's root `.dockerignore` excludes. Local build outputs, editor state, and
|
||||
private working directories are excluded from the scanner context.
|
||||
|
||||
Enrollment lives in `anthropics/oss-scanner`, under `projects/silo/project.yaml`.
|
||||
Once these files are published on `main`, that configuration can use
|
||||
`dockerfile: .oss-scanner/Dockerfile` and
|
||||
`threat_model: .oss-scanner/threat_model.md`; remove its adjacent copies in the
|
||||
same update. Do not switch the enrollment paths before the files are public.
|
||||
+165
-209
@@ -2,227 +2,183 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
Preparation target: `RELEASE.2026-09-16T00-00-00Z` (package version
|
||||
`20260916000000.0.0`). The entries below describe the candidate changes since
|
||||
the latest published Server.
|
||||
**The latest published Server remains 20260903.** These changes are not in its
|
||||
binaries, packages or images. See the [component matrix](https://silo.pgsty.com/compatibility/versions/)
|
||||
and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-09-03T13-18-01Z...main).
|
||||
- Embed Console 2.7.0 source `26ce922424f1`, including its ten-language UI,
|
||||
rebuilt frontend assets, Swagger array-validation fixes and Redoc routing.
|
||||
Select the published mcli 20261109 source `e1d4cc5abfe0` and silo-pkg
|
||||
v3.16.0 directly in the maintained dependency graph.
|
||||
- Adopt go-jose/v4 v4.1.9, cpuid/v2 v2.4.2, YAML v3.0.6 and gjson v1.20.1,
|
||||
plus Console's runtime/server-middleware v0.33.4. Preserve Go 1.27.3, the
|
||||
verified upstream minio-go commit, networking/crypto security floors and
|
||||
terminal-width/NetBSD compatibility selections.
|
||||
- Update NATS credentials to jwt/v2 v2.8.3 and nkeys v0.4.17, including
|
||||
rejection of identity-point and small-order public keys. Use nats-server
|
||||
v2.15.2 for notification integration tests.
|
||||
- Update Google Cloud auth to v0.24.3 and oauth2adapt to v0.3.1 for REST
|
||||
request validation and AWS FIPS endpoint signing fixes.
|
||||
- Bundle verified mcli 20261109 archives in source container builds and use
|
||||
that release by default in the integration-test client installer.
|
||||
- Prevent replication worker metrics from panicking when the replication
|
||||
pool is absent, while retaining historical averages and peak counts.
|
||||
|
||||
### Authorization and security
|
||||
## RELEASE.2026-10-26T00-00-00Z
|
||||
|
||||
- Synchronize CPU metrics reads with resource-metrics updates (#210), preventing
|
||||
concurrent map access from terminating the server during Prometheus scraping.
|
||||
Metric names, values and authentication requirements are unchanged.
|
||||
- Restrict embedded Console's anonymous sharing proxy to object-content GETs
|
||||
at the configured S3 origin, and reject every redirect. Internal metrics,
|
||||
system paths and non-download S3 operations cannot be reached through it.
|
||||
Normal public, presigned and versioned downloads remain available without a
|
||||
new setting; a full sharing-disable switch is not introduced. See
|
||||
[Console #56](https://github.com/pgsty/silo-console/pull/56) and the
|
||||
[design record](https://github.com/pgsty/silo-console/issues/52).
|
||||
Thanks to Jiri Pejchal (@jiri-pejchal) for the report.
|
||||
- Persist IAM deletion revisions and parent revocation boundaries so stale site
|
||||
events cannot restore deleted identities, policies or their older grants
|
||||
(#191, #192). Peer deletion notifications reload committed storage; deliberate
|
||||
recreation requires a newer revision, and credentials issued before the
|
||||
parent's revocation remain invalid.
|
||||
**Coordinated upgrade required:** upgrade every participating node and site.
|
||||
Mixed old/new nodes sharing an IAM backend and rolling downgrade are
|
||||
unsupported. Back up complete IAM storage and encryption material; an admin
|
||||
export of live records omits deletion history. Reissue credentials for
|
||||
recreated parents and explicitly reconcile pre-upgrade revocations whose
|
||||
history is already lost. Restoring an older backup can lose later revocations;
|
||||
keep affected sites isolated until reconciliation/rekeying is complete. See
|
||||
[the operator runbook](https://silo.pgsty.com/operations/replication/iam-upgrade/).
|
||||
- Enforce an absolute HTTP/1 request-header deadline through the connection
|
||||
wrapper (#196). Repeated small reads no longer extend that deadline, and
|
||||
`--read-header-timeout` / `MINIO_READ_HEADER_TIMEOUT` now reaches the HTTP
|
||||
server. HTTP/1 request bodies retain the rolling idle timeout; this does not
|
||||
impose a total upload/download duration. A shorter setting also constrains
|
||||
TLS handshake reads. The wrapper's strict header mode is not applied to HTTP/2.
|
||||
- Reject unsigned `x-amz-*` request headers that could turn a signed PUT into a
|
||||
copy of another object accessible to the signer (SN-2026-011). The latest
|
||||
public Server is affected; the fix is on main. See [the advisory ledger](https://silo.pgsty.com/about/security-advisories/).
|
||||
- Align signed request fields with policy conditions and enforce header-only
|
||||
presigned payload checksums. See [the signed-header review](https://silo.pgsty.com/blog/design/signed-header-coverage/).
|
||||
- **Breaking policy semantics:** separate self-service `admin:ChangeMyPassword`
|
||||
from `admin:CreateUser`. Built-in read-only policies follow the split. Preserve
|
||||
both denies if the previous combined restriction must survive upgrades or
|
||||
rollback. Saved policies are not rewritten. Deploy with the matching Console
|
||||
and pkg; see [the migration guide](docs/iam/password-permissions.md).
|
||||
Package version: `20261026000000.0.0`; published from source `c3ad836aed0d`.
|
||||
Artifacts are listed in the [GitHub release](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-10-26T00-00-00Z).
|
||||
The coordinated [English release notes](https://silo.pgsty.com/blog/release/silo-20261026/)
|
||||
and [Chinese release notes](https://silo.pgsty.com/zh/blog/release/silo-20261026/)
|
||||
cover all components, upgrade guidance, and validation scope.
|
||||
|
||||
### Object storage and replication
|
||||
- Fix LDAP login for long normalized DNs without hiding unreadable IAM
|
||||
revocation records. Only length errors for valid keys with an ASCII path
|
||||
component over 255 bytes are treated as absent; long-DN writes still fail.
|
||||
Thanks to [@910patin](https://github.com/910patin) for the original fix and
|
||||
reproduction in [#250](https://github.com/pgsty/silo/pull/250).
|
||||
- Build the server and standalone debugging tools with Go 1.27.3 and refresh
|
||||
Go dependencies, including silo-pkg v3.15.2, mcli 20261026
|
||||
and upstream minio-go `320af050c445` for multipart, read/seek,
|
||||
batch-delete and bucket-replication removal fixes. Retain output-format and NetBSD compatibility selections.
|
||||
- Embed Console v2.5.1 source `fff1618d9abb`, including its GUI update,
|
||||
diagnostic WebSocket closing handshake, complete health-report decoding,
|
||||
group-membership preservation, OIDC group permissions, TLS-proxy session
|
||||
cookies, credential exports and accessibility fixes.
|
||||
- Bundle mcli 20261026 in container builds, the verified client installer and
|
||||
Helm client jobs. Include client identity isolation, resync status and rate
|
||||
fixes, and bounded batch-status fallback. Scripts must handle the additive
|
||||
batch `pending` state and nonzero exits for partial resync mutations.
|
||||
- Read current bucket inventory in account-info responses so Console refreshes
|
||||
immediately reflect successful creation and deletion. This management endpoint
|
||||
now performs a live list instead of returning a cached inventory.
|
||||
- Restore the native built-in policy immediately after resetting an override.
|
||||
Peer and etcd notifications reload committed state, so delayed delete events
|
||||
preserve subsequent edits. Keep deletion revisions, policy-in-use checks and
|
||||
custom-policy deletion behavior; storage failures retain the previous policy.
|
||||
- Reload policies without holding the authentication-cache lock, retain IAM
|
||||
write ordering and publish replacements only after a complete successful load.
|
||||
Propagate cancellation through IAM/KMS and SSE-S3 decryption without shortening
|
||||
the caller's policy-query deadline.
|
||||
- Preserve authoritative copies when a multi-pool delete or bulk cleanup fails,
|
||||
choose an available pool for a new delete marker, and keep tier data referenced
|
||||
by surviving copies. This does not introduce strict cross-pool read recovery.
|
||||
- Preserve remote tier data when lifecycle expiry only adds a delete marker,
|
||||
including non-null versions in suspended buckets. Forward `FULL_OBJECT`
|
||||
checksum type for multipart replicas. Thanks to
|
||||
[@botdeniska](https://github.com/botdeniska) for these lifecycle, replication
|
||||
and multi-pool deletion contributions.
|
||||
- Preserve Kafka's default broker protocol 2.1 and terminal-width behavior;
|
||||
fix legacy DARE v1 key unsealing and verify DARE v1/v2 and MinKMS TLS compatibility.
|
||||
- Forward storage RPC delete options, including the previous data directory
|
||||
needed by overwrite rollback. Preserve existing drive/RPC timeouts, request
|
||||
cancellation, object locking, read-quorum and healing behavior.
|
||||
- Update bundled curl to 8.23.0 and require patched static libraries. Apply
|
||||
[Red Hat's compromised-CA mitigation](https://access.redhat.com/security/vulnerabilities/RHSB-2026-012);
|
||||
the root blocklist also rejects legitimate chains under the affected roots.
|
||||
- Ship Windows platform archives as ZIP; Linux and macOS retain tar.gz.
|
||||
|
||||
- Make `ListMultipartUploads` discover quorum-valid uploads from durable state
|
||||
across pools, erasure sets and drives, then apply S3 prefix, delimiter,
|
||||
marker, ordering and 1,000-entry pagination semantics globally (#198). New uploads
|
||||
store their canonical bucket and key as reserved fields in the existing
|
||||
quorum-written `xl.meta`; completion removes those upload-only fields. Native
|
||||
markers remain usable after their upload is completed or canceled. Strict
|
||||
listing returns a diagnostic 503 for legacy uploads or uncertain coverage;
|
||||
the default remains the released exact-key/cache-based `legacy` behavior.
|
||||
Opt into strict mode only through `MINIO_API_MULTIPART_LISTING=strict`, after
|
||||
upgrading every writer, draining old uploads, checking the read-only admin
|
||||
`multipart-preflight` report and validating scan capacity. The process-only
|
||||
setting is not persisted into shared API configuration. Historical
|
||||
`multipart_listing` keys are ignored and can be removed with a targeted
|
||||
`mcli admin config reset ALIAS api multipart_listing` before rollback. Per-process
|
||||
admission, directory-entry, worker and time budgets bound scan scheduling;
|
||||
each page still scans durable state. See [issue #79](https://github.com/pgsty/silo/issues/79)
|
||||
and its [design record](https://silo.pgsty.com/blog/design/list-multipart-uploads/).
|
||||
Thanks to mr javad seydi (@mrjavadseydi) for the original implementation.
|
||||
- Retain released read-quorum and best-effort multipart cancellation in default
|
||||
legacy mode. Strict mode requires majority deletion acknowledgements and
|
||||
permits retries below read quorum. When most drives were already empty,
|
||||
failed deletion of an observed remnant now returns 503 instead of being
|
||||
masked by empty-drive successes. Wrong-key or wrong-bucket cancellation
|
||||
preserves the valid upload's cache entry; successful cancellation notifies
|
||||
peers with the request context after the distributed lock is released.
|
||||
The HTTP response for an absent
|
||||
upload remains 204; this is not proof of physical cleanup. **Known boundary:**
|
||||
delayed creation writes can still restore an upload after cancellation;
|
||||
this change does not add a durable creation fence.
|
||||
- Preserve object tags during multi-pool metadata reconciliation by reading the
|
||||
resolved tag field together with its revision (#189). Previously, reconciliation
|
||||
could replace existing tags with an empty value.
|
||||
- Preserve the tag revision on SSE-KMS metadata replication (#193), and advance
|
||||
tag revisions monotonically on local PUT/DELETE tagging (#196). Empty tags
|
||||
participate in reconciliation as an ordered deletion, preventing older
|
||||
events from restoring removed tags. SSE-C key rotation also retains the tag
|
||||
revision. Malformed historical revisions can fail and retry; their missing
|
||||
history is not reconstructed by the upgrade.
|
||||
- Complete delete-marker version purges and preserve their identity and retry
|
||||
state through MRF recovery (#196). Recovery accepts a 405 marker response only
|
||||
when its version, bucket, object name and modification time match the task.
|
||||
Purge audit status is normalized from `COMPLETE` to `COMPLETED`.
|
||||
Thanks to Julien Laurenceau (@julienlau) for the investigation and proposed
|
||||
fix in #184 that helped shape this follow-up.
|
||||
- Restore only the six replication-specific metadata fields after ordinary
|
||||
request metadata extraction (#194). This prevents transport-only `aws-chunked`
|
||||
from being stored as Content-Encoding while preserving the signed-header
|
||||
protections. Trusted Snowball entries no longer inherit the outer archive's
|
||||
ordinary metadata. Thanks to Mikhail Khadarenka (@chodorenko) for the fix in #187.
|
||||
**Existing data:** these repairs prevent new errors; they do not scan or rewrite
|
||||
historical object metadata, recover lost tags or prove that old purge work has
|
||||
converged. Follow the [read-only audit procedure](https://silo.pgsty.com/operations/replication/replica-metadata-audit/)
|
||||
before planning any repair of stored state.
|
||||
Storage timeouts retain their existing meaning: a returned timeout does not
|
||||
prove that the underlying disk operation stopped or that a write did not
|
||||
commit. Late commits or delayed rollback can affect newer writes; stale reads
|
||||
remain possible after partial failures, lease loss or network partitions. This
|
||||
release does not provide distributed fencing or repair previously inconsistent
|
||||
objects.
|
||||
|
||||
- Make exact-version delete-marker purges converge in replicated buckets
|
||||
(`eb4f5e5b3`, `254b19ac0`, `358ab38fb`). A purge no longer creates the
|
||||
marker on drives that lacked it; a retried purge of a missing version is
|
||||
acknowledged only when a write-quorum majority of drives report it absent;
|
||||
purge results merge removed and reliably absent replies; healing a marker
|
||||
preserves its stored replication and purge metadata; and a queued marker
|
||||
creation is re-checked against the source under the replication lock before
|
||||
it is sent, so a purge that already reached the targets is not undone by a
|
||||
stale task from another frontend, a GET/LIST heal, the scanner or MRF.
|
||||
Purging a data version whose earlier purge is still pending reports it as a
|
||||
data version. **Known limitations:** creations already in flight or replayed
|
||||
from another site, and minority marker copies left by a crash after a
|
||||
majority-acknowledged purge, are tracked in #217. See
|
||||
[the replication reliability record](https://silo.pgsty.com/blog/design/replication-reliability/).
|
||||
- Keep a null object version that has listing quorum when a newer minority of
|
||||
drives sorts first (`8d06424b1`). The resolver recounts per header only when
|
||||
the original selection lacks quorum, every non-empty drive stream holds
|
||||
exactly one ordinary null version and all share the same erasure layout;
|
||||
mixed histories keep their previous behavior. **Known limitation:** a
|
||||
successful ListObjects can still omit readable keys during rolling restarts
|
||||
with concurrent overwrites (#218). Do not run destination-deleting sync tools
|
||||
against a listing taken during a rolling restart; list again once the
|
||||
cluster is stable.
|
||||
- Carry object tags through rebalance and decommission for ordinary and
|
||||
multipart writes (`fced86303`). Both migration entry points restore the tags
|
||||
and their revision fields when rewriting the object in the destination pool.
|
||||
Tags dropped by earlier migrations are not recovered; audit tag-dependent
|
||||
lifecycle and policy rules for pools migrated with an older build.
|
||||
These source changes are not included in the published 20261018 artifacts.
|
||||
|
||||
- Evaluate conditional multipart completion against the logical current object
|
||||
across all pools while holding the existing object lock. A stale `If-Match`
|
||||
can no longer replace newer data in another pool, and the current ETag is no
|
||||
longer rejected because the upload resides next to an older copy. Conditions
|
||||
are evaluated once; a current delete marker counts as an absent object.
|
||||
**Availability change:** if any pool's metadata cannot be read, conditional
|
||||
completion fails even when another pool can still serve GET/HEAD. This also
|
||||
applies when the unreadable pool may not hold the object: absence cannot be
|
||||
verified. Retry after the pool recovers. Unconditional completion and the
|
||||
single-pool path retain their existing behavior.
|
||||
## RELEASE.2026-10-18T00-00-00Z
|
||||
|
||||
- Evaluate ordinary multi-pool conditional PUT against the logical current
|
||||
object across all pools, including draining pools, under the existing object
|
||||
lock (#207). A stale destination copy no longer accepts a stale ETag or rejects
|
||||
the current one; a current delete marker is treated as absence.
|
||||
**Availability change:** if any pool's object metadata cannot be verified,
|
||||
the condition fails even when GET can use another pool; read-quorum failures
|
||||
return 503. Restore readability or heal before retrying. Unconditional PUT,
|
||||
single-pool conditions and internal replication retain their existing behavior.
|
||||
A public condition with a destination `versionId` compares the current object
|
||||
while preserving the requested write version. This change does not retire
|
||||
stale copies in other pools, undo historical accepted overwrites or provide
|
||||
a new global clock-ordering guarantee. The multipart-completion repair in #190
|
||||
neither introduced nor repaired this separate PUT defect.
|
||||
Package version: `20261018000000.0.0`; published on 2026-10-18 from source
|
||||
`0cd388c666f8211f552604ae73448a066e9c25ce`. This section covers the changes
|
||||
after the public 20260916 source, `2a4d51406b7ed87af5fe6fe0f801f3290f96eb3c`.
|
||||
|
||||
- Reconcile ordinary single-object version DELETE across all pools, including
|
||||
null versions, delete markers and unqualified directory-marker DELETE. This
|
||||
applies the deletion to every resolved pool copy under existing quorum
|
||||
rules. Pending outbound delete replication retains versions until the
|
||||
existing replication worker completes their purge; a successful response
|
||||
does not imply immediate physical removal from every drive. Unreadable
|
||||
pools now consistently return 503 instead of depending on pool traversal
|
||||
order; insufficient read quorum returns `SlowDownRead`. This extends the
|
||||
existing failure surface. Retry after recovery.
|
||||
Cleanup failures also return an error. Batch deletion already fans out across
|
||||
pools; replication and scanner cleanup keep their existing contracts. See
|
||||
[scope and limitations](docs/bucket/lifecycle/access-tiering-removal.md#version-deletion-scope).
|
||||
Full [English release notes](https://silo.pgsty.com/blog/release/silo-20261018/)
|
||||
and [Chinese release notes](https://silo.pgsty.com/zh/blog/release/silo-20261018/)
|
||||
cover the upgrade contract, evidence and remaining limits.
|
||||
|
||||
- Remove the opt-in GET-frequency pool-tiering feature from PR #60, including
|
||||
its tracker, mover, scanner hooks, configuration, XML actions and metrics.
|
||||
Accept and ignore retired configuration/XML and preserve ordinary statistics
|
||||
when reading v9 caches. See [migration notes](docs/bucket/lifecycle/access-tiering-removal.md).
|
||||
The [decision record](https://silo.pgsty.com/compatibility/access-tiering-removal/) preserves
|
||||
the feature's introduction, subsequent fixes, rollback scope and review history.
|
||||
- Preserve the independent multi-pool write, metadata, healing and conditional
|
||||
deletion fixes from PR #178, including shared remote-tier reference protection.
|
||||
- Enforce `If-Match` on DELETE, preserve retention and independently ordered
|
||||
Object Lock/tag updates, and correctly retransmit encrypted replicas.
|
||||
- Preserve plaintext part sizes and raw SSE-C replicas; prevent SSE-C
|
||||
compression, honor key-rotation checksums, and complete attributes pagination.
|
||||
- Repair federated CopyObject checksums, destination timestamps, reserved
|
||||
metadata, encrypted-object forwarding, legal hold and KMS context.
|
||||
- Make resync counters, target selection, cancellation and worker lifetimes
|
||||
reflect actual work, and report bounded MRF drops.
|
||||
- Converge bucket metadata with deterministic source state, deletion tombstones,
|
||||
creation time recovery and diagnostics. The mixed-version export gate requires
|
||||
coordinated upgrades before tombstones are exported. See [the #77 record](https://silo.pgsty.com/blog/design/bucket-metadata-convergence/).
|
||||
- Include per-bucket CORS in metadata export/import, close metadata publication
|
||||
and logger races, and report effective bucket quotas in metrics.
|
||||
- **Object Lock (#234, #235):** require `s3:PutObjectRetention` with request-body
|
||||
conditions before object lookup; weakening active GOVERNANCE additionally
|
||||
requires bypass intent and permission. Protect caller-addressed write versions,
|
||||
directory null versions and persisted multipart targets under the write lock;
|
||||
fail closed on unreadable lock state. See SN-2026-015/016 in the
|
||||
[advisory ledger](https://silo.pgsty.com/about/security-advisories/).
|
||||
- **IAM Admin authorization (#242, `995439260`):** enforce explicit self denies,
|
||||
session restrictions and plugin decisions; prevent restricted delegation and
|
||||
non-root mutation of root-derived credentials under built-in authorization.
|
||||
Preserve empty-policy inheritance and damaged-key cleanup. Normalize bulk
|
||||
self-list aliases and allow ordinary policyless self inspection. See
|
||||
[SN-2026-017](https://silo.pgsty.com/blog/security/20261018-release-hardening/#sn-2026-017).
|
||||
- **Scoped STS revocation (`0cd388c66`):** enumerate durable parent/type-selected
|
||||
tokens, propagate each committed tombstone to sibling nodes and sites, and
|
||||
recover missed notifications through existing refresh/healing. Tokens issued
|
||||
only at an isolated site are outside the requesting cluster's snapshot;
|
||||
propagation is eventual and newly issued keys remain valid.
|
||||
- **Pool migration (#248, `60c7179dd`, `1748f62c7`):** prevent stale moved
|
||||
payloads from overwriting newer copies, preserve null/UUID delete markers,
|
||||
recheck source versions and clean only confirmed moved versions under the
|
||||
object lock. Added per-pool reads and per-version cleanup have maintenance
|
||||
costs; earlier data loss is not reconstructed.
|
||||
|
||||
### Console, dependencies and delivery
|
||||
- **LIST recovery (#239, `3dd53eb81`, `2080b3d03`):** confirm eligible uncertain
|
||||
latest-object groups under the appropriate read locks, distinguish absence
|
||||
from failure, preserve directory/common-prefix entries and terminal pagination
|
||||
errors, and make lock backoff cancelable. Resolved entries keep their fast path;
|
||||
version history, quorum and disk formats retain their contracts. The strict
|
||||
sub-5% performance target remains unconfirmed in the recorded comparison.
|
||||
- **Multipart listing (`4733e1423`):** withdraw #243's default persistent scan.
|
||||
Public listing uses node-local cache or exact-key lookup; restart omissions,
|
||||
prefix and cache-pagination limits remain. The retired strict switch is ignored
|
||||
with a startup warning. **Former strict users lose durable listing and strict
|
||||
cancellation.** Keep identity metadata, path/wrong-target safety, default
|
||||
best-effort Abort and peer notification. Diagnostic `multipart-preflight`
|
||||
reports `mode=legacy`; its budgets do not gate public operations. #79 remains
|
||||
open, including delayed creation after cancellation.
|
||||
- **Multipart cleanup (#236, `0f9199e0c`):** parse native timestamps before the
|
||||
Base64 fallback and remove empty hash parents without a second expiry wait.
|
||||
Keep active/unreadable uploads; default expiry/check intervals stay 24h/6h.
|
||||
- **Commit errors (#232):** missing staged `xl.meta` at final commit becomes
|
||||
HTTP 503 `SlowDownWrite`; preserve this classification across storage RPC and
|
||||
healing. Real incomplete bodies retain their existing errors.
|
||||
- **Reused metadata (#233):** clear absent legacy `EcM`/`Signature` and locally
|
||||
reset mutually exclusive scanner response fields without changing disk/RPC formats.
|
||||
- **Checksum and Console errors (#225, #229, #238):** align Content-MD5 and
|
||||
algorithm-specific messages, retaining the generic unknown-algorithm message;
|
||||
embed Console's wrapped-error and `InvalidBucketState` handling.
|
||||
- **Lambda and erasure documentation (#220, `2fde3cf53`):** fix inverted
|
||||
`TargetIDSet.IsEmpty`; document parity/quorum per object and erasure set.
|
||||
- **Coordinated dependencies:** Console v2.4.2 (`ef2dd2c41d21`), mcli 20261018
|
||||
(`fa3b8bf72458`), silo-pkg v3.14.3, upstream minio-go v7.4.1 and Go 1.27.2.
|
||||
Standalone Console/mcli still declare pkg v3.14.2. The Server package update
|
||||
speeds admin-action classification without changing policy behavior. Refresh
|
||||
Go x/*, go-jose v4.1.6, LDAP v3.4.15, compress v1.20.3, msgp v1.6.5, etcd v3.7.2,
|
||||
fwd v1.2.1 and cpuid v2.4.1. OIDC RSA JWKs above 4096 bits are rejected.
|
||||
Keep the NetBSD go-systemd v22.6.0 replacement; parallel SDK GET/Compose remain opt-in.
|
||||
- **Build and delivery:** gate releases on Object Lock regressions; use
|
||||
Git for rebrand scanning without a ripgrep dependency (`7007c164a`). Refresh UBI 9 pins,
|
||||
GoReleaser v2.18.3, nFPM v2.48.0, golangci-lint v2.15.0 and govulncheck v1.9.0.
|
||||
Update bundled client hashes/helper and the Helm client image to mcli 20261018.
|
||||
The chart's Server default stays at the public 20260916 image until publication.
|
||||
|
||||
- Restore embedded Console login over loopback TLS, trusted-proxy handling and
|
||||
all four WebSocket connection limits. Preserve Go TLS defaults across transports.
|
||||
- Directly require `github.com/pgsty/silo-pkg/v3` v3.14.1; select released Console
|
||||
v2.4.1 (`v0.0.0-20260916075814-1360e26d976d`) and mcli 20260916
|
||||
(`v0.0.0-20260916070421-e952aa78f10a`) with explicit PGSTY replacements.
|
||||
The embedded frontend identifies itself as Console v2.4.1.
|
||||
- Pin upstream minio-go `v7.3.1-0.20260915093545-32e1f32cb176` to handle
|
||||
CopyObject errors embedded in HTTP 200 responses. Update JWX to v3.3.0 for
|
||||
JSON field-name escaping, strfmt to v0.27.2 for Go 1.27 hostname validation,
|
||||
and LZ4 to v4.1.30 for frame-reader, partial-read and concurrency fixes.
|
||||
Retain the earlier Go x/* and bounded AMQP frame updates, Go 1.27.1, and
|
||||
go-systemd v22.6.0's NetBSD compatibility replacement.
|
||||
- Refresh container base digests and build static curl 8.22.0 from verified
|
||||
source for both Linux architectures. Pin the published mcli 20260916 archives
|
||||
and hashes in the container and update the client installer default.
|
||||
- Prepare Helm chart 7.0.3 with Server and client defaults for the September 16
|
||||
batch. Publish the chart only after the corresponding Server image exists.
|
||||
- Pin GoReleaser v2.18.1 and its action commit identically in snapshot and release
|
||||
workflows. Dependency-only PRs now run the Test Release Pipeline too.
|
||||
Upgrade every writer and replication site; follow the
|
||||
[IAM recovery guide](https://silo.pgsty.com/operations/replication/iam-upgrade/).
|
||||
Old strict multipart users must review the capability loss before upgrading.
|
||||
#217's multi-site delete-marker late creation and crash residue remain unresolved.
|
||||
Source tests, draft artifacts, public images and production rollout are separate
|
||||
acceptance boundaries; historical 20261015 test counts do not certify this source.
|
||||
|
||||
Validation of earlier source revisions does not establish acceptance of this
|
||||
candidate. Final source, package, image and multi-process checks are tracked
|
||||
separately in [#203](https://github.com/pgsty/silo/issues/203). No Server release
|
||||
or production rollout is implied by this preparation target.
|
||||
## RELEASE.2026-09-16T00-00-00Z
|
||||
|
||||
Package version: `20260916000000.0.0`; published source:
|
||||
`2a4d51406b7ed87af5fe6fe0f801f3290f96eb3c`.
|
||||
|
||||
This release includes signed-header and payload-policy hardening (SN-2026-011/012),
|
||||
durable IAM revocations (SN-2026-013), the bounded Console sharing proxy
|
||||
(SN-2026-014), password-permission separation, multi-pool conditions/deletion/tag
|
||||
repairs, encrypted/federated copy repairs, delete-marker retry fixes, opt-in strict
|
||||
multipart discovery, Go-default TLS key exchange, absolute HTTP/1 header deadlines
|
||||
and synchronized CPU metrics. It embeds Console v2.4.1, mcli 20260916 and pkg v3.14.1.
|
||||
|
||||
The complete release, contributor credits, historical artifact acceptance and
|
||||
upgrade requirements are preserved in the canonical
|
||||
[English](https://silo.pgsty.com/blog/release/silo-20260916/) and
|
||||
[Chinese](https://silo.pgsty.com/zh/blog/release/silo-20260916/) release records.
|
||||
|
||||
## RELEASE.2026-09-03T13-18-01Z
|
||||
|
||||
|
||||
@@ -53,6 +53,20 @@ Before opening a pull request:
|
||||
- Explain any preserved `MINIO_*`, `minio_*`, `x-minio-*`, `/minio/*`,
|
||||
`.minio.sys`, ARN, module/import-path, or serialized compatibility name.
|
||||
|
||||
The maintenance branch retains existing storage timeouts, cancellation,
|
||||
object locking, read-quorum and healing behavior. The normal suite checks local
|
||||
and RPC timeout/cancellation, remote rollback options, and readability after a
|
||||
failed partial overwrite. A timeout does not prove that a disk worker stopped
|
||||
or that a write did not commit.
|
||||
|
||||
Stronger mutation lifetime, rollback ownership, generation ordering, strict-read
|
||||
and cross-pool recovery diagnostics are preserved behind `consistency_strict`.
|
||||
Compile them with `go test -tags kqueue,dev,consistency_strict ./cmd -run '^$'`
|
||||
and run individual scenarios explicitly. They describe guarantees outside the
|
||||
maintenance contract and may fail; report their results separately from the
|
||||
normal suite. They must not be used to claim distributed fencing or repair of
|
||||
previously inconsistent objects.
|
||||
|
||||
### Commit changes
|
||||
|
||||
After verification, commit your changes with a concise message and a DCO
|
||||
|
||||
+72
-38
File diff suppressed because one or more lines are too long
+24
-2
@@ -8,10 +8,30 @@
|
||||
# seeds fresh volumes from the image-layer mountpoint, no entrypoint
|
||||
# exists to repair ownership at runtime, and 0777 is what keeps every
|
||||
# privilege mode working, --user included.
|
||||
FROM busybox:1.37.0 AS prep
|
||||
FROM busybox:1.37.0@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec AS prep
|
||||
RUN mkdir -p /prep/data && chmod 0777 /prep/data
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:latest
|
||||
# Debian's bundle in distroless still trusts the roots the classic image
|
||||
# blocklists. Build the same bundle as Dockerfile.goreleaser so both variants
|
||||
# trust the same roots; it is architecture-neutral, so build it natively.
|
||||
FROM --platform=$BUILDPLATFORM registry.access.redhat.com/ubi9/ubi:latest@sha256:16e9e43d99e181463513cc84a514859b1abb9460f1c4d9e208da4b4e51cd5cc3 AS certs
|
||||
# Native root blocklist mitigation: https://access.redhat.com/security/vulnerabilities/RHSB-2026-012
|
||||
RUN dnf -y install ca-certificates && \
|
||||
if [ -d /etc/pki/ca-trust/source/blocklist ]; then \
|
||||
blocklist=/etc/pki/ca-trust/source/blocklist; \
|
||||
else \
|
||||
blocklist=/etc/pki/ca-trust/source/blacklist; \
|
||||
fi && \
|
||||
trust extract --overwrite --format=pem-bundle \
|
||||
--filter='pkcs11:object=Comodo%20AAA%20Services%20root;type=cert' \
|
||||
"$blocklist/Comodo_AAA_Services_root.pem" && \
|
||||
trust extract --overwrite --format=pem-bundle \
|
||||
--filter='pkcs11:object=emSign%20ECC%20Root%20CA%20-%20G3;type=cert' \
|
||||
"$blocklist/emSign_ECC_Root_CA_-_G3.pem" && \
|
||||
update-ca-trust && \
|
||||
cp /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem /tmp/ca-certificates.crt
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:latest@sha256:d75cdd72874d4790092fcb1b058493ecf6bb5bf2b2b897045b00ff01d91843f2
|
||||
|
||||
LABEL org.opencontainers.image.title="Silo" \
|
||||
org.opencontainers.image.description="S3-Interface Libre Object Storage (distroless)" \
|
||||
@@ -28,6 +48,8 @@ ENV MINIO_ACCESS_KEY_FILE=access_key \
|
||||
MINIO_CONFIG_ENV_FILE=config.env \
|
||||
HOME=/tmp
|
||||
|
||||
# The only file under /etc/ssl/certs, so replacing it is sufficient.
|
||||
COPY --from=certs /tmp/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --chmod=0755 silo /usr/bin/silo
|
||||
# COPY of a directory copies its contents, not the directory entry, so an
|
||||
# empty /prep/data would arrive as a default root:0755 /data and non-root
|
||||
|
||||
+24
-8
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.27.1-alpine@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS curl-build
|
||||
FROM golang:1.27.3-alpine@sha256:d530cf60723abf1ffae0cd16bb546b379ec0550d2f3815e5b16ed69dbc00fba4 AS curl-build
|
||||
ARG TARGETARCH
|
||||
COPY dockerscripts/build-static-curl.sh /build/build-static-curl
|
||||
RUN /bin/sh /build/build-static-curl
|
||||
@@ -9,7 +9,7 @@ COPY --from=curl-build /go/bin/curl /curl
|
||||
COPY --from=curl-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
ENTRYPOINT ["/curl"]
|
||||
|
||||
FROM golang:1.27.1-alpine@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS build
|
||||
FROM golang:1.27.3-alpine@sha256:d530cf60723abf1ffae0cd16bb546b379ec0550d2f3815e5b16ed69dbc00fba4 AS build
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -17,9 +17,9 @@ ENV GOPATH=/go
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
ARG MC_REPO=pgsty/mc
|
||||
ARG MC_VERSION=RELEASE.2026-09-16T00-00-00Z
|
||||
ARG MC_AMD64_SHA256=4ba2814fd5507fbe6b4d237c359750b9119d28d7217495fa5b48002fcbd397ef
|
||||
ARG MC_ARM64_SHA256=b7008ca2a1bc5735b6585981c59a3640a0daa152dc789df3d1a0d0438787de82
|
||||
ARG MC_VERSION=RELEASE.2026-11-09T00-00-00Z
|
||||
ARG MC_AMD64_SHA256=79e6cf4ad00a9e5eb2e7ceccc6e6496e4af4ec096e59695515ea8f223c287e4a
|
||||
ARG MC_ARM64_SHA256=5473c4b39a31dd66478f5c44686a09f3e9e37861a30f1faf18564fbe66118387
|
||||
|
||||
RUN apk add -U --no-cache \
|
||||
ca-certificates \
|
||||
@@ -65,16 +65,30 @@ RUN apk add -U --no-cache \
|
||||
echo "No mc or mcli binary found in archive:"; ls -la /tmp/mcli-extract/; exit 1; \
|
||||
fi && \
|
||||
chmod +x /go/bin/mcli && \
|
||||
ln -sf mcli /go/bin/mc
|
||||
ln -sf mcli /go/bin/mc && \
|
||||
mkdir -p /go/share/mcli && \
|
||||
cp /tmp/mcli-extract/LICENSE /tmp/mcli-extract/NOTICE /tmp/mcli-extract/CREDITS /go/share/mcli/
|
||||
|
||||
FROM registry.access.redhat.com/ubi9/ubi:latest@sha256:206b65b8ee0f04b992818c9a51b29081b14974630d4850bc358097d0c44ea156 AS certs
|
||||
FROM registry.access.redhat.com/ubi9/ubi:latest@sha256:16e9e43d99e181463513cc84a514859b1abb9460f1c4d9e208da4b4e51cd5cc3 AS certs
|
||||
# Native root blocklist mitigation: https://access.redhat.com/security/vulnerabilities/RHSB-2026-012
|
||||
RUN dnf -y install ca-certificates && \
|
||||
if [ -d /etc/pki/ca-trust/source/blocklist ]; then \
|
||||
blocklist=/etc/pki/ca-trust/source/blocklist; \
|
||||
else \
|
||||
blocklist=/etc/pki/ca-trust/source/blacklist; \
|
||||
fi && \
|
||||
trust extract --overwrite --format=pem-bundle \
|
||||
--filter='pkcs11:object=Comodo%20AAA%20Services%20root;type=cert' \
|
||||
"$blocklist/Comodo_AAA_Services_root.pem" && \
|
||||
trust extract --overwrite --format=pem-bundle \
|
||||
--filter='pkcs11:object=emSign%20ECC%20Root%20CA%20-%20G3;type=cert' \
|
||||
"$blocklist/emSign_ECC_Root_CA_-_G3.pem" && \
|
||||
update-ca-trust && \
|
||||
cp /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem /tmp/ca-certificates.crt && \
|
||||
dnf clean all && \
|
||||
rm -rf /var/cache/dnf
|
||||
|
||||
FROM registry.access.redhat.com/ubi9/ubi-micro:latest@sha256:f332c99eb8f798a8486821c91937f10ad64ee83d7e739303be2df051040918f6
|
||||
FROM registry.access.redhat.com/ubi9/ubi-micro:latest@sha256:5be877a2a280ae36eb9ab80ca4efb28bbf77c57abbba38137cdc37a03b8465e6
|
||||
|
||||
LABEL org.opencontainers.image.title="Silo" \
|
||||
org.opencontainers.image.description="S3-Interface Libre Object Storage" \
|
||||
@@ -95,6 +109,8 @@ ENV MINIO_ACCESS_KEY_FILE=access_key \
|
||||
COPY --from=certs /tmp/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY silo /usr/bin/silo
|
||||
COPY --from=build /go/bin/mcli /usr/bin/mcli
|
||||
# mcli's own legal files, from the same verified archive as the binary.
|
||||
COPY --from=build /go/share/mcli /licenses/mcli
|
||||
COPY --from=curl-build /go/bin/curl /usr/bin/curl
|
||||
COPY --from=curl-build /go/share/curl /licenses/curl
|
||||
COPY dockerscripts/docker-entrypoint.sh /usr/bin/docker-entrypoint.sh
|
||||
|
||||
@@ -4,7 +4,7 @@ LDFLAGS := $(shell go run buildscripts/gen-ldflags.go)
|
||||
|
||||
GOOS ?= $(shell go env GOOS)
|
||||
GOARCH ?= $(shell go env GOARCH)
|
||||
GOLANGCI_VERSION ?= v2.13.1
|
||||
GOLANGCI_VERSION ?= v2.15.0
|
||||
|
||||
VERSION ?= $(shell git describe --tags)
|
||||
REPO ?= docker.io/pgsty
|
||||
@@ -73,6 +73,11 @@ lint-fix: getdeps ## runs golangci-lint suite of linters with automatic fixes
|
||||
@$(GOLANGCI) run --build-tags kqueue --timeout=10m --config ./.golangci.yml --fix
|
||||
|
||||
check: test
|
||||
# -tags kqueue,dev: kqueue selects the kqueue backend of rjeczalik/notify on
|
||||
# BSD and macOS (no-op on Linux); dev came with upstream's 2024-06 change for
|
||||
# development builds and no file in this repository or its dependencies
|
||||
# carries a dev build constraint, so it selects nothing. Kept for the
|
||||
# documented command lines; .github/workflows/go.yml carries the same note.
|
||||
test: verifiers build ## builds Silo, runs linters, tests
|
||||
@echo "Running unit tests"
|
||||
@MINIO_API_REQUESTS_MAX=10000 CGO_ENABLED=0 go test -v -tags kqueue,dev ./...
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<p align="center">
|
||||
<a href="https://silo.pgsty.com/"><img alt="Website" src="https://img.shields.io/badge/Website-silo.pgsty.com-1d588c"></a>
|
||||
<a href="https://github.com/pgsty/silo/releases"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/pgsty/silo?include_prereleases&label=release&logo=github"></a>
|
||||
<a href="https://hub.docker.com/r/pgsty/silo"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/pgsty/minio?logo=docker"></a>
|
||||
<a href="https://hub.docker.com/r/pgsty/silo"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/pgsty/silo?logo=docker"></a>
|
||||
<a href="go.mod"><img alt="Go Version" src="https://img.shields.io/github/go-mod/go-version/pgsty/silo?logo=go"></a>
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPLv3-blue"></a>
|
||||
</p>
|
||||
@@ -35,13 +35,13 @@
|
||||
> [!NOTE]
|
||||
> Renamed from `pgsty/minio` to `pgsty/silo`, default branch `master` → `main`, on 2026-08-06. Artifacts under the original MinIO identity stay published on the archived [`minio`](https://github.com/pgsty/silo/tree/minio) branch and in releases up to [`RELEASE.2026-08-04T00-00-00Z`](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-08-04T00-00-00Z).
|
||||
|
||||
## Current release and main branch
|
||||
## SILO 20261026000000
|
||||
|
||||
The latest published Server is [20260903](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-09-03T13-18-01Z).
|
||||
As of 2026-09-16, the main branch has newer security, storage, Console and
|
||||
shared-package changes that have not shipped in a Server release. See
|
||||
[CHANGELOG.md](CHANGELOG.md) and the [component version matrix](https://silo.pgsty.com/compatibility/versions/)
|
||||
for the exact release/source boundary, including SN-2026-011 and password-policy migration.
|
||||
[SILO 20261026000000](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-10-26T00-00-00Z) is available as platform archives, Linux packages, and standard or distroless Docker images for amd64 and arm64. The release tag is `RELEASE.2026-10-26T00-00-00Z`; the package version is `20261026000000.0.0`, built from source `c3ad836aed0d`.
|
||||
|
||||
This release fixes LDAP long-DN login, IAM policy refresh, multi-pool deletion safety, tiered-object expiry, multipart replication checksums, and remote rollback-option propagation. It embeds Console v2.5.1 (`fff1618d9abb`) and uses silo-pkg v3.15.2, Go 1.27.3, and upstream minio-go `v7.4.2-0.20261025165926-320af050c445`. Use mcli 20261026 for administration; the container images bundle it as `mcli`.
|
||||
|
||||
The Server IAM cache repair enables Console's **Reset to default** action. Existing storage timeouts and cancellation behavior remain: a timeout does not prove that a disk operation stopped or a write did not commit. Read the [coordinated release notes](https://silo.pgsty.com/blog/release/silo-20261026/) and [remaining storage limits](CHANGELOG.md#release2026-10-26t00-00-00z) before upgrading. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the people behind the code, reports, and documentation.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -62,7 +62,7 @@ docker run -d --name silo -p 9000:9000 -p 9001:9001 \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=change-me-long-password \
|
||||
-v "$PWD/data:/data" \
|
||||
docker.io/pgsty/silo:latest server /data --console-address ":9001"
|
||||
docker.io/pgsty/silo:RELEASE.2026-10-26T00-00-00Z server /data --console-address ":9001"
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
@@ -101,8 +101,8 @@ Every divergence from upstream is listed in the code-verified [compatibility aud
|
||||
|
||||
### TLS and Go upgrades
|
||||
|
||||
The following TLS repair is on main and is not included in Server 20260903.
|
||||
With that repair, TLS key exchange follows Go's defaults across the S3 listener, node links,
|
||||
Server 20260916 restored Go's default key exchange; Server 20261018 retains it with Go 1.27.2.
|
||||
TLS key exchange follows Go's defaults across the S3 listener, node links,
|
||||
replication, identity providers, etcd, and external HTTP services. If an endpoint
|
||||
cannot accept ML-KEM, `GODEBUG=tlsmlkem=0` disables the default hybrid exchanges
|
||||
for the process; certificate verification remains enabled. This option does not
|
||||
@@ -137,7 +137,7 @@ Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md); e
|
||||
|
||||
<!-- Generated by silo.pgsty.com/bin/contributors.py. -->
|
||||
|
||||
Every human issue or pull-request author is part of the SILO community, including open and unmerged work. Merged fixes, adopted proposals, and actionable reports receive priority, with first participation guiding the remaining order. Gold rings highlight reviewed significant contributions.
|
||||
Every human issue or pull-request author is part of the SILO community, including open and unmerged work. We also recognize security reports, including reports sent by email, with the reporter's permission. Merged fixes, adopted proposals, and actionable reports receive priority, with first participation guiding the remaining order. Gold rings highlight reviewed significant contributions.
|
||||
|
||||
<a href="CONTRIBUTORS.md">
|
||||
<picture>
|
||||
|
||||
+10
-9
@@ -24,7 +24,7 @@
|
||||
<p align="center">
|
||||
<a href="https://silo.pgsty.com/zh/"><img alt="官网" src="https://img.shields.io/badge/%E5%AE%98%E7%BD%91-silo.pgsty.com%2Fzh-1d588c"></a>
|
||||
<a href="https://github.com/pgsty/silo/releases"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/pgsty/silo?include_prereleases&label=release&logo=github"></a>
|
||||
<a href="https://hub.docker.com/r/pgsty/silo"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/pgsty/minio?logo=docker"></a>
|
||||
<a href="https://hub.docker.com/r/pgsty/silo"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/pgsty/silo?logo=docker"></a>
|
||||
<a href="go.mod"><img alt="Go Version" src="https://img.shields.io/github/go-mod/go-version/pgsty/silo?logo=go"></a>
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPLv3-blue"></a>
|
||||
</p>
|
||||
@@ -35,12 +35,13 @@
|
||||
> [!NOTE]
|
||||
> 2026-08-06,本仓库由 `pgsty/minio` 更名为 `pgsty/silo`,默认分支由 `master` 更名为 `main`。以原 MinIO 形态维持的归档构件仍位于归档的 [`minio`](https://github.com/pgsty/silo/tree/minio) 分支,以及截止 [`RELEASE.2026-08-04T00-00-00Z`](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-08-04T00-00-00Z) 的历次发布中。
|
||||
|
||||
## 当前发行版与主分支
|
||||
## SILO 20261026000000
|
||||
|
||||
最新已发布的 Server 仍为 [20260903](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-09-03T13-18-01Z)。
|
||||
截至 2026-09-16,主分支已合入更新的安全、存储、Console 与共享包改动,但尚未发布新 Server。
|
||||
准确的已发布/源码边界见 [CHANGELOG.md](CHANGELOG.md) 与[组件版本矩阵](https://silo.pgsty.com/zh/compatibility/versions/),
|
||||
其中包括 SN-2026-011 修复状态与密码权限迁移要求。
|
||||
[SILO 20261026000000](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-10-26T00-00-00Z) 已正式发布,提供各平台归档包、Linux 软件包,以及 amd64/arm64 标准版和 distroless Docker 镜像。版本标签为 `RELEASE.2026-10-26T00-00-00Z`,包版本为 `20261026000000.0.0`,构建源码为 `c3ad836aed0d`。
|
||||
|
||||
本版修复 LDAP 长 DN 登录、IAM 策略刷新、多池删除安全、分层对象过期、分片复制校验和及远端回滚参数透传。内置 Console v2.5.1(`fff1618d9abb`),使用 silo-pkg v3.15.2、Go 1.27.3 与上游 minio-go `v7.4.2-0.20261025165926-320af050c445`。配套管理客户端为 mcli 20261026,容器镜像内置该版本的 `mcli`。
|
||||
|
||||
Server IAM 缓存修复使 Console **Reset to default(恢复默认)** 操作即时生效。存储超时与取消行为保持既有语义:超时不代表底层磁盘操作已停止,也不代表写入未提交。升级前请阅读[联合发布说明](https://silo.pgsty.com/zh/blog/release/silo-20261026/)与[仍存的存储限制](CHANGELOG.md#release2026-10-26t00-00-00z)。代码、问题报告和文档贡献者见 [CONTRIBUTORS.md](CONTRIBUTORS.md)。
|
||||
|
||||
## 概述
|
||||
|
||||
@@ -61,7 +62,7 @@ docker run -d --name silo -p 9000:9000 -p 9001:9001 \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=change-me-long-password \
|
||||
-v "$PWD/data:/data" \
|
||||
docker.io/pgsty/silo:latest server /data --console-address ":9001"
|
||||
docker.io/pgsty/silo:RELEASE.2026-10-26T00-00-00Z server /data --console-address ":9001"
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
@@ -100,7 +101,7 @@ Silo 保留 S3 与存储格式兼容性,包括既有 `MINIO_*` 环境变量、
|
||||
|
||||
### TLS 与 Go 升级
|
||||
|
||||
Server 恢复 Go 默认密钥交换策略的修复已在 main,尚未包含在 Server 20260903。
|
||||
Server 20260916 已恢复 Go 默认密钥交换策略,20261018 在 Go 1.27.2 下保留该行为。
|
||||
`GODEBUG=tlsmlkem=0`、`tlssecpmlkem=0` 的适用范围、macOS 根证书来源变化,
|
||||
以及 OIDC discovery 的诊断方法见 [Go 1.27 TLS 与 OIDC 指南](https://silo.pgsty.com/zh/blog/design/go127-tls-oidc-discovery/)。
|
||||
|
||||
@@ -119,7 +120,7 @@ Server 恢复 Go 默认密钥交换策略的修复已在 main,尚未包含在
|
||||
|
||||
<!-- Generated by silo.pgsty.com/bin/contributors.py. -->
|
||||
|
||||
每位 issue 或 PR 的作者都是 SILO 社区的一员,包括尚未合并的工作。已合并的修复、被采纳的方案和有效报告优先展示,其余参考首次参与时间;金色圆环突出经过审核的显著贡献。
|
||||
每位 issue 或 PR 的作者,以及同意公开署名的安全报告者,都是 SILO 社区的一员,包括尚未合并的工作。已合并的修复、被采纳的方案和有效报告优先展示,其余参考首次参与时间;金色圆环突出经过审核的显著贡献。
|
||||
|
||||
<a href="CONTRIBUTORS.md">
|
||||
<picture>
|
||||
|
||||
+12
-3
@@ -31,15 +31,24 @@ separately even when the fork preserves the original commit object and SHA.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
The security contact for Silo is [ron@pgsty.com](mailto:ron@pgsty.com).
|
||||
This address also receives automated security reports for this repository.
|
||||
|
||||
For vulnerabilities in this fork:
|
||||
|
||||
1. Follow the fork-specific expectations in [VULNERABILITY_REPORT.md](VULNERABILITY_REPORT.md).
|
||||
2. Prefer this repository's [private GitHub security advisory](https://github.com/pgsty/silo/security/advisories/new) workflow.
|
||||
3. If private reporting is unavailable, contact the maintainers through the
|
||||
repository without publishing exploit details until a private channel is
|
||||
established.
|
||||
3. If you cannot use GitHub private reporting, email [ron@pgsty.com](mailto:ron@pgsty.com)
|
||||
directly. Do not publish vulnerability details or exploit code in public
|
||||
issues or pull requests.
|
||||
4. If you confirm the issue also affects upstream `minio/minio`, report it upstream separately.
|
||||
|
||||
## Scanner Configuration
|
||||
|
||||
The [OSS Scanner build environment](.oss-scanner/Dockerfile) and
|
||||
[scan scope and offline checks](.oss-scanner/threat_model.md) are maintained
|
||||
with the source. Update them when dependencies or security boundaries change.
|
||||
|
||||
## Disclosure Process
|
||||
|
||||
Fork-specific fixes and user-visible upgrade notes are published in [the security advisory ledger](https://silo.pgsty.com/about/security-advisories/). The fork-specific triage and remediation process is described in [VULNERABILITY_REPORT.md](VULNERABILITY_REPORT.md).
|
||||
|
||||
@@ -7,7 +7,7 @@ _init() {
|
||||
|
||||
## Minimum required versions for build dependencies
|
||||
GIT_VERSION="1.0"
|
||||
GO_VERSION="1.27.1"
|
||||
GO_VERSION="1.27.3"
|
||||
OSX_VERSION="10.8"
|
||||
KNAME=$(uname -s)
|
||||
ARCH=$(uname -m)
|
||||
|
||||
@@ -40,7 +40,7 @@ if [ -n "${MCLI_BIN:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release=${MCLI_RELEASE:-RELEASE.2026-09-16T00-00-00Z}
|
||||
release=${MCLI_RELEASE:-RELEASE.2026-11-09T00-00-00Z}
|
||||
version_hyphen=${release#RELEASE.}
|
||||
package_version=$(printf '%s\n' "${version_hyphen}" | sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2})-([0-9]{2})-([0-9]{2})Z$/\1\2\3\4\5\6.0.0/')
|
||||
if [ "${package_version}" = "${version_hyphen}" ]; then
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"MINIO_API_TRANSITION_WORKERS",
|
||||
"MINIO_API_TRUSTED_PROXIES",
|
||||
"MINIO_ARGS",
|
||||
"MINIO_AUDIT_",
|
||||
"MINIO_AUDIT_KAFKA_BROKERS",
|
||||
"MINIO_AUDIT_KAFKA_CLIENT_TLS_CERT",
|
||||
"MINIO_AUDIT_KAFKA_CLIENT_TLS_KEY",
|
||||
@@ -313,6 +314,7 @@
|
||||
"MINIO_LAMBDA_WEBHOOK_ENABLE",
|
||||
"MINIO_LAMBDA_WEBHOOK_ENDPOINT",
|
||||
"MINIO_LISTENERS",
|
||||
"MINIO_LOGGER_",
|
||||
"MINIO_LOGGER_HTTP_ENDPOINT",
|
||||
"MINIO_LOGGER_WEBHOOK_AUTH_TOKEN",
|
||||
"MINIO_LOGGER_WEBHOOK_BATCH_SIZE",
|
||||
@@ -594,11 +596,13 @@
|
||||
"x-minio-from-peer",
|
||||
"x-minio-healing-drives",
|
||||
"x-minio-internal",
|
||||
"x-minio-internal-a",
|
||||
"x-minio-internal-actual-object-size",
|
||||
"x-minio-internal-actual-size",
|
||||
"x-minio-internal-archive-info",
|
||||
"x-minio-internal-archive-type",
|
||||
"x-minio-internal-b",
|
||||
"x-minio-internal-c",
|
||||
"x-minio-internal-compression",
|
||||
"x-minio-internal-encrypted-multipart",
|
||||
"x-minio-internal-encryptedmultipart",
|
||||
@@ -611,6 +615,7 @@
|
||||
"x-minio-internal-replication-reset-arn",
|
||||
"x-minio-internal-replication-status",
|
||||
"x-minio-internal-replication-timestamp",
|
||||
"x-minio-internal-restore-attempt",
|
||||
"x-minio-internal-server-side-encryption",
|
||||
"x-minio-internal-server-side-encryption-context",
|
||||
"x-minio-internal-server-side-encryption-iv",
|
||||
|
||||
@@ -11,6 +11,33 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Git is already a build prerequisite. --no-index also scans untracked files
|
||||
# and exported source trees. As before, honor .gitignore only in a worktree.
|
||||
search_ignore=--no-exclude-standard
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
search_ignore=--exclude-standard
|
||||
fi
|
||||
search_errors="$(mktemp)"
|
||||
trap 'rm -f "${search_errors}"' EXIT
|
||||
|
||||
search_text() {
|
||||
local status=0
|
||||
git --no-pager grep --no-index "${search_ignore}" --no-color -nIHE "$@" 2>"${search_errors}" || status=$?
|
||||
# Git can return 1 for both no matches and a file read error. Check stderr
|
||||
# as well, before callers filter matches through their allowlists.
|
||||
if [ "${status}" -gt 1 ] || [ -s "${search_errors}" ]; then
|
||||
cat "${search_errors}" >&2
|
||||
fail "could not scan source files"
|
||||
fi
|
||||
}
|
||||
|
||||
# Keep hidden files and build output out of the repository-wide scans.
|
||||
source_excludes=(
|
||||
':(exclude,glob)**/.*'
|
||||
':(exclude,glob)**/.*/**'
|
||||
':(exclude)dist/**'
|
||||
)
|
||||
|
||||
require_file() {
|
||||
[ -f "$1" ] || fail "missing required file: $1"
|
||||
}
|
||||
@@ -152,7 +179,11 @@ if grep -Ev '^[[:space:]]*(#|$)' silo.env | grep -q '='; then
|
||||
fail "silo.env must not contain active assignments that shadow /etc/default/minio"
|
||||
fi
|
||||
|
||||
if rg -n 'pgsty/minio:' .github/workflows Dockerfile.goreleaser helm/silo; then
|
||||
delivery_hits="$(search_text 'pgsty/minio:' -- .github/workflows Dockerfile.goreleaser helm/silo \
|
||||
':(exclude,glob).github/workflows/**/.*' ':(exclude,glob).github/workflows/**/.*/**' \
|
||||
':(exclude,glob)helm/silo/**/.*' ':(exclude,glob)helm/silo/**/.*/**')"
|
||||
if [ -n "${delivery_hits}" ]; then
|
||||
printf '%s\n' "${delivery_hits}" >&2
|
||||
fail "an active delivery surface still publishes the frozen pgsty/minio image"
|
||||
fi
|
||||
|
||||
@@ -165,10 +196,10 @@ fi
|
||||
# So two rules. First, no live URL may resolve to the old repository anywhere,
|
||||
# READMEs and CONTRIBUTORS.md included.
|
||||
old_repo_pattern='pgsty/minio(\.git)?([^[:alnum:]_.-]|$)'
|
||||
stale_repo_url="$(rg -n -e "github\.com/${old_repo_pattern}" -e "hub\.docker\.com/r/${old_repo_pattern}" \
|
||||
--glob '!.git/**' --glob '!dist/**' \
|
||||
--glob '!SILO_REBRANDING_MIGRATION.md' \
|
||||
--glob '!buildscripts/rebrand-guard/compat-baseline.json' . |
|
||||
stale_repo_url="$(search_text -e "github\.com/${old_repo_pattern}" -e "hub\.docker\.com/r/${old_repo_pattern}" -- . \
|
||||
"${source_excludes[@]}" ':(exclude)SILO_REBRANDING_MIGRATION.md' \
|
||||
':(exclude)buildscripts/rebrand-guard/compat-baseline.json')"
|
||||
stale_repo_url="$(printf '%s\n' "${stale_repo_url}" |
|
||||
sed 's#^\./##' | grep -v '^buildscripts/verify-rebrand\.sh:' || true)"
|
||||
if [ -n "${stale_repo_url}" ]; then
|
||||
printf '%s\n' "${stale_repo_url}" >&2
|
||||
@@ -180,32 +211,32 @@ fi
|
||||
# legacy image, the two READMEs that document the rename and the archived
|
||||
# minio branch, and historical issue titles in CONTRIBUTORS.md.
|
||||
repo_guard_allowlist='^(buildscripts/minio-upgrade\.sh|buildscripts/verify-rebrand\.sh|buildscripts/helm-migration-guard/main\.go|README\.md|README_ZH\.md|CONTRIBUTORS\.md):'
|
||||
stale_repo="$(rg -n "${old_repo_pattern}" --glob '!.git/**' --glob '!dist/**' \
|
||||
--glob '!SILO_REBRANDING_MIGRATION.md' \
|
||||
--glob '!buildscripts/rebrand-guard/compat-baseline.json' . |
|
||||
stale_repo="$(search_text "${old_repo_pattern}" -- . "${source_excludes[@]}" \
|
||||
':(exclude)SILO_REBRANDING_MIGRATION.md' ':(exclude)buildscripts/rebrand-guard/compat-baseline.json')"
|
||||
stale_repo="$(printf '%s\n' "${stale_repo}" |
|
||||
sed 's#^\./##' | grep -Ev "${repo_guard_allowlist}" || true)"
|
||||
if [ -n "${stale_repo}" ]; then
|
||||
printf '%s\n' "${stale_repo}" >&2
|
||||
fail "a source reference still names the pre-rename pgsty/minio repository"
|
||||
fi
|
||||
|
||||
stale_branch="$(rg -n 'pgsty/silo/(blob/|tree/|raw/)?master' \
|
||||
--glob '!.git/**' --glob '!dist/**' . || true)"
|
||||
stale_branch="$(search_text 'pgsty/silo/(blob/|tree/|raw/)?master' -- . "${source_excludes[@]}")"
|
||||
if [ -n "${stale_branch}" ]; then
|
||||
printf '%s\n' "${stale_branch}" >&2
|
||||
fail "a link still targets the retired master branch; raw and Actions URLs do not follow a branch rename"
|
||||
fi
|
||||
|
||||
for workflow in .github/workflows/go.yml .github/workflows/vulncheck.yml; do
|
||||
if rg -q '^\s+- master$' "${workflow}"; then
|
||||
stale_filter="$(search_text '^[[:space:]]+- master$' -- "${workflow}")"
|
||||
if [ -n "${stale_filter}" ]; then
|
||||
fail "${workflow} still filters on master and would go silently dormant on main"
|
||||
fi
|
||||
require_text "${workflow}" " - main"
|
||||
done
|
||||
|
||||
network_hits="$(rg -n --glob '*.go' --glob '!**/*_test.go' \
|
||||
'https?://[^"`[:space:]]*(dl\.min\.io|subnet\.min\.io|api\.min\.io|slack\.min\.io|play\.min\.io)' \
|
||||
cmd internal || true)"
|
||||
network_hits="$(search_text \
|
||||
'https?://[^"`[:space:]]*(dl\.min\.io|subnet\.min\.io|api\.min\.io|slack\.min\.io|play\.min\.io)' -- \
|
||||
':(glob)cmd/**/*.go' ':(glob)internal/**/*.go' ':(exclude,glob)**/*_test.go' "${source_excludes[@]}")"
|
||||
network_hits="$(printf '%s\n' "${network_hits}" | grep -Ev '^[^:]+:[0-9]+:[[:space:]]*//' || true)"
|
||||
if [ -n "${network_hits}" ]; then
|
||||
printf '%s\n' "${network_hits}" >&2
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//go:build consistency_strict
|
||||
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type abandonedCleanupCommitDisk struct {
|
||||
StorageAPI
|
||||
entered chan<- struct{}
|
||||
release <-chan struct{}
|
||||
}
|
||||
|
||||
func (d abandonedCleanupCommitDisk) RenameData(ctx context.Context, sv, sp string, fi FileInfo, dv, dp string, o RenameOptions) (RenameDataResp, error) {
|
||||
var success int64
|
||||
gate := &scopeStagedContext{
|
||||
Context: ctx, meta: filepath.Join(d.Endpoint().Path, dv, dp, fi.DataDir, "part.1"),
|
||||
entered: d.entered, release: d.release,
|
||||
tracker: healthDiskCtxValue{lastSuccess: &success},
|
||||
}
|
||||
return d.StorageAPI.RenameData(gate, sv, sp, fi, dv, dp, o)
|
||||
}
|
||||
|
||||
// A lost namespace lease can admit cleanup while a disk transaction is between
|
||||
// moving its external shards and replacing xl.meta. Those shards are live work,
|
||||
// even though the old metadata does not refer to them yet. Cleanup must wait.
|
||||
func TestMutationAbandonedCleanupWaitsForCommit(t *testing.T) {
|
||||
z, bucket := mutationPools(t)
|
||||
const object = "cleanup-during-commit"
|
||||
body := strings.Repeat("new payload", 200000)
|
||||
mutationPut(t, z, bucket, object, 0, strings.Repeat("old payload", 200000), time.Now().Add(-time.Minute))
|
||||
set := z.serverPools[0].getHashedSet(object)
|
||||
disks, swap := installListingDiskSwitch(set)
|
||||
defer swap(disks)
|
||||
entered, release := make(chan struct{}, len(disks)), make(chan struct{})
|
||||
unblock := sync.OnceFunc(func() { close(release) })
|
||||
defer unblock()
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
for i, d := range disks {
|
||||
wrapped[i] = abandonedCleanupCommitDisk{d, entered, release}
|
||||
}
|
||||
swap(wrapped)
|
||||
committed := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := z.serverPools[0].PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, strings.NewReader(body), int64(len(body)), "", ""), ObjectOptions{NoLock: true})
|
||||
committed <- err
|
||||
}()
|
||||
for range disks {
|
||||
select {
|
||||
case <-entered:
|
||||
case err := <-committed:
|
||||
t.Fatalf("PUT returned before shard barrier: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("PUT did not move all external shards")
|
||||
}
|
||||
}
|
||||
t.Log("All eight new external shard directories exist; old xl.meta is still active")
|
||||
cleaned := make(chan error, len(disks))
|
||||
for _, d := range disks {
|
||||
go func(d StorageAPI) { cleaned <- d.CleanAbandonedData(context.Background(), bucket, object) }(d)
|
||||
}
|
||||
early := 0
|
||||
timer := time.NewTimer(150 * time.Millisecond)
|
||||
wait:
|
||||
for {
|
||||
select {
|
||||
case err := <-cleaned:
|
||||
early++
|
||||
t.Errorf("abandoned-data cleanup overtook unfinished commit: %v", err)
|
||||
if early == len(disks) {
|
||||
break wait
|
||||
}
|
||||
case <-timer.C:
|
||||
break wait
|
||||
}
|
||||
}
|
||||
timer.Stop()
|
||||
unblock()
|
||||
select {
|
||||
case err := <-committed:
|
||||
if err != nil {
|
||||
t.Fatalf("PUT failed: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("PUT did not finish")
|
||||
}
|
||||
for i := early; i < len(disks); i++ {
|
||||
select {
|
||||
case err := <-cleaned:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("cleanup did not finish")
|
||||
}
|
||||
}
|
||||
swap(disks)
|
||||
r, err := z.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("acknowledged PUT unreadable after abandoned-data cleanup: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil || string(data) != body {
|
||||
t.Fatalf("acknowledged external shards lost: bytes=%d err=%v", len(data), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup can also arrive after a disk has committed but before its delayed
|
||||
// reply lets the coordinator decide to undo a failed quorum. The previous
|
||||
// generation's backup is still owned by that unfinished transaction.
|
||||
func TestMutationAbandonedCleanupPreservesUndoBackup(t *testing.T) {
|
||||
z, bucket := mutationPools(t)
|
||||
const object = "cleanup-before-undo"
|
||||
body := strings.Repeat("last acknowledged", 150000)
|
||||
base := time.Now().Add(-time.Minute)
|
||||
mutationPut(t, z, bucket, object, 0, body, base)
|
||||
set := z.serverPools[0].getHashedSet(object)
|
||||
disks, swap := installListingDiskSwitch(set)
|
||||
defer swap(disks)
|
||||
entered, release := make(chan struct{}, 4), make(chan struct{})
|
||||
unblock := sync.OnceFunc(func() { close(release) })
|
||||
defer unblock()
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
for i, d := range disks {
|
||||
wrapped[i] = delayedRenameResult{d, i >= 4, entered, release}
|
||||
}
|
||||
swap(wrapped)
|
||||
committed := make(chan error, 1)
|
||||
go func() {
|
||||
next := strings.Repeat("failed update", 180000)
|
||||
_, err := z.serverPools[0].PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, strings.NewReader(next), int64(len(next)), "", ""), ObjectOptions{NoLock: true, MTime: base.Add(time.Second)})
|
||||
committed <- err
|
||||
}()
|
||||
for range 4 {
|
||||
select {
|
||||
case <-entered:
|
||||
case err := <-committed:
|
||||
t.Fatalf("PUT returned before reply barrier: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("disk commit did not reach reply barrier")
|
||||
}
|
||||
}
|
||||
for _, d := range disks[:4] {
|
||||
if err := d.CleanAbandonedData(t.Context(), bucket, object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case err := <-committed:
|
||||
if err == nil {
|
||||
t.Fatal("four-of-eight commit unexpectedly reached write quorum")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("failed PUT did not finish undo")
|
||||
}
|
||||
swap(disks)
|
||||
for i, d := range disks {
|
||||
fi, err := d.ReadVersion(t.Context(), "", bucket, object, "", ReadOptions{})
|
||||
if err != nil || !fi.ModTime.Equal(base) {
|
||||
t.Errorf("disk%d lost last acknowledged generation after failed PUT: mtime=%v err=%v", i, fi.ModTime, err)
|
||||
}
|
||||
}
|
||||
r, err := z.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("last acknowledged value unreadable after cleanup and undo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil || string(data) != body {
|
||||
t.Fatalf("last acknowledged value lost after cleanup and undo: bytes=%d err=%v", len(data), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/cachevalue"
|
||||
)
|
||||
|
||||
func TestAccountInfoReflectsBucketMutations(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
bed, err := prepareAdminErasureTestBed(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer bed.TearDown()
|
||||
previousCache := listBucketsCache
|
||||
defer func() { listBucketsCache = previousCache }()
|
||||
|
||||
// Hold a real pre-mutation snapshot past the assertions so the regression
|
||||
// cannot disappear merely because a slow test outlasted the cache TTL.
|
||||
freezeListing := func() {
|
||||
t.Helper()
|
||||
buckets, err := bed.objLayer.ListBuckets(ctx, BucketOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listBucketsCache = cachevalue.NewFromFunc(time.Hour, cachevalue.Opts{}, func(context.Context) ([]BucketInfo, error) {
|
||||
return buckets, nil
|
||||
})
|
||||
if _, err := listBucketsCache.GetWithCtx(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const bucket = "account-info-freshness"
|
||||
assertPresent := func(want bool) {
|
||||
t.Helper()
|
||||
req, err := buildAdminRequest(nil, http.MethodGet, "/accountinfo", 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
bed.router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("account info: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var info madmin.AccountInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, item := range info.Buckets {
|
||||
if item.Name == bucket {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if found != want {
|
||||
t.Errorf("account info bucket present = %v, want %v", found, want)
|
||||
}
|
||||
}
|
||||
|
||||
freezeListing()
|
||||
if err := bed.objLayer.MakeBucket(ctx, bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPresent(true)
|
||||
freezeListing()
|
||||
if err := bed.objLayer.DeleteBucket(ctx, bucket, DeleteBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPresent(false)
|
||||
}
|
||||
@@ -249,8 +249,10 @@ func (a adminAPIHandlers) AddServiceAccountLDAP(w http.ResponseWriter, r *http.R
|
||||
|
||||
// In case of LDAP/OIDC we need to set `opts.claims` to ensure
|
||||
// it is associated with the LDAP/OIDC user properly.
|
||||
// The new credential's policy comes from the request, not the caller.
|
||||
for k, v := range cred.Claims {
|
||||
if k == expClaim {
|
||||
switch k {
|
||||
case expClaim, policy.SessionPolicyName, sessionPolicyNameExtracted, iamPolicyClaimNameSA():
|
||||
continue
|
||||
}
|
||||
opts.claims[k] = v
|
||||
|
||||
@@ -373,7 +373,9 @@ func (a adminAPIHandlers) RebalanceStop(w http.ResponseWriter, r *http.Request)
|
||||
// Cancel any ongoing rebalance operation
|
||||
globalNotificationSys.StopRebalance(r.Context())
|
||||
writeSuccessResponseHeadersOnly(w)
|
||||
adminLogIf(ctx, pools.saveRebalanceStats(GlobalContext, 0, rebalSaveStoppedAt))
|
||||
if err := pools.saveRebalanceStats(GlobalContext, 0, rebalSaveStoppedAt); !errors.Is(err, errRebalanceNotStarted) {
|
||||
adminLogIf(ctx, err)
|
||||
}
|
||||
globalNotificationSys.LoadRebalanceMeta(ctx, false)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
polplugin "github.com/minio/minio/internal/config/policy/plugin"
|
||||
"github.com/minio/mux"
|
||||
xnet "github.com/pgsty/silo-pkg/v3/net"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
func iamAdminFixture(t *testing.T) (context.Context, *IAMSys) {
|
||||
t.Helper()
|
||||
ctx, sys, obj := prepareIAMRevisionFixture(t)
|
||||
globalObjLayerMutex.Lock()
|
||||
globalObjectAPI = obj
|
||||
globalObjLayerMutex.Unlock()
|
||||
return ctx, sys
|
||||
}
|
||||
|
||||
func iamAdminUser(ctx context.Context, t *testing.T, sys *IAMSys, name, policyName string) auth.Credentials {
|
||||
t.Helper()
|
||||
cred := auth.Credentials{AccessKey: name, SecretKey: "review-user-valid-secret"}
|
||||
_, err := sys.CreateUser(ctx, name, madmin.AddOrUpdateUserReq{SecretKey: cred.SecretKey, Status: madmin.AccountEnabled})
|
||||
mustIAM(t, err)
|
||||
if policyName != "" {
|
||||
_, err = sys.PolicyDBSet(ctx, name, policyName, regUser, false)
|
||||
mustIAM(t, err)
|
||||
}
|
||||
return cred
|
||||
}
|
||||
|
||||
func iamAdminServiceAccount(ctx context.Context, t *testing.T, sys *IAMSys, parent, key, document string) auth.Credentials {
|
||||
t.Helper()
|
||||
var sp *policy.Policy
|
||||
var err error
|
||||
if document != "" {
|
||||
sp, err = policy.ParseConfig(strings.NewReader(document))
|
||||
mustIAM(t, err)
|
||||
}
|
||||
cred, _, err := sys.NewServiceAccount(ctx, parent, nil, newServiceAccountOpts{accessKey: key, secretKey: "review-service-valid-secret", sessionPolicy: sp})
|
||||
mustIAM(t, err)
|
||||
return cred
|
||||
}
|
||||
|
||||
func iamAdminRequest(t *testing.T, cred auth.Credentials, method, target string, payload any) *http.Request {
|
||||
t.Helper()
|
||||
var body []byte
|
||||
if payload != nil {
|
||||
raw, err := json.Marshal(payload)
|
||||
mustIAM(t, err)
|
||||
body, err = madmin.EncryptData(cred.SecretKey, raw)
|
||||
mustIAM(t, err)
|
||||
}
|
||||
headers := map[string]string{}
|
||||
if cred.IsTemp() && !cred.IsServiceAccount() {
|
||||
headers["X-Amz-Security-Token"] = cred.SessionToken
|
||||
}
|
||||
req, err := newTestSignedRequestV4(method, "http://localhost"+target, int64(len(body)), bytes.NewReader(body), cred.AccessKey, cred.SecretKey, headers)
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, req.ParseForm())
|
||||
return req
|
||||
}
|
||||
|
||||
func iamAdminCall(t *testing.T, cred auth.Credentials, method, target string, payload any, vars map[string]string, handler http.HandlerFunc) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := iamAdminRequest(t, cred, method, target, payload)
|
||||
req = mux.SetURLVars(req, vars)
|
||||
rec := httptest.NewRecorder()
|
||||
req = req.WithContext(newContext(req, rec, "IAMAdminAuthorization"))
|
||||
handler(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func assertIAMAdminStatus(t *testing.T, operation string, rec *httptest.ResponseRecorder, want int) {
|
||||
t.Helper()
|
||||
if rec.Code != want {
|
||||
t.Errorf("%s status=%d want=%d body=%s", operation, rec.Code, want, rec.Body.String())
|
||||
return
|
||||
}
|
||||
if want == http.StatusForbidden || want == http.StatusNotFound {
|
||||
var response APIErrorResponse
|
||||
mustIAM(t, json.Unmarshal(rec.Body.Bytes(), &response))
|
||||
wantCode := errorCodes.ToAPIErr(ErrAccessDenied).Code
|
||||
if want == http.StatusNotFound {
|
||||
wantCode = errorCodes.ToAPIErr(ErrAdminServiceAccountNotFound).Code
|
||||
}
|
||||
if response.Code != wantCode {
|
||||
t.Errorf("%s error code=%s want=%s", operation, response.Code, wantCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func iamAdminPlugin(t *testing.T, allow bool) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"result": allow})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
u, err := xnet.ParseHTTPURL(server.URL)
|
||||
mustIAM(t, err)
|
||||
previous := newGlobalAuthZPluginFn()
|
||||
setGlobalAuthZPlugin(polplugin.New(polplugin.Args{URL: u, CloseRespFn: func(r io.ReadCloser) { r.Close() }}))
|
||||
t.Cleanup(func() { setGlobalAuthZPlugin(previous) })
|
||||
}
|
||||
|
||||
func TestIAMAdminRootEmptyPolicy(t *testing.T) {
|
||||
for _, doc := range []string{"", "null", "{}", `{"Statement":null}`} {
|
||||
t.Run("policy="+doc, func(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
caller := iamAdminServiceAccount(ctx, t, sys, globalActiveCred.AccessKey, "review-root-empty", doc)
|
||||
sibling := iamAdminServiceAccount(ctx, t, sys, globalActiveCred.AccessKey, "review-root-sibling", "")
|
||||
req := iamAdminRequest(t, caller, "GET", "/minio/admin/v3/list-service-accounts", nil)
|
||||
checked, owner, code := checkKeyValid(req, caller.AccessKey)
|
||||
if code != ErrNone {
|
||||
t.Fatalf("checkKeyValid: %v", code)
|
||||
}
|
||||
active, _ := isAllowedBySessionPolicyForServiceAccount(policy.Args{Claims: checked.Claims})
|
||||
t.Logf("owner=%v parsed_active_session_policy=%v", owner, active)
|
||||
api := adminAPIHandlers{}
|
||||
assertIAMAdminStatus(t, "create", iamAdminCall(t, caller, "PUT", "/minio/admin/v3/add-service-account", madmin.AddServiceAccountReq{AccessKey: "review-root-new", SecretKey: "review-service-valid-secret"}, nil, api.AddServiceAccount), 200)
|
||||
assertIAMAdminStatus(t, "update", iamAdminCall(t, caller, "POST", "/minio/admin/v3/update-service-account?accessKey="+sibling.AccessKey, madmin.UpdateServiceAccountReq{NewName: "review"}, map[string]string{"accessKey": sibling.AccessKey}, api.UpdateServiceAccount), 204)
|
||||
assertIAMAdminStatus(t, "delete", iamAdminCall(t, caller, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+sibling.AccessKey, nil, map[string]string{"accessKey": sibling.AccessKey}, api.DeleteServiceAccount), 204)
|
||||
assertIAMAdminStatus(t, "revoke", iamAdminCall(t, caller, "POST", "/minio/admin/v3/revoke-tokens/builtin?fullRevoke=true", nil, map[string]string{"userProvider": madmin.BuiltinProvider}, api.RevokeTokens), 204)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMAdminEmptyPolicyDelegation(t *testing.T) {
|
||||
for _, parentType := range []string{"root", "user"} {
|
||||
for _, document := range []string{"null", "{}", `{"Statement":null}`} {
|
||||
t.Run(parentType+"/"+document, func(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
parent := globalActiveCred
|
||||
if parentType == "user" {
|
||||
parent = iamAdminUser(ctx, t, sys, "empty-policy-parent", "readwrite")
|
||||
}
|
||||
sp, err := policy.ParseConfig(strings.NewReader(document))
|
||||
mustIAM(t, err)
|
||||
caller, _, err := sys.NewServiceAccount(ctx, parent.AccessKey, nil, newServiceAccountOpts{
|
||||
accessKey: "empty-policy-caller", secretKey: "empty-policy-caller-secret", sessionPolicy: sp,
|
||||
claims: map[string]any{"identity-attribute": "preserved"},
|
||||
})
|
||||
mustIAM(t, err)
|
||||
const limitedPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["admin:ListServiceAccounts"]}]}`
|
||||
for _, tc := range []struct {
|
||||
policy string
|
||||
listStatus int
|
||||
allowObject bool
|
||||
}{
|
||||
{"", http.StatusOK, true},
|
||||
{"null", http.StatusOK, true},
|
||||
{"{}", http.StatusOK, true},
|
||||
{`{"Statement":null}`, http.StatusOK, true},
|
||||
{limitedPolicy, http.StatusOK, false},
|
||||
{`{"Version":"2012-10-17"}`, http.StatusForbidden, false},
|
||||
{`{"Statement":[]}`, http.StatusForbidden, false},
|
||||
} {
|
||||
expires := UTCNow().Add(time.Hour)
|
||||
req := madmin.AddServiceAccountReq{Expiration: &expires}
|
||||
if tc.policy != "" {
|
||||
req.Policy = json.RawMessage(tc.policy)
|
||||
}
|
||||
api := adminAPIHandlers{}
|
||||
rec := iamAdminCall(t, caller, "PUT", "/minio/admin/v3/add-service-account", req, nil, api.AddServiceAccount)
|
||||
assertIAMAdminStatus(t, "create expiring child", rec, http.StatusOK)
|
||||
data, err := madmin.DecryptData(caller.SecretKey, rec.Body)
|
||||
mustIAM(t, err)
|
||||
var response madmin.AddServiceAccountResp
|
||||
mustIAM(t, json.Unmarshal(data, &response))
|
||||
child, ok := sys.store.GetUser(response.Credentials.AccessKey)
|
||||
if !ok {
|
||||
t.Fatal("new service account missing")
|
||||
}
|
||||
// Exercise the issued key through authentication and authorization.
|
||||
assertIAMAdminStatus(t, "use expiring child policy="+tc.policy, iamAdminCall(t, child.Credentials, "GET", "/minio/admin/v3/list-service-accounts", nil, nil, api.ListServiceAccounts), tc.listStatus)
|
||||
signed := iamAdminRequest(t, child.Credentials, "GET", "/example/object", nil)
|
||||
checked, owner, code := checkKeyValid(signed, child.Credentials.AccessKey)
|
||||
if code != ErrNone {
|
||||
t.Fatalf("child authentication: %v", code)
|
||||
}
|
||||
if checked.Claims["identity-attribute"] != "preserved" {
|
||||
t.Fatal("creation lost the caller's identity context")
|
||||
}
|
||||
allowed := sys.IsAllowed(policy.Args{AccountName: checked.AccessKey, Claims: checked.Claims, IsOwner: owner, Action: policy.GetObjectAction, BucketName: "example", ObjectName: "object"})
|
||||
if allowed != tc.allowObject {
|
||||
t.Errorf("GetObject allowed=%v with requested policy=%s", allowed, tc.policy)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMAdminCorruptServiceCleanup(t *testing.T) {
|
||||
for _, status := range []string{auth.AccountOn, auth.AccountOff, "expired"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
caller := iamAdminUser(ctx, t, sys, "review-admin", "consoleAdmin")
|
||||
parent := iamAdminUser(ctx, t, sys, "review-parent", "readwrite")
|
||||
svc := iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "rv-corrupt", "")
|
||||
if status == auth.AccountOff {
|
||||
_, err := sys.UpdateServiceAccount(ctx, svc.AccessKey, updateServiceAccountOpts{status: status})
|
||||
mustIAM(t, err)
|
||||
}
|
||||
cache := sys.store.lock()
|
||||
u := cache.iamUsersMap[svc.AccessKey]
|
||||
u.Credentials.SessionToken = "broken-session-token"
|
||||
if status == "expired" {
|
||||
u.Credentials.Expiration = UTCNow().Add(-time.Minute)
|
||||
}
|
||||
cache.iamUsersMap[svc.AccessKey] = u
|
||||
sys.store.unlock()
|
||||
_, _, err := sys.GetServiceAccount(ctx, svc.AccessKey)
|
||||
if err == nil {
|
||||
t.Fatal("fault injection failed")
|
||||
}
|
||||
raw, exists := sys.store.GetUser(svc.AccessKey)
|
||||
_, loginOK := sys.GetUser(ctx, svc.AccessKey)
|
||||
t.Logf("metadata_exists=%v parent_known=%v GetUser_ok=%v", exists, raw.Credentials.ParentUser == parent.AccessKey, loginOK)
|
||||
api := adminAPIHandlers{}
|
||||
outsider := iamAdminUser(ctx, t, sys, "cleanup-outsider", "readwrite")
|
||||
assertIAMAdminStatus(t, "unauthorized cleanup", iamAdminCall(t, outsider, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+svc.AccessKey, nil, map[string]string{"accessKey": svc.AccessKey}, api.DeleteServiceAccount), 404)
|
||||
assertIAMAdminStatus(t, "admin cleanup", iamAdminCall(t, caller, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+svc.AccessKey, nil, map[string]string{"accessKey": svc.AccessKey}, api.DeleteServiceAccount), 204)
|
||||
if _, ok := sys.store.GetUser(svc.AccessKey); ok {
|
||||
t.Fatal("deleted service account remains in the IAM cache")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMAdminPolicylessSelf(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
caller := iamAdminUser(ctx, t, sys, "review-policyless", "")
|
||||
svc := iamAdminServiceAccount(ctx, t, sys, caller.AccessKey, "rv-policyless-child", "")
|
||||
other := iamAdminUser(ctx, t, sys, "bulk-other", "readwrite")
|
||||
_ = iamAdminServiceAccount(ctx, t, sys, other.AccessKey, "bulk-other-child", "")
|
||||
api := adminAPIHandlers{}
|
||||
assertIAMAdminStatus(t, "list", iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-service-accounts", nil, nil, api.ListServiceAccounts), 200)
|
||||
rec := iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-access-keys-bulk?listType=all", nil, nil, api.ListAccessKeysBulk)
|
||||
assertIAMAdminStatus(t, "list bulk self", rec, 200)
|
||||
data, err := madmin.DecryptData(caller.SecretKey, rec.Body)
|
||||
mustIAM(t, err)
|
||||
var listed map[string]madmin.ListAccessKeysResp
|
||||
mustIAM(t, json.Unmarshal(data, &listed))
|
||||
entry, ok := listed[caller.AccessKey]
|
||||
if len(listed) != 1 || !ok || len(entry.ServiceAccounts) != 1 || entry.ServiceAccounts[0].AccessKey != svc.AccessKey || len(entry.STSKeys) != 0 {
|
||||
t.Fatalf("policyless bulk self returned unexpected identities: %+v", listed)
|
||||
}
|
||||
assertIAMAdminStatus(t, "info service", iamAdminCall(t, caller, "GET", "/minio/admin/v3/info-service-account?accessKey="+svc.AccessKey, nil, map[string]string{"accessKey": svc.AccessKey}, api.InfoServiceAccount), 200)
|
||||
assertIAMAdminStatus(t, "info access key", iamAdminCall(t, caller, "GET", "/minio/admin/v3/info-access-key?accessKey="+svc.AccessKey, nil, map[string]string{"accessKey": svc.AccessKey}, api.InfoAccessKey), 200)
|
||||
assertIAMAdminStatus(t, "delete", iamAdminCall(t, caller, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+svc.AccessKey, nil, map[string]string{"accessKey": svc.AccessKey}, api.DeleteServiceAccount), 204)
|
||||
assertIAMAdminStatus(t, "revoke", iamAdminCall(t, caller, "POST", "/minio/admin/v3/revoke-tokens/builtin?fullRevoke=true", nil, map[string]string{"userProvider": madmin.BuiltinProvider}, api.RevokeTokens), 204)
|
||||
assertIAMAdminStatus(t, "list another user", iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-service-accounts?user="+globalActiveCred.AccessKey, nil, nil, api.ListServiceAccounts), 403)
|
||||
assertIAMAdminStatus(t, "revoke another user", iamAdminCall(t, caller, "POST", "/minio/admin/v3/revoke-tokens/builtin?fullRevoke=true&user="+globalActiveCred.AccessKey, nil, map[string]string{"userProvider": madmin.BuiltinProvider}, api.RevokeTokens), 403)
|
||||
}
|
||||
|
||||
func TestIAMAdminExpiringInheritedPolicy(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
parent := iamAdminUser(ctx, t, sys, "review-legacy-parent", "consoleAdmin")
|
||||
document := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["admin:CreateServiceAccount","admin:UpdateServiceAccount"]}]}`
|
||||
// Model a credential persisted by the old handler: it copied its caller's
|
||||
// policy claims but set the newly created service's type to inherited.
|
||||
expires := UTCNow().Add(time.Hour)
|
||||
caller, _, err := sys.NewServiceAccount(ctx, parent.AccessKey, nil, newServiceAccountOpts{
|
||||
accessKey: "rv-exp-inherited", secretKey: "review-service-valid-secret", expiration: &expires,
|
||||
claims: map[string]any{policy.SessionPolicyName: base64.StdEncoding.EncodeToString([]byte(document))},
|
||||
})
|
||||
mustIAM(t, err)
|
||||
req := iamAdminRequest(t, caller, "GET", "/minio/admin/v3/list-service-accounts", nil)
|
||||
checked, owner, code := checkKeyValid(req, caller.AccessKey)
|
||||
if code != ErrNone {
|
||||
t.Fatalf("checkKeyValid: %v", code)
|
||||
}
|
||||
args := policy.Args{AccountName: caller.AccessKey, Claims: checked.Claims, IsOwner: owner, Action: policy.CreateServiceAccountAdminAction, DenyOnly: true}
|
||||
if !sys.IsAllowed(args) {
|
||||
t.Fatal("fixture cannot create")
|
||||
}
|
||||
args.Action = policy.CreateUserAdminAction
|
||||
args.DenyOnly = false
|
||||
if sys.IsAllowed(args) {
|
||||
t.Fatal("fixture not constrained")
|
||||
}
|
||||
t.Logf("service=%v temp=%v inherited=%v; create allowed, create-user denied", checked.IsServiceAccount(), checked.IsTemp(), checked.Claims[iamPolicyClaimNameSA()] == inheritedPolicyType)
|
||||
api := adminAPIHandlers{}
|
||||
rec := iamAdminCall(t, caller, "PUT", "/minio/admin/v3/add-service-account", madmin.AddServiceAccountReq{AccessKey: "review-escaped", SecretKey: "review-service-valid-secret"}, nil, api.AddServiceAccount)
|
||||
if rec.Code == 200 {
|
||||
child, ok := sys.GetUser(ctx, "review-escaped")
|
||||
if !ok {
|
||||
t.Fatal("new key missing")
|
||||
}
|
||||
childReq := iamAdminRequest(t, child.Credentials, "GET", "/minio/admin/v3/list-service-accounts", nil)
|
||||
childChecked, childOwner, childCode := checkKeyValid(childReq, child.Credentials.AccessKey)
|
||||
if childCode != ErrNone {
|
||||
t.Fatalf("child auth=%v", childCode)
|
||||
}
|
||||
allowed := sys.IsAllowed(policy.Args{AccountName: childChecked.AccessKey, Claims: childChecked.Claims, IsOwner: childOwner, Action: policy.CreateUserAdminAction})
|
||||
t.Errorf("constrained inherited service created a new key; new key CreateUser allowed=%v", allowed)
|
||||
} else {
|
||||
assertIAMAdminStatus(t, "reject derived creation", rec, 403)
|
||||
}
|
||||
updated := iamAdminCall(t, caller, "POST", "/minio/admin/v3/update-service-account?accessKey="+caller.AccessKey, madmin.UpdateServiceAccountReq{NewName: "metadata-only"}, map[string]string{"accessKey": caller.AccessKey}, api.UpdateServiceAccount)
|
||||
if updated.Code == 204 {
|
||||
after, ok := sys.GetUser(ctx, caller.AccessKey)
|
||||
if !ok {
|
||||
t.Fatal("updated key missing")
|
||||
}
|
||||
afterReq := iamAdminRequest(t, after.Credentials, "GET", "/minio/admin/v3/list-service-accounts", nil)
|
||||
afterChecked, afterOwner, afterCode := checkKeyValid(afterReq, caller.AccessKey)
|
||||
if afterCode != ErrNone {
|
||||
t.Fatalf("updated auth=%v", afterCode)
|
||||
}
|
||||
allowed := sys.IsAllowed(policy.Args{AccountName: caller.AccessKey, Claims: afterChecked.Claims, IsOwner: afterOwner, Action: policy.CreateUserAdminAction})
|
||||
t.Errorf("constrained inherited service updated itself; CreateUser allowed after update=%v", allowed)
|
||||
} else {
|
||||
assertIAMAdminStatus(t, "reject inherited self update", updated, 403)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMAdminSessionPolicyCannotDelegate(t *testing.T) {
|
||||
for _, temp := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "service", true: "sts"}[temp], func(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
parent := iamAdminUser(ctx, t, sys, "rv-parent", "consoleAdmin")
|
||||
doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["admin:CreateServiceAccount","admin:UpdateServiceAccount"]}]}`
|
||||
var caller auth.Credentials
|
||||
if !temp {
|
||||
caller = iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "review-restricted", doc)
|
||||
} else {
|
||||
var err error
|
||||
caller, err = auth.GetNewCredentialsWithMetadata(map[string]any{parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix(), policy.SessionPolicyName: base64.StdEncoding.EncodeToString([]byte(doc))}, globalActiveCred.SecretKey)
|
||||
mustIAM(t, err)
|
||||
caller.ParentUser = parent.AccessKey
|
||||
_, err = sys.SetTempUser(ctx, caller.AccessKey, caller, "")
|
||||
mustIAM(t, err)
|
||||
}
|
||||
sibling := iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "rv-sibling", "")
|
||||
api := adminAPIHandlers{}
|
||||
assertIAMAdminStatus(t, "restrict create", iamAdminCall(t, caller, "PUT", "/minio/admin/v3/add-service-account", madmin.AddServiceAccountReq{AccessKey: "review-bad-new", SecretKey: "review-service-valid-secret"}, nil, api.AddServiceAccount), 403)
|
||||
assertIAMAdminStatus(t, "restrict LDAP create", iamAdminCall(t, caller, "PUT", "/minio/admin/v3/idp/ldap/add-service-account", madmin.AddServiceAccountReq{AccessKey: "review-bad-new", SecretKey: "review-service-valid-secret"}, nil, api.AddServiceAccountLDAP), 403)
|
||||
assertIAMAdminStatus(t, "restrict update", iamAdminCall(t, caller, "POST", "/minio/admin/v3/update-service-account?accessKey="+sibling.AccessKey, madmin.UpdateServiceAccountReq{NewSecretKey: "review-known-new-secret"}, map[string]string{"accessKey": sibling.AccessKey}, api.UpdateServiceAccount), 403)
|
||||
if _, ok := sys.store.GetUser("review-bad-new"); ok {
|
||||
t.Fatal("denied creation persisted a credential")
|
||||
}
|
||||
u, ok := sys.store.GetUser(sibling.AccessKey)
|
||||
if !ok || u.Credentials.SecretKey != sibling.SecretKey {
|
||||
t.Fatal("denied update changed the target credential")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMAdminSelfDenials(t *testing.T) {
|
||||
for _, mode := range []string{"parent_deny", "group_deny", "session_implicit_deny", "session_explicit_deny", "plugin_deny"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
parent := iamAdminUser(ctx, t, sys, "rv-self-parent", "readwrite")
|
||||
target := iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "rv-self-target", "")
|
||||
caller := parent
|
||||
doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":["admin:ListServiceAccounts","admin:RemoveServiceAccount"]}]}`
|
||||
switch mode {
|
||||
case "parent_deny", "group_deny":
|
||||
p, err := policy.ParseConfig(strings.NewReader(doc))
|
||||
mustIAM(t, err)
|
||||
_, err = sys.SetPolicy(ctx, "review-self-deny", *p)
|
||||
mustIAM(t, err)
|
||||
if mode == "group_deny" {
|
||||
_, err = sys.AddUsersToGroup(ctx, "self-deny-group", []string{parent.AccessKey})
|
||||
mustIAM(t, err)
|
||||
_, err = sys.PolicyDBSet(ctx, "self-deny-group", "review-self-deny", regUser, true)
|
||||
} else {
|
||||
_, err = sys.PolicyDBSet(ctx, parent.AccessKey, "readwrite,review-self-deny", regUser, false)
|
||||
}
|
||||
mustIAM(t, err)
|
||||
case "session_implicit_deny":
|
||||
caller = iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "rv-self-caller", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::example/*"]}]}`)
|
||||
case "session_explicit_deny":
|
||||
caller = iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "rv-self-caller", doc)
|
||||
case "plugin_deny":
|
||||
iamAdminPlugin(t, false)
|
||||
}
|
||||
api := adminAPIHandlers{}
|
||||
assertIAMAdminStatus(t, "list", iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-service-accounts", nil, nil, api.ListServiceAccounts), 403)
|
||||
assertIAMAdminStatus(t, "list bulk self", iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-access-keys-bulk?listType=all", nil, nil, api.ListAccessKeysBulk), 403)
|
||||
assertIAMAdminStatus(t, "info service", iamAdminCall(t, caller, "GET", "/minio/admin/v3/info-service-account?accessKey="+target.AccessKey, nil, map[string]string{"accessKey": target.AccessKey}, api.InfoServiceAccount), 403)
|
||||
assertIAMAdminStatus(t, "info access key", iamAdminCall(t, caller, "GET", "/minio/admin/v3/info-access-key?accessKey="+target.AccessKey, nil, map[string]string{"accessKey": target.AccessKey}, api.InfoAccessKey), 403)
|
||||
assertIAMAdminStatus(t, "delete", iamAdminCall(t, caller, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+target.AccessKey, nil, map[string]string{"accessKey": target.AccessKey}, api.DeleteServiceAccount), 403)
|
||||
assertIAMAdminStatus(t, "revoke", iamAdminCall(t, caller, "POST", "/minio/admin/v3/revoke-tokens/builtin?fullRevoke=true", nil, map[string]string{"userProvider": madmin.BuiltinProvider}, api.RevokeTokens), 403)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMAdminNonRootCannotManageRoot(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
caller := iamAdminUser(ctx, t, sys, "rv-console-admin", "consoleAdmin")
|
||||
target := iamAdminServiceAccount(ctx, t, sys, globalActiveCred.AccessKey, "rv-root-target", "")
|
||||
api := adminAPIHandlers{}
|
||||
assertIAMAdminStatus(t, "create root", iamAdminCall(t, caller, "PUT", "/minio/admin/v3/add-service-account", madmin.AddServiceAccountReq{AccessKey: "rv-new-root", SecretKey: "review-service-valid-secret", TargetUser: globalActiveCred.AccessKey}, nil, api.AddServiceAccount), 403)
|
||||
assertIAMAdminStatus(t, "update root", iamAdminCall(t, caller, "POST", "/minio/admin/v3/update-service-account?accessKey="+target.AccessKey, madmin.UpdateServiceAccountReq{NewSecretKey: "review-known-new-secret"}, map[string]string{"accessKey": target.AccessKey}, api.UpdateServiceAccount), 403)
|
||||
assertIAMAdminStatus(t, "delete root", iamAdminCall(t, caller, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+target.AccessKey, nil, map[string]string{"accessKey": target.AccessKey}, api.DeleteServiceAccount), 404)
|
||||
assertIAMAdminStatus(t, "revoke root", iamAdminCall(t, caller, "POST", "/minio/admin/v3/revoke-tokens/builtin?fullRevoke=true&user="+globalActiveCred.AccessKey, nil, map[string]string{"userProvider": madmin.BuiltinProvider}, api.RevokeTokens), 403)
|
||||
// Restricted root-derived credentials and the site replicator are not root
|
||||
// administrators, even when their effective policy allows these actions.
|
||||
limited := iamAdminServiceAccount(ctx, t, sys, globalActiveCred.AccessKey, "limited-root", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["admin:*"]}]}`)
|
||||
assertIAMAdminStatus(t, "limited root delete", iamAdminCall(t, limited, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+target.AccessKey, nil, map[string]string{"accessKey": target.AccessKey}, api.DeleteServiceAccount), 404)
|
||||
assertIAMAdminStatus(t, "limited root revoke", iamAdminCall(t, limited, "POST", "/minio/admin/v3/revoke-tokens/builtin?fullRevoke=true", nil, map[string]string{"userProvider": madmin.BuiltinProvider}, api.RevokeTokens), 403)
|
||||
replicator, _, err := sys.NewServiceAccount(ctx, globalActiveCred.AccessKey, nil, newServiceAccountOpts{accessKey: siteReplicatorSvcAcc, secretKey: "replicator-valid-secret", allowSiteReplicatorAccount: true})
|
||||
mustIAM(t, err)
|
||||
assertIAMAdminStatus(t, "replicator create root", iamAdminCall(t, replicator, "PUT", "/minio/admin/v3/add-service-account", madmin.AddServiceAccountReq{}, nil, api.AddServiceAccount), 403)
|
||||
|
||||
// An external plugin remains authoritative, including for root targets.
|
||||
iamAdminPlugin(t, true)
|
||||
assertIAMAdminStatus(t, "plugin update root", iamAdminCall(t, limited, "POST", "/minio/admin/v3/update-service-account?accessKey="+target.AccessKey, madmin.UpdateServiceAccountReq{NewName: "plugin-authorized"}, map[string]string{"accessKey": target.AccessKey}, api.UpdateServiceAccount), 204)
|
||||
assertIAMAdminStatus(t, "plugin create root", iamAdminCall(t, limited, "PUT", "/minio/admin/v3/add-service-account", madmin.AddServiceAccountReq{}, nil, api.AddServiceAccount), 200)
|
||||
}
|
||||
|
||||
func TestIAMAdminDeleteCorruptServiceReplicates(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
caller := iamAdminUser(ctx, t, sys, "cleanup-admin", "consoleAdmin")
|
||||
parent := iamAdminUser(ctx, t, sys, "cleanup-parent", "readwrite")
|
||||
target := iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "cleanup-target", "")
|
||||
cache := sys.store.lock()
|
||||
u := cache.iamUsersMap[target.AccessKey]
|
||||
u.Credentials.SessionToken = "broken-session-token"
|
||||
u.Credentials.Status = auth.AccountOff
|
||||
cache.iamUsersMap[target.AccessKey] = u
|
||||
sys.store.unlock()
|
||||
|
||||
var deletions atomic.Int32
|
||||
peer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/minio/health/live" {
|
||||
return
|
||||
}
|
||||
var batch iamRevisionBatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
|
||||
t.Error(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(batch.Items) != 1 || batch.Items[0].SvcAccChange == nil || batch.Items[0].SvcAccChange.Delete == nil || batch.Items[0].SvcAccChange.Delete.AccessKey != target.AccessKey {
|
||||
t.Error("replication did not carry the service account deletion")
|
||||
} else {
|
||||
deletions.Add(1)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(iamRevisionResponse{iamRevisionStatus: iamRevisionStatus{Version: iamRevisionProtocol, Node: "peer", Instance: "peer-boot", Digest: "deleted"}})
|
||||
}))
|
||||
t.Cleanup(peer.Close)
|
||||
globalSiteReplicatorCred.RLock()
|
||||
previousSecret := globalSiteReplicatorCred.secretKey
|
||||
globalSiteReplicatorCred.RUnlock()
|
||||
globalSiteReplicatorCred.Set(caller.SecretKey)
|
||||
t.Cleanup(func() { globalSiteReplicatorCred.Set(previousSecret) })
|
||||
globalSiteReplicationSys.Lock()
|
||||
globalSiteReplicationSys.enabled = true
|
||||
globalSiteReplicationSys.state = srState{ServiceAccountAccessKey: caller.AccessKey, Peers: map[string]madmin.PeerInfo{
|
||||
globalDeploymentID(): {DeploymentID: globalDeploymentID(), Name: "local"},
|
||||
"peer": {DeploymentID: "peer", Name: "peer", Endpoint: peer.URL},
|
||||
}}
|
||||
globalSiteReplicationSys.Unlock()
|
||||
t.Cleanup(func() {
|
||||
globalSiteReplicationSys.Lock()
|
||||
globalSiteReplicationSys.enabled = false
|
||||
globalSiteReplicationSys.Unlock()
|
||||
})
|
||||
api := adminAPIHandlers{}
|
||||
assertIAMAdminStatus(t, "replicated cleanup", iamAdminCall(t, caller, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+target.AccessKey, nil, map[string]string{"accessKey": target.AccessKey}, api.DeleteServiceAccount), 204)
|
||||
if deletions.Load() != 1 {
|
||||
t.Fatalf("expected one immediate deletion notification, got %d", deletions.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMAdminListSelfAliases(t *testing.T) {
|
||||
for _, kind := range []string{"service", "sts"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
parent := iamAdminUser(ctx, t, sys, "alias-parent", "readwrite")
|
||||
target := iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "alias-target", "")
|
||||
serviceCaller := iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "alias-caller", "")
|
||||
caller := serviceCaller
|
||||
other := iamAdminUser(ctx, t, sys, "alias-other", "readwrite")
|
||||
_ = iamAdminServiceAccount(ctx, t, sys, other.AccessKey, "alias-other-child", "")
|
||||
if kind == "sts" {
|
||||
var err error
|
||||
caller, err = auth.GetNewCredentialsWithMetadata(map[string]any{parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix()}, globalActiveCred.SecretKey)
|
||||
mustIAM(t, err)
|
||||
caller.ParentUser = parent.AccessKey
|
||||
_, err = sys.SetTempUser(ctx, caller.AccessKey, caller, "")
|
||||
mustIAM(t, err)
|
||||
}
|
||||
api := adminAPIHandlers{}
|
||||
for _, alias := range []string{"", caller.AccessKey, parent.AccessKey} {
|
||||
rec := iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-service-accounts?user="+alias, nil, nil, api.ListServiceAccounts)
|
||||
assertIAMAdminStatus(t, "list self alias", rec, 200)
|
||||
data, err := madmin.DecryptData(caller.SecretKey, rec.Body)
|
||||
mustIAM(t, err)
|
||||
var response madmin.ListServiceAccountsResp
|
||||
mustIAM(t, json.Unmarshal(data, &response))
|
||||
found := false
|
||||
for _, account := range response.Accounts {
|
||||
found = found || account.AccessKey == target.AccessKey
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("self alias %q did not list the parent's service accounts", alias)
|
||||
}
|
||||
|
||||
query := url.Values{"listType": {madmin.AccessKeyListAll}}
|
||||
if alias != "" {
|
||||
query.Set("users", alias)
|
||||
}
|
||||
bulk := iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-access-keys-bulk?"+query.Encode(), nil, nil, api.ListAccessKeysBulk)
|
||||
assertIAMAdminStatus(t, "list bulk self alias", bulk, 200)
|
||||
data, err = madmin.DecryptData(caller.SecretKey, bulk.Body)
|
||||
mustIAM(t, err)
|
||||
var listed map[string]madmin.ListAccessKeysResp
|
||||
mustIAM(t, json.Unmarshal(data, &listed))
|
||||
entry, ok := listed[parent.AccessKey]
|
||||
if len(listed) != 1 || !ok || len(entry.ServiceAccounts) != 2 {
|
||||
t.Fatalf("bulk self alias %q returned unexpected identities: %+v", alias, listed)
|
||||
}
|
||||
want := map[string]bool{target.AccessKey: true, serviceCaller.AccessKey: true}
|
||||
for _, account := range entry.ServiceAccounts {
|
||||
if !want[account.AccessKey] {
|
||||
t.Fatalf("bulk self alias %q returned unexpected or duplicate service account %s", alias, account.AccessKey)
|
||||
}
|
||||
delete(want, account.AccessKey)
|
||||
}
|
||||
if kind == "sts" {
|
||||
if len(entry.STSKeys) != 1 || entry.STSKeys[0].AccessKey != caller.AccessKey {
|
||||
t.Fatalf("bulk self alias %q returned unexpected STS accounts: %+v", alias, entry.STSKeys)
|
||||
}
|
||||
} else if len(entry.STSKeys) != 0 {
|
||||
t.Fatalf("service caller returned unexpected STS accounts: %+v", entry.STSKeys)
|
||||
}
|
||||
if bytes.Contains(data, []byte(serviceCaller.SecretKey)) || bytes.Contains(data, []byte(`"secretKey"`)) || bytes.Contains(data, []byte(`"sessionToken"`)) {
|
||||
t.Fatal("bulk listing returned credential secrets")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+177
-92
@@ -622,7 +622,11 @@ func (a adminAPIHandlers) TemporaryAccountInfo(w http.ResponseWriter, r *http.Re
|
||||
policiesNames = policySet.ToSlice()
|
||||
}
|
||||
|
||||
stsAccountPolicy = globalIAMSys.GetCombinedPolicy(policiesNames...)
|
||||
stsAccountPolicy, err = globalIAMSys.GetCombinedPolicy(policiesNames...)
|
||||
if err != nil {
|
||||
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
policyJSON, err := json.MarshalIndent(stsAccountPolicy, "", " ")
|
||||
@@ -714,8 +718,10 @@ func (a adminAPIHandlers) AddServiceAccount(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// In case of LDAP/OIDC we need to set `opts.claims` to ensure
|
||||
// it is associated with the LDAP/OIDC user properly.
|
||||
// The new credential's policy comes from the request, not the caller.
|
||||
for k, v := range cred.Claims {
|
||||
if k == expClaim {
|
||||
switch k {
|
||||
case expClaim, policy.SessionPolicyName, sessionPolicyNameExtracted, iamPolicyClaimNameSA():
|
||||
continue
|
||||
}
|
||||
opts.claims[k] = v
|
||||
@@ -855,10 +861,9 @@ func (a adminAPIHandlers) UpdateServiceAccount(w http.ResponseWriter, r *http.Re
|
||||
|
||||
// Permission checks:
|
||||
//
|
||||
// 1. Any type of account (i.e. access keys (previously/still called service
|
||||
// accounts), STS accounts, internal IDP accounts, etc) with the
|
||||
// policy.UpdateServiceAccountAdminAction permission can update any service
|
||||
// account.
|
||||
// 1. Updating requires policy.UpdateServiceAccountAdminAction. Under built-in
|
||||
// IAM, session-limited callers cannot update credentials, and only unrestricted
|
||||
// root credentials can manage root's service accounts.
|
||||
//
|
||||
// 2. We would like to let a user update their own access keys, however it
|
||||
// is currently blocked pending a re-design. Users are still able to delete
|
||||
@@ -874,6 +879,11 @@ func (a adminAPIHandlers) UpdateServiceAccount(w http.ResponseWriter, r *http.Re
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
if newGlobalAuthZPluginFn() == nil && (hasActiveSessionPolicy(cred) ||
|
||||
(svcAccount.ParentUser == globalActiveCred.AccessKey && !canManageRootCredentials(cred))) {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
var sp *policy.Policy
|
||||
if len(updateReq.NewPolicy) > 0 {
|
||||
@@ -951,23 +961,21 @@ func (a adminAPIHandlers) InfoServiceAccount(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if !globalIAMSys.IsAllowed(policy.Args{
|
||||
requestUser := cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
requestUser = cred.ParentUser
|
||||
}
|
||||
if !isAllowedServiceAccountAction(cred, policy.Args{
|
||||
AccountName: cred.AccessKey,
|
||||
Groups: cred.Groups,
|
||||
Action: policy.ListServiceAccountsAdminAction,
|
||||
ConditionValues: getConditionValues(r, "", cred),
|
||||
IsOwner: owner,
|
||||
Claims: cred.Claims,
|
||||
DenyOnly: requestUser == svcAccount.ParentUser,
|
||||
}) {
|
||||
requestUser := cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
requestUser = cred.ParentUser
|
||||
}
|
||||
|
||||
if requestUser != svcAccount.ParentUser {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
// if session policy is nil or empty, then it is implied policy
|
||||
@@ -983,7 +991,11 @@ func (a adminAPIHandlers) InfoServiceAccount(w http.ResponseWriter, r *http.Requ
|
||||
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
svcAccountPolicy = globalIAMSys.GetCombinedPolicy(policiesNames...)
|
||||
svcAccountPolicy, err = globalIAMSys.GetCombinedPolicy(policiesNames...)
|
||||
if err != nil {
|
||||
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
policyJSON, err := json.MarshalIndent(svcAccountPolicy, "", " ")
|
||||
@@ -1039,29 +1051,27 @@ func (a adminAPIHandlers) ListServiceAccounts(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
var targetAccount string
|
||||
|
||||
// If listing is requested for a specific user (who is not the request
|
||||
// sender), check that the user has permissions.
|
||||
selfUser := cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
selfUser = cred.ParentUser
|
||||
}
|
||||
user := r.Form.Get("user")
|
||||
if user != "" && user != cred.AccessKey {
|
||||
if !globalIAMSys.IsAllowed(policy.Args{
|
||||
AccountName: cred.AccessKey,
|
||||
Groups: cred.Groups,
|
||||
Action: policy.ListServiceAccountsAdminAction,
|
||||
ConditionValues: getConditionValues(r, "", cred),
|
||||
IsOwner: owner,
|
||||
Claims: cred.Claims,
|
||||
}) {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
targetAccount = user
|
||||
} else {
|
||||
targetAccount = cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
targetAccount = cred.ParentUser
|
||||
}
|
||||
selfOnly := user == "" || user == cred.AccessKey || user == selfUser
|
||||
if !isAllowedServiceAccountAction(cred, policy.Args{
|
||||
AccountName: cred.AccessKey,
|
||||
Groups: cred.Groups,
|
||||
Action: policy.ListServiceAccountsAdminAction,
|
||||
ConditionValues: getConditionValues(r, "", cred),
|
||||
IsOwner: owner,
|
||||
Claims: cred.Claims,
|
||||
DenyOnly: selfOnly,
|
||||
}) {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
targetAccount := user
|
||||
if selfOnly {
|
||||
targetAccount = selfUser
|
||||
}
|
||||
|
||||
serviceAccounts, err := globalIAMSys.ListServiceAccounts(ctx, targetAccount)
|
||||
@@ -1110,7 +1120,7 @@ func (a adminAPIHandlers) DeleteServiceAccount(w http.ResponseWriter, r *http.Re
|
||||
|
||||
// Get current object layer instance.
|
||||
objectAPI := newObjectLayerFn()
|
||||
if objectAPI == nil || globalNotificationSys == nil {
|
||||
if objectAPI == nil || globalNotificationSys == nil || !globalIAMSys.Initialized() {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL)
|
||||
return
|
||||
}
|
||||
@@ -1131,35 +1141,41 @@ func (a adminAPIHandlers) DeleteServiceAccount(w http.ResponseWriter, r *http.Re
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrInvalidArgument), r.URL)
|
||||
return
|
||||
}
|
||||
// We do not care if service account is readable or not at this point,
|
||||
// since this is a delete call we shall allow it to be deleted if possible.
|
||||
svcAccount, _, err := globalIAMSys.GetServiceAccount(ctx, serviceAccount)
|
||||
if errors.Is(err, errNoSuchServiceAccount) {
|
||||
// Deletion needs the stored identity, not a valid token or session policy.
|
||||
// Keep disabled, expired and damaged service accounts removable, and retain
|
||||
// their parent for authorization and the site replication hook below.
|
||||
u, ok := globalIAMSys.store.GetUser(serviceAccount)
|
||||
if !ok || !u.Credentials.IsServiceAccount() {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminServiceAccountNotFound), r.URL)
|
||||
return
|
||||
}
|
||||
svcAccount := u.Credentials
|
||||
|
||||
adminPrivilege := globalIAMSys.IsAllowed(policy.Args{
|
||||
parentUser := cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
parentUser = cred.ParentUser
|
||||
}
|
||||
isSelf := parentUser == svcAccount.ParentUser
|
||||
if !isAllowedServiceAccountAction(cred, policy.Args{
|
||||
AccountName: cred.AccessKey,
|
||||
Groups: cred.Groups,
|
||||
Action: policy.RemoveServiceAccountAdminAction,
|
||||
ConditionValues: getConditionValues(r, "", cred),
|
||||
IsOwner: owner,
|
||||
Claims: cred.Claims,
|
||||
})
|
||||
|
||||
if !adminPrivilege {
|
||||
parentUser := cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
parentUser = cred.ParentUser
|
||||
}
|
||||
if svcAccount.ParentUser != "" && parentUser != svcAccount.ParentUser {
|
||||
// The service account belongs to another user but return not
|
||||
// found error to mitigate brute force attacks. or the
|
||||
// serviceAccount doesn't exist.
|
||||
DenyOnly: isSelf,
|
||||
}) {
|
||||
if !isSelf {
|
||||
// Hide the existence of another user's service account.
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminServiceAccountNotFound), r.URL)
|
||||
return
|
||||
}
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
if newGlobalAuthZPluginFn() == nil && svcAccount.ParentUser == globalActiveCred.AccessKey && !canManageRootCredentials(cred) {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminServiceAccountNotFound), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
if err := globalIAMSys.DeleteServiceAccount(ctx, serviceAccount, true); err != nil {
|
||||
@@ -1229,7 +1245,7 @@ func (a adminAPIHandlers) ListAccessKeysBulk(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
|
||||
if !globalIAMSys.IsAllowed(policy.Args{
|
||||
if !isAllowedServiceAccountAction(cred, policy.Args{
|
||||
AccountName: cred.AccessKey,
|
||||
Groups: cred.Groups,
|
||||
Action: policy.ListServiceAccountsAdminAction,
|
||||
@@ -1242,12 +1258,12 @@ func (a adminAPIHandlers) ListAccessKeysBulk(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if selfOnly && len(users) == 0 {
|
||||
if selfOnly {
|
||||
selfUser := cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
selfUser = cred.ParentUser
|
||||
}
|
||||
users = append(users, selfUser)
|
||||
users = []string{selfUser}
|
||||
}
|
||||
|
||||
var checkedUserList []string
|
||||
@@ -1439,7 +1455,10 @@ func (a adminAPIHandlers) AccountInfoHandler(w http.ResponseWriter, r *http.Requ
|
||||
return buckets[i].Name < buckets[j].Name
|
||||
})
|
||||
} else {
|
||||
buckets, err = objectAPI.ListBuckets(ctx, BucketOptions{Cached: true})
|
||||
// Console refreshes this inventory immediately after bucket mutations.
|
||||
// A stale cached list can otherwise hide a creation or retain a deletion
|
||||
// indefinitely in the client's snapshot.
|
||||
buckets, err = objectAPI.ListBuckets(ctx, BucketOptions{})
|
||||
if err != nil {
|
||||
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
||||
return
|
||||
@@ -1455,6 +1474,7 @@ func (a adminAPIHandlers) AccountInfoHandler(w http.ResponseWriter, r *http.Requ
|
||||
roleArn := policy.Args{Claims: cred.Claims}.GetRoleArn()
|
||||
policySetFromClaims, hasPolicyClaim := policy.GetPoliciesFromClaims(cred.Claims, iamPolicyClaimNameOpenID())
|
||||
var effectivePolicy policy.Policy
|
||||
var policyErr error
|
||||
|
||||
var buf []byte
|
||||
switch {
|
||||
@@ -1478,10 +1498,10 @@ func (a adminAPIHandlers) AccountInfoHandler(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
policySlice := newMappedPolicy(policy).toSlice()
|
||||
effectivePolicy = globalIAMSys.GetCombinedPolicy(policySlice...)
|
||||
effectivePolicy, policyErr = globalIAMSys.GetCombinedPolicy(policySlice...)
|
||||
|
||||
case hasPolicyClaim:
|
||||
effectivePolicy = globalIAMSys.GetCombinedPolicy(policySetFromClaims.ToSlice()...)
|
||||
effectivePolicy, policyErr = globalIAMSys.GetCombinedPolicy(policySetFromClaims.ToSlice()...)
|
||||
|
||||
default:
|
||||
policies, err := globalIAMSys.PolicyDBGet(accountName, cred.Groups...)
|
||||
@@ -1490,7 +1510,11 @@ func (a adminAPIHandlers) AccountInfoHandler(w http.ResponseWriter, r *http.Requ
|
||||
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
effectivePolicy = globalIAMSys.GetCombinedPolicy(policies...)
|
||||
effectivePolicy, policyErr = globalIAMSys.GetCombinedPolicy(policies...)
|
||||
}
|
||||
if policyErr != nil {
|
||||
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, policyErr), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
buf, err = json.MarshalIndent(effectivePolicy, "", " ")
|
||||
@@ -2046,26 +2070,30 @@ func (a adminAPIHandlers) RevokeTokens(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
adminPrivilege := globalIAMSys.IsAllowed(policy.Args{
|
||||
parentUser := cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
parentUser = cred.ParentUser
|
||||
}
|
||||
isSelfTarget := isTokenSelfRevoke || user == parentUser
|
||||
if !isAllowedServiceAccountAction(cred, policy.Args{
|
||||
AccountName: cred.AccessKey,
|
||||
Groups: cred.Groups,
|
||||
Action: policy.RemoveServiceAccountAdminAction,
|
||||
ConditionValues: getConditionValues(r, "", cred),
|
||||
IsOwner: owner,
|
||||
Claims: cred.Claims,
|
||||
})
|
||||
|
||||
if !adminPrivilege || isTokenSelfRevoke {
|
||||
parentUser := cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
parentUser = cred.ParentUser
|
||||
}
|
||||
if !isTokenSelfRevoke && user != parentUser {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
DenyOnly: isSelfTarget,
|
||||
}) {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
if isSelfTarget {
|
||||
user = parentUser
|
||||
}
|
||||
if newGlobalAuthZPluginFn() == nil && user == globalActiveCred.AccessKey && !canManageRootCredentials(cred) {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
// Infer token revoke type from the request if requestor is STS.
|
||||
if isTokenSelfRevoke && tokenRevokeType == "" && !fullRevoke {
|
||||
@@ -2112,29 +2140,22 @@ func (a adminAPIHandlers) InfoAccessKey(w http.ResponseWriter, r *http.Request)
|
||||
u, ok := globalIAMSys.GetUser(ctx, accessKey)
|
||||
targetCred := u.Credentials
|
||||
|
||||
if !globalIAMSys.IsAllowed(policy.Args{
|
||||
requestUser := cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
requestUser = cred.ParentUser
|
||||
}
|
||||
isSelf := ok && targetCred.ParentUser != "" && requestUser == targetCred.ParentUser
|
||||
if !isAllowedServiceAccountAction(cred, policy.Args{
|
||||
AccountName: cred.AccessKey,
|
||||
Groups: cred.Groups,
|
||||
Action: policy.ListServiceAccountsAdminAction,
|
||||
ConditionValues: getConditionValues(r, "", cred),
|
||||
IsOwner: owner,
|
||||
Claims: cred.Claims,
|
||||
DenyOnly: isSelf,
|
||||
}) {
|
||||
// If requested user does not exist and requestor is not allowed to list service accounts, return access denied.
|
||||
if !ok {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
requestUser := cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
requestUser = cred.ParentUser
|
||||
}
|
||||
|
||||
if requestUser != targetCred.ParentUser {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||
return
|
||||
}
|
||||
|
||||
if !ok {
|
||||
@@ -2181,7 +2202,11 @@ func (a adminAPIHandlers) InfoAccessKey(w http.ResponseWriter, r *http.Request)
|
||||
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
svcAccountPolicy = globalIAMSys.GetCombinedPolicy(policiesNames...)
|
||||
svcAccountPolicy, err = globalIAMSys.GetCombinedPolicy(policiesNames...)
|
||||
if err != nil {
|
||||
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
policyJSON, err := json.MarshalIndent(svcAccountPolicy, "", " ")
|
||||
@@ -2908,6 +2933,52 @@ func addExpirationToCondValues(exp *time.Time, condValues map[string][]string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasActiveSessionPolicy follows the same credential dispatch as IAMSys.IsAllowed.
|
||||
// Expiring service accounts also satisfy IsTemp and are evaluated as STS there.
|
||||
// Callers use this only after authorization under built-in IAM.
|
||||
func hasActiveSessionPolicy(cred auth.Credentials) bool {
|
||||
args := policy.Args{Claims: cred.Claims}
|
||||
if cred.IsTemp() {
|
||||
hasSP, _ := isAllowedBySessionPolicy(args)
|
||||
return hasSP
|
||||
}
|
||||
if cred.IsServiceAccount() {
|
||||
if cred.Claims[iamPolicyClaimNameSA()] == inheritedPolicyType {
|
||||
return false
|
||||
}
|
||||
hasSP, _ := isAllowedBySessionPolicyForServiceAccount(args)
|
||||
return hasSP
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// canManageRootCredentials is an additional guard after built-in IAM authorization.
|
||||
// The signature's owner flag is insufficient: historical empty session policies
|
||||
// clear it even though the service account still inherits root's permissions.
|
||||
func canManageRootCredentials(cred auth.Credentials) bool {
|
||||
return cred.AccessKey == globalActiveCred.AccessKey ||
|
||||
(cred.ParentUser == globalActiveCred.AccessKey && cred.AccessKey != siteReplicatorSvcAcc && !hasActiveSessionPolicy(cred))
|
||||
}
|
||||
|
||||
// isAllowedServiceAccountAction preserves regular users' self inspection and
|
||||
// cleanup even without a policy attachment, while enforcing explicit denies.
|
||||
// Derived credentials and external authorization plugins use full IAM evaluation.
|
||||
func isAllowedServiceAccountAction(cred auth.Credentials, args policy.Args) bool {
|
||||
if args.DenyOnly && !args.IsOwner && !cred.IsTemp() && !cred.IsServiceAccount() && newGlobalAuthZPluginFn() == nil {
|
||||
policies, err := globalIAMSys.PolicyDBGet(args.AccountName, args.Groups...)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
combined, err := globalIAMSys.GetCombinedPolicy(policies...)
|
||||
if err != nil {
|
||||
iamLogIf(GlobalContext, err)
|
||||
return false
|
||||
}
|
||||
return combined.IsAllowed(args)
|
||||
}
|
||||
return globalIAMSys.IsAllowed(args)
|
||||
}
|
||||
|
||||
func commonAddServiceAccount(r *http.Request, ldap bool) (context.Context, auth.Credentials, newServiceAccountOpts, madmin.AddServiceAccountReq, string, APIError) {
|
||||
ctx := r.Context()
|
||||
|
||||
@@ -2996,6 +3067,14 @@ func commonAddServiceAccount(r *http.Request, ldap bool) (context.Context, auth.
|
||||
}) {
|
||||
return ctx, auth.Credentials{}, newServiceAccountOpts{}, madmin.AddServiceAccountReq{}, "", errorCodes.ToAPIErr(ErrAccessDenied)
|
||||
}
|
||||
effectiveTarget := targetUser
|
||||
if (targetUser == cred.AccessKey || targetUser == cred.ParentUser) && cred.ParentUser != "" {
|
||||
effectiveTarget = cred.ParentUser
|
||||
}
|
||||
if newGlobalAuthZPluginFn() == nil && (hasActiveSessionPolicy(cred) ||
|
||||
(effectiveTarget == globalActiveCred.AccessKey && !canManageRootCredentials(cred))) {
|
||||
return ctx, auth.Credentials{}, newServiceAccountOpts{}, madmin.AddServiceAccountReq{}, "", errorCodes.ToAPIErr(ErrAccessDenied)
|
||||
}
|
||||
|
||||
var sp *policy.Policy
|
||||
if len(createReq.Policy) > 0 {
|
||||
@@ -3003,6 +3082,12 @@ func commonAddServiceAccount(r *http.Request, ldap bool) (context.Context, auth.
|
||||
if err != nil {
|
||||
return ctx, auth.Credentials{}, newServiceAccountOpts{}, madmin.AddServiceAccountReq{}, "", toAdminAPIErr(ctx, err)
|
||||
}
|
||||
// Empty service policies mean inheritance, including for expiring keys.
|
||||
// Normalize the replication request as well as the local credential.
|
||||
if sp.Version == "" && sp.Statements == nil && sp.ID == "" {
|
||||
sp = nil
|
||||
createReq.Policy = nil
|
||||
}
|
||||
}
|
||||
|
||||
opts.sessionPolicy = sp
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
//
|
||||
// This file is part of SILO Object Storage.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/hash"
|
||||
)
|
||||
|
||||
func TestChecksumMismatchAPIMessage(t *testing.T) {
|
||||
for _, algorithm := range []string{"CRC32", "CRC32C", "SHA1", "SHA256", "CRC64NVME", ""} {
|
||||
t.Run(algorithm, func(t *testing.T) {
|
||||
mismatch := hash.ChecksumMismatch{Algorithm: algorithm, Want: "expected", Got: "actual"}
|
||||
want := errorCodes.ToAPIErr(ErrContentChecksumMismatch).Description
|
||||
if algorithm != "" {
|
||||
want = fmt.Sprintf("The %s you specified did not match the calculated checksum.", algorithm)
|
||||
}
|
||||
for _, err := range []error{mismatch, fmt.Errorf("upload: %w", mismatch)} {
|
||||
got := toAPIError(t.Context(), err)
|
||||
if got.HTTPStatusCode != http.StatusBadRequest || got.Code != "XAmzContentChecksumMismatch" || got.Description != want {
|
||||
t.Fatalf("toAPIError(%v) = %+v, want 400 XAmzContentChecksumMismatch: %s", err, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -450,7 +450,7 @@ const (
|
||||
ErrAdminNoSecretKey
|
||||
|
||||
ErrIAMNotInitialized
|
||||
ErrMultipartListingLegacy
|
||||
ErrMultipartListingLegacy // Reserved: legacy uploads no longer block listing.
|
||||
ErrMultipartListingIdentity
|
||||
ErrSlowDown
|
||||
|
||||
@@ -550,7 +550,7 @@ var errorCodes = errorCodeMap{
|
||||
},
|
||||
ErrBadDigest: {
|
||||
Code: "BadDigest",
|
||||
Description: "The Content-Md5 you specified did not match what we received.",
|
||||
Description: "The Content-MD5 you specified did not match what we received.",
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
ErrEntityTooSmall: {
|
||||
@@ -2191,8 +2191,6 @@ func toAPIErrorCode(ctx context.Context, err error) (apiErr APIErrorCode) {
|
||||
err = unwrapAll(err)
|
||||
|
||||
switch err {
|
||||
case errMultipartListingLegacy:
|
||||
apiErr = ErrMultipartListingLegacy
|
||||
case errMultipartListingIdentity:
|
||||
apiErr = ErrMultipartListingIdentity
|
||||
case errCompleteMultipartChecksumMismatch, errCompleteMultipartChecksumTypeMismatch:
|
||||
@@ -2505,6 +2503,14 @@ func toAPIError(ctx context.Context, err error) APIError {
|
||||
case errors.Is(err, errMissingPartChecksum):
|
||||
apiErr.Description = strings.TrimPrefix(err.Error(), errMissingPartChecksum.Error()+": ")
|
||||
}
|
||||
// PutObject/UploadPart checksum mismatches name the algorithm in the
|
||||
// message, matching S3 (e.g. "The CRC32 you specified did not match the
|
||||
// calculated checksum."). Falls through to the generic table description
|
||||
// when the algorithm is unknown.
|
||||
var checksumMismatch hash.ChecksumMismatch
|
||||
if errors.As(err, &checksumMismatch) && checksumMismatch.Algorithm != "" {
|
||||
apiErr.Description = fmt.Sprintf("The %s you specified did not match the calculated checksum.", checksumMismatch.Algorithm)
|
||||
}
|
||||
switch apiErr.Code {
|
||||
case "NotImplemented":
|
||||
apiErr = APIError{
|
||||
|
||||
@@ -28,13 +28,10 @@ import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
objectlock "github.com/minio/minio/internal/bucket/object/lock"
|
||||
"github.com/minio/minio/internal/etag"
|
||||
"github.com/minio/minio/internal/hash"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
@@ -733,48 +730,6 @@ func setAuthMiddleware(h http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func isPutRetentionAllowed(bucketName, objectName string, retDays int, retDate time.Time, retMode objectlock.RetMode, byPassSet bool, r *http.Request, cred auth.Credentials, owner bool) (s3Err APIErrorCode) {
|
||||
var retSet bool
|
||||
if cred.AccessKey == "" {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
conditions := getConditionValues(r, "", cred)
|
||||
conditions["object-lock-mode"] = []string{string(retMode)}
|
||||
conditions["object-lock-retain-until-date"] = []string{retDate.UTC().Format(time.RFC3339)}
|
||||
if retDays > 0 {
|
||||
conditions["object-lock-remaining-retention-days"] = []string{strconv.Itoa(retDays)}
|
||||
}
|
||||
if retMode == objectlock.RetGovernance && byPassSet {
|
||||
byPassSet = globalIAMSys.IsAllowed(policy.Args{
|
||||
AccountName: cred.AccessKey,
|
||||
Groups: cred.Groups,
|
||||
Action: policy.BypassGovernanceRetentionAction,
|
||||
BucketName: bucketName,
|
||||
ObjectName: objectName,
|
||||
ConditionValues: conditions,
|
||||
IsOwner: owner,
|
||||
Claims: cred.Claims,
|
||||
})
|
||||
}
|
||||
if globalIAMSys.IsAllowed(policy.Args{
|
||||
AccountName: cred.AccessKey,
|
||||
Groups: cred.Groups,
|
||||
Action: policy.PutObjectRetentionAction,
|
||||
BucketName: bucketName,
|
||||
ConditionValues: conditions,
|
||||
ObjectName: objectName,
|
||||
IsOwner: owner,
|
||||
Claims: cred.Claims,
|
||||
}) {
|
||||
retSet = true
|
||||
}
|
||||
if byPassSet || retSet {
|
||||
return ErrNone
|
||||
}
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// isPutActionAllowed - check if PUT operation is allowed on the resource, this
|
||||
// call verifies bucket policies and IAM policies, supports multi user
|
||||
// checks etc.
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/crypto"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
// A pull into a suspended bucket replaces the null version. Its policy
|
||||
// decision must name that version even when the source is unversioned S3.
|
||||
func TestBatchReviewPullImplicitNullVersionAuthorization(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
_, err := globalBucketMetadataSys.Update(f.ctx, f.bucket, bucketVersioningConfig, []byte(`<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Suspended</Status></VersioningConfiguration>`))
|
||||
mustIAM(t, err)
|
||||
data := []byte("original null version")
|
||||
before, err := f.obj.PutObject(f.ctx, f.bucket, "pull/null", mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{VersionSuspended: true})
|
||||
mustIAM(t, err)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "null-deny", batchAuthzFullAccess+`,
|
||||
{"Effect":"Deny","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"s3:versionid":"null"}}}`)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "null-deny-user", "null-deny")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
job := batchPullJob(f.bucket)
|
||||
job.Source.Type = BatchJobReplicateResourceS3
|
||||
err = job.ReplicateFromSource(f.ctx, f.obj, batchLockRemote(t, "replacement"), ObjectInfo{
|
||||
Name: before.Name, ModTime: UTCNow(), ETag: "batchetag", UserDefined: map[string]string{},
|
||||
}, false, sub)
|
||||
if !isBatchJobAuthzError(err) {
|
||||
t.Errorf("null version policy must refuse the write, got %v", err)
|
||||
}
|
||||
after, gerr := f.obj.GetObjectInfo(f.ctx, f.bucket, before.Name, ObjectOptions{VersionID: nullVersionID})
|
||||
if gerr != nil || before.ETag != after.ETag {
|
||||
t.Errorf("denied null version must remain unchanged: read=%v unchanged=%v", gerr, before.ETag == after.ETag)
|
||||
}
|
||||
}
|
||||
|
||||
type batchReviewPrefixWalkFailure struct{ ObjectLayer }
|
||||
|
||||
func (a batchReviewPrefixWalkFailure) Walk(ctx context.Context, bucket, prefix string, results chan<- itemOrErr[ObjectInfo], opts WalkOptions) error {
|
||||
if prefix != "a/" {
|
||||
return a.ObjectLayer.Walk(ctx, bucket, prefix, results, opts)
|
||||
}
|
||||
go func() {
|
||||
defer close(results)
|
||||
results <- itemOrErr[ObjectInfo]{Err: errors.New("test: incomplete first prefix")}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
type batchReviewWalkCutoff struct {
|
||||
ObjectLayer
|
||||
cutoff string
|
||||
}
|
||||
|
||||
func (a batchReviewWalkCutoff) Walk(ctx context.Context, bucket, prefix string, results chan<- itemOrErr[ObjectInfo], opts WalkOptions) error {
|
||||
inner := make(chan itemOrErr[ObjectInfo], 1)
|
||||
if err := a.ObjectLayer.Walk(ctx, bucket, prefix, inner, opts); err != nil {
|
||||
close(results)
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
defer close(results)
|
||||
stopped := false
|
||||
for result := range inner {
|
||||
if stopped {
|
||||
continue
|
||||
}
|
||||
if result.Err == nil && result.Item.Name >= a.cutoff {
|
||||
stopped = true
|
||||
result = itemOrErr[ObjectInfo]{Err: errors.New("test: listing stopped before remaining child keys")}
|
||||
}
|
||||
select {
|
||||
case results <- result:
|
||||
case <-ctx.Done():
|
||||
stopped = true
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Listing a directory prefix returns the directory object before its child
|
||||
// keys. A durable cursor for that directory must preserve unfinished children.
|
||||
func TestBatchReviewDirectoryCursorKeepsChildrenPending(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
for range 12 {
|
||||
batchPutVersion(t, f.obj, f.bucket, "a/", nil)
|
||||
}
|
||||
var pending string
|
||||
for i := 0; i < 13; i++ {
|
||||
older := f.putVersions(t, fmt.Sprintf("a/key-%02d", i))
|
||||
if i == 11 {
|
||||
pending = older
|
||||
}
|
||||
}
|
||||
rec, job := f.submit(t, globalActiveCred, fmt.Sprintf(`expire:
|
||||
apiVersion: v1
|
||||
bucket: %s
|
||||
prefix: ["a/"]
|
||||
rules:
|
||||
- type: object
|
||||
purge:
|
||||
retainVersions: 1
|
||||
`, f.bucket))
|
||||
if job == nil {
|
||||
t.Fatalf("submit: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
_ = job.Expire.Start(f.ctx, batchReviewWalkCutoff{ObjectLayer: f.obj, cutoff: "a/key-11"}, *job)
|
||||
ri := f.jobInfo(t, job)
|
||||
if ri.Object != "a/" {
|
||||
t.Fatalf("control: the completed directory batch must be the cursor, got %q", ri.Object)
|
||||
}
|
||||
mustIAM(t, job.Expire.Start(f.ctx, f.obj, *job))
|
||||
if f.versionExists(t, "a/key-11", pending) {
|
||||
t.Error("a directory cursor must not suppress the unvisited child key on resume")
|
||||
}
|
||||
}
|
||||
|
||||
// Later prefixes must not advance the completion cursor past an earlier
|
||||
// prefix whose listing failed. This state is produced by Start itself.
|
||||
func TestBatchReviewExpireFailedPrefixKeepsWorkPending(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
older := f.putVersions(t, "a/todo")
|
||||
for i := 0; i < 13; i++ {
|
||||
f.putVersions(t, fmt.Sprintf("z/key-%02d", i))
|
||||
}
|
||||
rec, job := f.submit(t, globalActiveCred, fmt.Sprintf(`expire:
|
||||
apiVersion: v1
|
||||
bucket: %s
|
||||
prefix: ["z/", "a/"]
|
||||
rules:
|
||||
- type: object
|
||||
purge:
|
||||
retainVersions: 1
|
||||
`, f.bucket))
|
||||
if job == nil {
|
||||
t.Fatalf("submit: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
_ = job.Expire.Start(f.ctx, batchReviewPrefixWalkFailure{f.obj}, *job)
|
||||
ri := f.jobInfo(t, job)
|
||||
if ri.Object >= "a/todo" {
|
||||
t.Errorf("cursor must not cross an incomplete prefix, got %q", ri.Object)
|
||||
}
|
||||
mustIAM(t, job.Expire.Start(f.ctx, f.obj, *job))
|
||||
if f.versionExists(t, "a/todo", older) {
|
||||
t.Error("a fresh Start must revisit the key omitted by the failed listing")
|
||||
}
|
||||
}
|
||||
|
||||
// A repeated marker replication error must remain a failure on the second
|
||||
// attempt, including after the retry loop resets its counters.
|
||||
func TestBatchReviewMarkerRetryRemainsFailed(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchPutVersion(t, f.obj, f.bucket, "marker", nil)
|
||||
_, err := f.obj.DeleteObject(f.ctx, f.bucket, "marker", ObjectOptions{Versioned: true})
|
||||
mustIAM(t, err)
|
||||
oldTransport := globalRemoteTargetTransport
|
||||
globalRemoteTargetTransport = batchLockTransport(func(r *http.Request) (*http.Response, error) {
|
||||
code, body := http.StatusForbidden, `<Error><Code>AccessDenied</Code><Message>test refusal</Message></Error>`
|
||||
if r.URL.Query().Has("location") {
|
||||
code, body = http.StatusOK, `<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">us-east-1</LocationConstraint>`
|
||||
}
|
||||
return &http.Response{StatusCode: code, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
|
||||
})
|
||||
t.Cleanup(func() { globalRemoteTargetTransport = oldTransport })
|
||||
job := BatchJobRequest{
|
||||
ID: "replicate-marker-retry" + getKeySeparator() + "0", Started: UTCNow(), Submitter: batchRootSubmitter(t),
|
||||
Replicate: &BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: f.bucket},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: "remote", Endpoint: "https://target.invalid", Creds: BatchJobReplicateCredentials{AccessKey: "test-access", SecretKey: "test-secret"}},
|
||||
},
|
||||
}
|
||||
job.Replicate.Flags.Retry.Attempts = 2
|
||||
job.Replicate.Flags.Retry.Delay = time.Millisecond
|
||||
mustIAM(t, job.Replicate.Start(f.ctx, f.obj, job))
|
||||
ri := f.jobInfo(t, &job)
|
||||
if ri.Complete || !ri.Failed || ri.DeleteMarkersFailed != 1 {
|
||||
t.Errorf("both marker attempts failed: complete=%v failed=%v markerFailures=%d attempts=%d", ri.Complete, ri.Failed, ri.DeleteMarkersFailed, ri.RetryAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// A current parent mapping suppresses the OpenID policy-name fallback.
|
||||
// Background-condition validation must inspect the same effective policies.
|
||||
func TestBatchReviewMappedPolicySuppressesClaimFallback(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "mapped-full", batchAuthzFullAccess)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "unused-claim-policy", `{"Effect":"Deny","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}`)
|
||||
parent := iamAdminUser(f.ctx, t, f.sys, "mapped-parent", "mapped-full")
|
||||
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{
|
||||
parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix(), iamPolicyClaimNameOpenID(): "unused-claim-policy",
|
||||
}, globalActiveCred.SecretKey)
|
||||
mustIAM(t, err)
|
||||
cred.ParentUser = parent.AccessKey
|
||||
_, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "")
|
||||
mustIAM(t, err)
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
p, err := sub.principal(f.ctx)
|
||||
mustIAM(t, err)
|
||||
req := batchAuthzRequest{action: policy.GetObjectAction, bucket: f.bucket, object: "mapped/key", versionID: mustGetUUID()}
|
||||
if !f.sys.isAllowedLocally(policy.Args{AccountName: p.accessKey, Claims: p.claims, Action: req.action, BucketName: req.bucket, ObjectName: req.object, ConditionValues: p.conditionValues(req)}) {
|
||||
t.Fatal("control: the current mapped policy must allow this read")
|
||||
}
|
||||
if err := sub.authorize(f.ctx, req); err != nil {
|
||||
t.Errorf("an unused fallback policy must not refuse a mapped-policy grant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchReviewBindingAndExpiringServiceControls(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
root := batchRootSubmitter(t)
|
||||
priorRoot := globalActiveCred
|
||||
globalActiveCred.SecretKey = "different-test-root-secret"
|
||||
_, err := root.principal(f.ctx)
|
||||
globalActiveCred = priorRoot
|
||||
if !isBatchJobAuthzError(err) {
|
||||
t.Errorf("root-secret change must invalidate the binding: %v", err)
|
||||
}
|
||||
missing := *root
|
||||
missing.Binding = nil
|
||||
if _, err := missing.principal(f.ctx); !isBatchJobAuthzError(err) {
|
||||
t.Errorf("missing fingerprint must refuse the principal: %v", err)
|
||||
}
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "service-parent-full", batchAuthzFullAccess)
|
||||
parent := iamAdminUser(f.ctx, t, f.sys, "service-parent", "service-parent-full")
|
||||
sp, err := policy.ParseConfig(strings.NewReader(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/*"]}]}`))
|
||||
mustIAM(t, err)
|
||||
expires := UTCNow().Add(time.Hour)
|
||||
cred, _, err := f.sys.NewServiceAccount(f.ctx, parent.AccessKey, nil, newServiceAccountOpts{sessionPolicy: sp, expiration: &expires})
|
||||
mustIAM(t, err)
|
||||
stored, ok := f.sys.GetUser(f.ctx, cred.AccessKey)
|
||||
if !ok {
|
||||
t.Fatal("created service account must resolve")
|
||||
}
|
||||
cred = stored.Credentials
|
||||
if !cred.IsTemp() || !cred.IsServiceAccount() {
|
||||
t.Fatal("fixture must be an expiring service account")
|
||||
}
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.GetObjectAction, bucket: f.bucket, object: "allowed"}); err != nil {
|
||||
t.Errorf("expiring service account must retain its allowed read: %v", err)
|
||||
}
|
||||
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.DeleteObjectAction, bucket: f.bucket, object: "allowed"}); !isBatchJobAuthzError(err) {
|
||||
t.Errorf("expiring service account must remain session-policy restricted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An interrupted cross-pool copy can leave the same version in both pools.
|
||||
// A newer legal-hold field in either copy must prevent key rotation.
|
||||
func TestBatchReviewRotationUsesAuthoritativePoolHold(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
const name = "rotation/split-hold"
|
||||
previousKMS := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("batch-rotation-key")
|
||||
t.Cleanup(func() { GlobalKMS = previousKMS })
|
||||
dek, err := GlobalKMS.GenerateKey(t.Context(), &kms.GenerateKeyRequest{AssociatedData: kms.Context{bucket: bucket + "/" + name}})
|
||||
mustIAM(t, err)
|
||||
objectKey := crypto.GenerateKey(dek.Plaintext, nil)
|
||||
sealed := objectKey.Seal(dek.Plaintext, crypto.GenerateIV(nil), crypto.S3.String(), bucket, name)
|
||||
first := crypto.S3.CreateMetadata(nil, dek.KeyID, dek.Ciphertext, sealed)
|
||||
first[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = "OFF"
|
||||
first[ReservedMetadataPrefixLower+ObjectLockLegalHoldTimestamp] = UTCNow().Add(-time.Hour).Format(time.RFC3339Nano)
|
||||
second := maps.Clone(first)
|
||||
second[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = "ON"
|
||||
second[ReservedMetadataPrefixLower+ObjectLockLegalHoldTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
ciphertext, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader("protected payload"), objectKey))
|
||||
mustIAM(t, err)
|
||||
before := putConsistencyObject(t, z, bucket, name, 0, string(ciphertext), ObjectOptions{Versioned: true, UserDefined: first})
|
||||
putConsistencyObject(t, z, bucket, name, 1, string(ciphertext), ObjectOptions{Versioned: true, VersionID: before.VersionID, MTime: before.ModTime, UserDefined: second})
|
||||
logical, err := z.replicaObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if !enforceRetentionForDeletion(t.Context(), logical) {
|
||||
t.Fatal("control: the authoritative logical version must be held")
|
||||
}
|
||||
r := BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: sses3}}
|
||||
err = r.KeyRotate(t.Context(), z, before, batchRootSubmitter(t))
|
||||
if !isErrObjectLocked(err) {
|
||||
t.Errorf("rotation must refuse the authoritative legal hold, got %v", err)
|
||||
}
|
||||
after, gerr := z.serverPools[0].GetObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, gerr)
|
||||
if after.UserDefined[crypto.MetaSealedKeyS3] != before.UserDefined[crypto.MetaSealedKeyS3] {
|
||||
t.Error("held logical version must not have its key rewritten")
|
||||
}
|
||||
}
|
||||
|
||||
// An encryption condition must describe what rotation actually commits,
|
||||
// even when the requested encryption type differs from the existing type.
|
||||
func TestBatchReviewRotationEncryptionConditionMatchesResult(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
previousKMS := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("required-key")
|
||||
t.Cleanup(func() { GlobalKMS = previousKMS })
|
||||
const name = "rotation/encryption-condition"
|
||||
meta := make(map[string]string)
|
||||
key, err := newEncryptMetadata(f.ctx, crypto.S3, "", nil, f.bucket, name, meta, nil)
|
||||
mustIAM(t, err)
|
||||
body, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader("encrypted payload"), key))
|
||||
mustIAM(t, err)
|
||||
before, err := f.obj.PutObject(f.ctx, f.bucket, name, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{Versioned: true, UserDefined: meta})
|
||||
mustIAM(t, err)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "kms-write-only", `
|
||||
{"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"]},
|
||||
{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"s3:x-amz-server-side-encryption":"aws:kms","s3:x-amz-server-side-encryption-aws-kms-key-id":"required-key"}}}`)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "kms-write-user", "kms-write-only")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
r := BatchJobKeyRotateV1{Bucket: f.bucket, Encryption: BatchJobKeyRotateEncryption{Type: ssekms, Key: "required-key"}}
|
||||
err = r.KeyRotate(f.ctx, f.obj, before, sub)
|
||||
after, gerr := f.obj.GetObjectInfo(f.ctx, f.bucket, name, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, gerr)
|
||||
if err == nil && !crypto.S3KMS.IsEncrypted(after.UserDefined) {
|
||||
t.Error("rotation allowed by an aws:kms-only write policy must produce SSE-KMS or refuse the operation")
|
||||
}
|
||||
if err != nil && before.UserDefined[crypto.MetaSealedKeyS3] != after.UserDefined[crypto.MetaSealedKeyS3] {
|
||||
t.Error("a refused rotation must leave the version unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit null marker collision must retain a held null data version.
|
||||
func TestBatchReviewPullMarkerNullLock(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
meta := batchLockMeta("LEGALHOLD")
|
||||
before, err := f.obj.PutObject(f.ctx, f.bucket, "pull/null", mustGetPutObjReader(t, strings.NewReader("held null"), 9, "", ""), ObjectOptions{VersionID: nullVersionID, Versioned: true, UserDefined: meta})
|
||||
mustIAM(t, err)
|
||||
job := BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: "remote"},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: f.bucket},
|
||||
}
|
||||
err = job.ReplicateFromSource(f.ctx, f.obj, nil, ObjectInfo{
|
||||
Name: before.Name, VersionID: nullVersionID, ModTime: UTCNow(), DeleteMarker: true,
|
||||
}, false, batchRootSubmitter(t))
|
||||
if !isErrObjectLocked(err) {
|
||||
t.Errorf("marker collision with protected null data must return ObjectLocked, got %v", err)
|
||||
}
|
||||
if !batchVersionExists(t, f.obj, f.bucket, before.Name, nullVersionID) {
|
||||
t.Fatal("protected null data must remain present")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
// A read policy must use the newest tag state of the logical version even
|
||||
// when an interrupted movement left an older copy in the preferred pool.
|
||||
func TestBatchReviewPushUsesAuthoritativePoolTags(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
f := newBatchAuthzFixture(t)
|
||||
const name = "push/tagged"
|
||||
first := map[string]string{
|
||||
xhttp.AmzObjectTagging: "classification=public",
|
||||
ReservedMetadataPrefixLower + TaggingTimestamp: UTCNow().Add(-time.Hour).Format(time.RFC3339Nano),
|
||||
}
|
||||
second := map[string]string{
|
||||
xhttp.AmzObjectTagging: "classification=private",
|
||||
ReservedMetadataPrefixLower + TaggingTimestamp: UTCNow().Format(time.RFC3339Nano),
|
||||
}
|
||||
before := putConsistencyObject(t, z, bucket, name, 0, "test payload", ObjectOptions{Versioned: true, UserDefined: first})
|
||||
putConsistencyObject(t, z, bucket, name, 1, "test payload", ObjectOptions{Versioned: true, VersionID: before.VersionID, MTime: before.ModTime, UserDefined: second})
|
||||
logical, err := z.replicaObjectInfo(f.ctx, bucket, name, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if logical.UserTags != "classification=private" {
|
||||
t.Fatal("control: logical version must have the newer private tag")
|
||||
}
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "pooled-push", fmt.Sprintf(`
|
||||
{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::%s/*"]},
|
||||
{"Effect":"Deny","Action":["s3:GetObject","s3:GetObjectVersion"],"Resource":["arn:aws:s3:::%s/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/classification":"private"}}}`, bucket, bucket))
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "pooled-push-user", "pooled-push")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.GetObjectAction, bucket: bucket, object: name, versionID: before.VersionID, tags: logical.UserTags}); !isBatchJobAuthzError(err) {
|
||||
t.Fatal("control: authoritative tags must deny the read")
|
||||
}
|
||||
writes := 0
|
||||
target, err := minio.NewCore("target.invalid", &minio.Options{
|
||||
Region: "us-east-1", Creds: credentials.NewStaticV4("test-access", "test-secret", ""),
|
||||
Transport: batchLockTransport(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
if r.Method == http.MethodPut {
|
||||
writes++
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Etag": {"\"test-etag\""}}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
|
||||
}),
|
||||
})
|
||||
mustIAM(t, err)
|
||||
r := BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: bucket},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: "remote"},
|
||||
}
|
||||
err = r.ReplicateToTarget(f.ctx, z, target, before, false, sub)
|
||||
if !isBatchJobAuthzError(err) || writes != 0 {
|
||||
t.Errorf("newer authoritative tags must deny push: err=%v writes=%d", err, writes)
|
||||
}
|
||||
}
|
||||
|
||||
// Verified token claims must not replace the actual version being authorized.
|
||||
func TestBatchReviewVersionConditionResistsClaimCollision(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
deniedVersion := mustGetUUID()
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "version-claims", batchAuthzFullAccess+fmt.Sprintf(`,
|
||||
{"Effect":"Deny","Action":["s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"s3:versionid":"%s"}}}`, deniedVersion))
|
||||
parent := iamAdminUser(f.ctx, t, f.sys, "version-claims-parent", "version-claims")
|
||||
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{
|
||||
parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix(), "versionid": "an-unrelated-claim-value",
|
||||
}, globalActiveCred.SecretKey)
|
||||
mustIAM(t, err)
|
||||
cred.ParentUser = parent.AccessKey
|
||||
_, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "")
|
||||
mustIAM(t, err)
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
req := batchAuthzRequest{action: policy.DeleteObjectVersionAction, bucket: f.bucket, object: "data", versionID: deniedVersion}
|
||||
if err := sub.authorize(f.ctx, req); !isBatchJobAuthzError(err) {
|
||||
t.Errorf("a token claim must not replace the actual denied version: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry totals describe a pass, including markers already successful before
|
||||
// another item caused that pass to retry.
|
||||
func TestBatchReviewMarkerSuccessCountResetsOnRetry(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchPutVersion(t, f.obj, f.bucket, "marker", nil)
|
||||
_, err := f.obj.DeleteObject(f.ctx, f.bucket, "marker", ObjectOptions{Versioned: true})
|
||||
mustIAM(t, err)
|
||||
oldTransport := remoteInstanceTransport
|
||||
remoteInstanceTransport = atomic.Value{}
|
||||
setRemoteInstanceTransport(batchLockTransport(func(r *http.Request) (*http.Response, error) {
|
||||
code, body := http.StatusForbidden, `<Error><Code>AccessDenied</Code><Message>test refusal</Message></Error>`
|
||||
h := make(http.Header)
|
||||
switch {
|
||||
case r.URL.Query().Has("location"):
|
||||
code, body = http.StatusOK, `<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">us-east-1</LocationConstraint>`
|
||||
case r.Method == http.MethodDelete:
|
||||
code, body = http.StatusNoContent, ""
|
||||
case r.Method == http.MethodHead && r.URL.Query().Get("versionId") != "":
|
||||
code, body = http.StatusMethodNotAllowed, ""
|
||||
h.Set("X-Amz-Delete-Marker", "true")
|
||||
}
|
||||
return &http.Response{StatusCode: code, Header: h, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
|
||||
}))
|
||||
t.Cleanup(func() { remoteInstanceTransport = oldTransport })
|
||||
job := BatchJobRequest{
|
||||
ID: "marker-success-retry" + getKeySeparator() + "0", Started: UTCNow(), Submitter: batchRootSubmitter(t),
|
||||
Replicate: &BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: f.bucket},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: "remote", Endpoint: "https://target.invalid", Creds: BatchJobReplicateCredentials{AccessKey: "test-access", SecretKey: "test-secret"}},
|
||||
},
|
||||
}
|
||||
job.Replicate.Flags.Retry.Attempts = 2
|
||||
job.Replicate.Flags.Retry.Delay = time.Millisecond
|
||||
mustIAM(t, job.Replicate.Start(f.ctx, f.obj, job))
|
||||
ri := f.jobInfo(t, &job)
|
||||
if !ri.Failed || ri.RetryAttempts != 2 {
|
||||
t.Fatalf("control: data failure must cause two attempts: failed=%v attempts=%d", ri.Failed, ri.RetryAttempts)
|
||||
}
|
||||
if ri.DeleteMarkers != 1 {
|
||||
t.Errorf("one successful marker must be counted once in the final attempt, got %d", ri.DeleteMarkers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
// A tag removal in a newer copy must govern both the authorization and the
|
||||
// metadata sent by a push. The submitter can read data but cannot read tags.
|
||||
func TestBatchReviewPushSendsOnlyAuthorizedMetadata(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
f := newBatchAuthzFixture(t)
|
||||
const name = "push/removed-tags"
|
||||
first := map[string]string{
|
||||
xhttp.AmzObjectTagging: "classification=private",
|
||||
ReservedMetadataPrefixLower + TaggingTimestamp: UTCNow().Add(-time.Hour).Format(time.RFC3339Nano),
|
||||
}
|
||||
second := map[string]string{
|
||||
xhttp.AmzObjectTagging: "",
|
||||
ReservedMetadataPrefixLower + TaggingTimestamp: UTCNow().Format(time.RFC3339Nano),
|
||||
}
|
||||
before := putConsistencyObject(t, z, bucket, name, 0, "test payload", ObjectOptions{Versioned: true, UserDefined: first})
|
||||
putConsistencyObject(t, z, bucket, name, 1, "test payload", ObjectOptions{Versioned: true, VersionID: before.VersionID, MTime: before.ModTime, UserDefined: second})
|
||||
logical, err := z.replicaObjectInfo(f.ctx, bucket, name, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if logical.UserTags != "" {
|
||||
t.Fatal("control: the authoritative version must have no tags")
|
||||
}
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "data-without-tags", fmt.Sprintf(`
|
||||
{"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectVersion"],"Resource":["arn:aws:s3:::%s/*"]},
|
||||
{"Effect":"Deny","Action":["s3:GetObjectTagging","s3:GetObjectVersionTagging"],"Resource":["arn:aws:s3:::%s/*"]}`, bucket, bucket))
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "data-without-tags-user", "data-without-tags")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.GetObjectTaggingAction, bucket: bucket, object: name, versionID: before.VersionID}); !isBatchJobAuthzError(err) {
|
||||
t.Fatal("control: reading tags must be denied")
|
||||
}
|
||||
writes := 0
|
||||
var sentTags string
|
||||
target, err := minio.NewCore("target.invalid", &minio.Options{
|
||||
Region: "us-east-1", Creds: credentials.NewStaticV4("test-access", "test-secret", ""),
|
||||
Transport: batchLockTransport(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
if r.Method == http.MethodPut {
|
||||
writes++
|
||||
sentTags = r.Header.Get(xhttp.AmzObjectTagging)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Etag": {"\"test-etag\""}}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
|
||||
}),
|
||||
})
|
||||
mustIAM(t, err)
|
||||
r := BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: bucket},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: "remote"},
|
||||
}
|
||||
err = r.ReplicateToTarget(f.ctx, z, target, before, false, sub)
|
||||
if err != nil && !isBatchJobAuthzError(err) {
|
||||
t.Fatalf("unexpected replication failure: %v", err)
|
||||
}
|
||||
if writes > 0 && sentTags != logical.UserTags {
|
||||
t.Errorf("push must send only metadata covered by authorization: writes=%d sentTags=%q authorizedTags=%q err=%v", writes, sentTags, logical.UserTags, err)
|
||||
}
|
||||
}
|
||||
|
||||
// LDAP's short username and its parent DN are distinct identities. The
|
||||
// background merge must not turn a deny for the verified short name into
|
||||
// a non-matching statement by replacing it with the parent DN.
|
||||
func TestBatchReviewLDAPUsernameDeny(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "ldap-short-name-deny", batchAuthzFullAccess+`,
|
||||
{"Effect":"Deny","Action":["s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"ldap:username":"alice"}}}`)
|
||||
const dn = "uid=alice,ou=people,dc=example,dc=org"
|
||||
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{
|
||||
"exp": UTCNow().Add(time.Hour).Unix(), ldapUser: dn, ldapActualUser: dn, ldapUserN: "alice",
|
||||
}, globalActiveCred.SecretKey)
|
||||
mustIAM(t, err)
|
||||
cred.ParentUser = dn
|
||||
_, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "ldap-short-name-deny")
|
||||
mustIAM(t, err)
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
p, err := sub.principal(f.ctx)
|
||||
mustIAM(t, err)
|
||||
cred.Claims = p.claims
|
||||
req, err := http.NewRequest(http.MethodDelete, "https://localhost/batch-authz/data", nil)
|
||||
mustIAM(t, err)
|
||||
args := policy.Args{
|
||||
AccountName: cred.AccessKey, Claims: p.claims, Action: policy.DeleteObjectVersionAction,
|
||||
BucketName: f.bucket, ObjectName: "data", ConditionValues: getConditionValues(req, "", cred),
|
||||
}
|
||||
if args.ConditionValues["username"][0] != "alice" || f.sys.isAllowedLocally(args) {
|
||||
t.Fatal("control: the request evaluator must deny the verified LDAP short username")
|
||||
}
|
||||
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.DeleteObjectVersionAction, bucket: f.bucket, object: "data", versionID: mustGetUUID()}); !isBatchJobAuthzError(err) {
|
||||
t.Errorf("batch authorization must preserve the LDAP username deny or fail closed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Failure to read authoritative metadata is incomplete work, even if one
|
||||
// pool's copy can still be opened. In particular, skipErrs=false must not
|
||||
// turn an unreadable entry into a successful archive.
|
||||
func TestBatchReviewSnowballAuthoritativeReadFailure(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
const name = "archive/unreadable-copy"
|
||||
before := putConsistencyObject(t, z, bucket, name, 0, "test payload", ObjectOptions{Versioned: true})
|
||||
set := z.serverPools[1].sets[0]
|
||||
getDisks := set.getDisks
|
||||
disks := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range disks {
|
||||
disks[i] = consistencyReadFaultDisk{StorageAPI: disks[i], bucket: bucket, object: name}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return disks }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
gr, err := z.GetObjectNInfo(t.Context(), bucket, name, nil, nil, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
_, logicalErr := batchAuthoritativeRead(t.Context(), z, bucket, name, gr.ObjInfo)
|
||||
gr.Close()
|
||||
if logicalErr == nil {
|
||||
t.Fatal("control: the authoritative read must fail")
|
||||
}
|
||||
remote, err := minio.New("archive.invalid", &minio.Options{
|
||||
Region: "us-east-1", Creds: credentials.NewStaticV4("test-access", "test-secret", ""),
|
||||
Transport: batchLockTransport(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
|
||||
}),
|
||||
})
|
||||
mustIAM(t, err)
|
||||
r := BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: bucket},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: "remote"},
|
||||
}
|
||||
r.Source.Snowball.InMemory = ptr(true)
|
||||
r.Source.Snowball.Compress = ptr(false)
|
||||
r.Source.Snowball.SkipErrs = ptr(false)
|
||||
if err := r.writeAsArchive(t.Context(), z, remote, []ObjectInfo{before}, "", batchRootSubmitter(t)); err == nil {
|
||||
t.Error("archive with an unreadable authoritative version must not report success")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/minio/minio/internal/crypto"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
)
|
||||
|
||||
// A suspended destination does not keep a source version ID. Authorization
|
||||
// must therefore describe the null destination version that the write really
|
||||
// replaces, and must not let a source ID escape that model.
|
||||
func TestBatchReviewPullSuspendedVersionBinding(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
_, err := globalBucketMetadataSys.Update(f.ctx, f.bucket, bucketVersioningConfig, []byte(`<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Suspended</Status></VersioningConfiguration>`))
|
||||
mustIAM(t, err)
|
||||
|
||||
sourceVersion := mustGetUUID()
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "pull-source-version-deny", batchAuthzFullAccess+fmt.Sprintf(`,
|
||||
{"Effect":"Deny","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"s3:versionid":"%s"}}}`, sourceVersion))
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "pull-source-version-user", "pull-source-version-deny")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
job := BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: "remote"},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: f.bucket},
|
||||
}
|
||||
err = job.ReplicateFromSource(f.ctx, f.obj, batchLockRemote(t, "replacement"), ObjectInfo{
|
||||
Name: "pull/suspended-version", VersionID: sourceVersion, ModTime: UTCNow(), ETag: "batchetag",
|
||||
UserDefined: map[string]string{},
|
||||
}, false, sub)
|
||||
if !isBatchJobAuthzError(err) {
|
||||
t.Fatalf("a source version ID must not evade the destination deny: %v", err)
|
||||
}
|
||||
if _, gerr := f.obj.GetObjectInfo(f.ctx, f.bucket, "pull/suspended-version", ObjectOptions{VersionID: sourceVersion}); gerr == nil {
|
||||
t.Fatal("a denied suspended-destination write created the source version")
|
||||
}
|
||||
}
|
||||
|
||||
// A failed source open in a non-skipping snowball must be reported to the
|
||||
// batch rather than silently omitted from a successful archive.
|
||||
type batchReviewOpenFailure struct {
|
||||
ObjectLayer
|
||||
name string
|
||||
}
|
||||
|
||||
func (a batchReviewOpenFailure) GetObjectNInfo(ctx context.Context, bucket, object string, rs *HTTPRangeSpec, h http.Header, opts ObjectOptions) (*GetObjectReader, error) {
|
||||
if object == a.name {
|
||||
return nil, fmt.Errorf("test: source open failed")
|
||||
}
|
||||
return a.ObjectLayer.GetObjectNInfo(ctx, bucket, object, rs, h, opts)
|
||||
}
|
||||
|
||||
func TestBatchReviewSnowballSourceOpenFailure(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
name := "archive/open-failure"
|
||||
entry := batchPutVersion(t, f.obj, f.bucket, name, nil)
|
||||
remote, err := minio.New("archive.invalid", &minio.Options{
|
||||
Region: "us-east-1", Creds: credentials.NewStaticV4("test-access", "test-secret", ""),
|
||||
Transport: batchLockTransport(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
|
||||
}),
|
||||
})
|
||||
mustIAM(t, err)
|
||||
r := BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: f.bucket},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: "remote"},
|
||||
}
|
||||
r.Source.Snowball.InMemory = ptr(true)
|
||||
r.Source.Snowball.Compress = ptr(false)
|
||||
r.Source.Snowball.SkipErrs = ptr(false)
|
||||
api := batchReviewOpenFailure{ObjectLayer: f.obj, name: name}
|
||||
if err := r.writeAsArchive(t.Context(), api, remote, []ObjectInfo{entry}, "", batchRootSubmitter(t)); err == nil {
|
||||
t.Fatal("a source open failure must not be reported as a successful archive")
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation must apply tag conditions to the logical version, including
|
||||
// a more recent tag update stored in a pool other than the selected copy.
|
||||
func TestBatchReviewRotationAuthoritativeTags(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
f := newBatchAuthzFixture(t)
|
||||
previousKMS := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("batch-round5-rotation-key")
|
||||
t.Cleanup(func() { GlobalKMS = previousKMS })
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "round5-rotation-tags", fmt.Sprintf(`
|
||||
{"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectVersion","s3:PutObject"],"Resource":["arn:aws:s3:::%s/*"]},
|
||||
{"Effect":"Deny","Action":["s3:GetObject","s3:GetObjectVersion"],"Resource":["arn:aws:s3:::%s/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/classification":"private"}}}`, bucket, bucket))
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "round5-rotation-user", "round5-rotation-tags")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
for _, classification := range []string{"private", "public"} {
|
||||
t.Run(classification, func(t *testing.T) {
|
||||
name := "rotation/authoritative-" + classification
|
||||
first := make(map[string]string)
|
||||
key, err := newEncryptMetadata(f.ctx, crypto.S3, "", nil, bucket, name, first, nil)
|
||||
mustIAM(t, err)
|
||||
first[xhttp.AmzObjectTagging] = "classification=public"
|
||||
first[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Add(-time.Hour).Format(time.RFC3339Nano)
|
||||
second := maps.Clone(first)
|
||||
second[xhttp.AmzObjectTagging] = "classification=" + classification
|
||||
second[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
ciphertext, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader("test encrypted payload"), key))
|
||||
mustIAM(t, err)
|
||||
before := putConsistencyObject(t, z, bucket, name, 0, string(ciphertext), ObjectOptions{Versioned: true, UserDefined: first})
|
||||
putConsistencyObject(t, z, bucket, name, 1, string(ciphertext), ObjectOptions{Versioned: true, VersionID: before.VersionID, MTime: before.ModTime, UserDefined: second})
|
||||
logical, err := z.replicaObjectInfo(f.ctx, bucket, name, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if logical.UserTags != "classification="+classification {
|
||||
t.Fatal("control: the second pool must govern the logical tags")
|
||||
}
|
||||
job := BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: sses3}}
|
||||
err = job.KeyRotate(f.ctx, z, before, sub)
|
||||
after, readErr := z.serverPools[0].GetObjectInfo(f.ctx, bucket, name, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, readErr)
|
||||
changed := after.UserDefined[crypto.MetaSealedKeyS3] != before.UserDefined[crypto.MetaSealedKeyS3]
|
||||
if classification == "private" {
|
||||
if !isBatchJobAuthzError(err) || changed {
|
||||
t.Fatalf("authoritative tag deny must prevent rewrapping: err=%v changed=%v", err, changed)
|
||||
}
|
||||
} else if err != nil || !changed {
|
||||
t.Fatalf("authorized unlocked version must rotate: err=%v changed=%v", err, changed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/crypto"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
)
|
||||
|
||||
// Rotation and listing cases found in review.
|
||||
|
||||
// Rotation rewrites the copy GetObjectInfo returns, which can carry another
|
||||
// tag revision than the merged version of all pools: both are authorized.
|
||||
func TestBatchReviewRotationAuthorizesWrittenCopy(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
f := newBatchAuthzFixture(t)
|
||||
previousKMS := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("review6-rotation-copy-key")
|
||||
t.Cleanup(func() { GlobalKMS = previousKMS })
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "review6-rotation-copy-deny", fmt.Sprintf(`
|
||||
{"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectVersion","s3:PutObject"],"Resource":["arn:aws:s3:::%s/*"]},
|
||||
{"Effect":"Deny","Action":["s3:GetObject","s3:GetObjectVersion"],"Resource":["arn:aws:s3:::%s/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/classification":"private"}}}`, bucket, bucket))
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "review6-rotation-copy-user", "review6-rotation-copy-deny")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
const name = "rotation/written-copy"
|
||||
first := make(map[string]string)
|
||||
key, err := newEncryptMetadata(f.ctx, crypto.S3, "", nil, bucket, name, first, nil)
|
||||
mustIAM(t, err)
|
||||
first[xhttp.AmzObjectTagging] = "classification=private"
|
||||
first[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Add(-time.Hour).Format(time.RFC3339Nano)
|
||||
second := maps.Clone(first)
|
||||
second[xhttp.AmzObjectTagging] = "classification=public"
|
||||
second[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
ciphertext, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader("review6 rotation payload"), key))
|
||||
mustIAM(t, err)
|
||||
before := putConsistencyObject(t, z, bucket, name, 0, string(ciphertext), ObjectOptions{Versioned: true, UserDefined: first})
|
||||
putConsistencyObject(t, z, bucket, name, 1, string(ciphertext), ObjectOptions{Versioned: true, VersionID: before.VersionID, MTime: before.ModTime, UserDefined: second})
|
||||
logical, err := z.replicaObjectInfo(f.ctx, bucket, name, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if logical.UserTags != "classification=public" {
|
||||
t.Fatalf("control: merged tags=%q, want public", logical.UserTags)
|
||||
}
|
||||
job := BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: sses3}}
|
||||
if err = job.KeyRotate(f.ctx, z, before, sub); !isBatchJobAuthzError(err) {
|
||||
t.Fatalf("rotation of a copy the submitter may not read returned %v", err)
|
||||
}
|
||||
after, err := z.serverPools[0].GetObjectInfo(f.ctx, bucket, name, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if after.UserDefined[crypto.MetaSealedKeyS3] != before.UserDefined[crypto.MetaSealedKeyS3] {
|
||||
t.Fatal("a refused rotation rewrote the copy")
|
||||
}
|
||||
}
|
||||
|
||||
// batchWalkError fails every listing before it returns a result.
|
||||
type batchWalkError struct {
|
||||
ObjectLayer
|
||||
err error
|
||||
}
|
||||
|
||||
func (w batchWalkError) Walk(context.Context, string, string, chan<- itemOrErr[ObjectInfo], WalkOptions) error {
|
||||
return w.err
|
||||
}
|
||||
|
||||
// A listing outage pauses the run, as a streamed listing error does.
|
||||
func TestBatchReviewExpireWalkErrorPausesRun(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
rec, job := f.submit(t, globalActiveCred, f.expireYAML("data/"))
|
||||
if job == nil {
|
||||
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
listErr := errors.New("review6: listing failed")
|
||||
if err := job.Expire.Start(f.ctx, batchWalkError{ObjectLayer: f.obj, err: listErr}, *job); !errors.Is(err, listErr) {
|
||||
t.Fatalf("start returned %v, want %v", err, listErr)
|
||||
}
|
||||
if ri := f.jobInfo(t, job); ri.Failed || ri.Complete || ri.PausedReason == "" {
|
||||
t.Fatalf("failed listing: complete=%v failed=%v", ri.Complete, ri.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
// Two pools hold different writes of the null version, and a newer version
|
||||
// of the key is in the second one. Rotation rewrites the copy it read and
|
||||
// authorized, in the first pool, not the copy the latest version of the key
|
||||
// would route a write to; the other write keeps its key and stays readable.
|
||||
func TestBatchReviewRotationRewritesTheCopyItRead(t *testing.T) {
|
||||
for _, otherLatest := range []bool{false, true} {
|
||||
name := "addressed-version-is-latest"
|
||||
if otherLatest {
|
||||
name = "another-version-is-latest"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resetTestGlobals()
|
||||
t.Cleanup(resetTestGlobals)
|
||||
z, _ := consistencyPools(t)
|
||||
globalIAMSys.initStore(z, nil)
|
||||
mustIAM(t, globalIAMSys.Load(t.Context(), true))
|
||||
const bucket = "review7-rotation-copies"
|
||||
mustIAM(t, z.MakeBucket(t.Context(), bucket, MakeBucketOptions{VersioningEnabled: true}))
|
||||
_, versionErr := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
|
||||
mustIAM(t, versionErr)
|
||||
oldKMS := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("review7-rotation-key")
|
||||
t.Cleanup(func() { GlobalKMS = oldKMS })
|
||||
const object = "rotation/null-copy"
|
||||
const payload = "rotation must preserve this data"
|
||||
readCopy := func(pool int) ([]byte, error) {
|
||||
gr, err := z.serverPools[pool].GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{VersionID: nullVersionID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gr.Close()
|
||||
return io.ReadAll(gr)
|
||||
}
|
||||
var before [2]ObjectInfo
|
||||
var keys [2]crypto.ObjectKey
|
||||
kinds := []crypto.Type{crypto.S3, crypto.S3KMS}
|
||||
for pool, kind := range kinds {
|
||||
meta := make(map[string]string)
|
||||
key, err := newEncryptMetadata(t.Context(), kind, "review7-rotation-key", nil, bucket, object, meta, nil)
|
||||
mustIAM(t, err)
|
||||
keys[pool] = key
|
||||
meta[xhttp.AmzObjectTagging] = "classification=public"
|
||||
if pool == 1 {
|
||||
meta[xhttp.AmzObjectTagging] = "classification=private"
|
||||
}
|
||||
meta[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Add(-time.Duration(pool+2) * time.Hour).Format(time.RFC3339Nano)
|
||||
body, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader(payload), key))
|
||||
mustIAM(t, err)
|
||||
putConsistencyObject(t, z, bucket, object, pool, string(body), ObjectOptions{
|
||||
Versioned: true, VersionID: nullVersionID, UserDefined: meta,
|
||||
MTime: UTCNow().Add(-time.Duration(pool+2) * time.Hour),
|
||||
})
|
||||
// As a read returns it, without the write's internal fields.
|
||||
before[pool], err = z.serverPools[pool].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: nullVersionID})
|
||||
mustIAM(t, err)
|
||||
data, err := readCopy(pool)
|
||||
if err != nil || string(data) != payload {
|
||||
t.Fatalf("control: pool %d was not readable before rotation: %v", pool, err)
|
||||
}
|
||||
}
|
||||
if otherLatest {
|
||||
putConsistencyObject(t, z, bucket, object, 1, "later version", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour)})
|
||||
}
|
||||
selected, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: nullVersionID, Versioned: true})
|
||||
mustIAM(t, err)
|
||||
if selected.VersionID != nullVersionID || !selected.ModTime.Equal(before[0].ModTime) || !crypto.S3.IsEncrypted(selected.UserDefined) {
|
||||
t.Fatal("control: the addressed null version must select the SSE-S3 write in pool zero")
|
||||
}
|
||||
logical, err := batchAuthoritativeVersion(t.Context(), z, bucket, object, selected)
|
||||
mustIAM(t, err)
|
||||
if logical.UserTags != "classification=public" || selected.UserTags != logical.UserTags {
|
||||
t.Fatal("control: both current authorization inputs must have public tags")
|
||||
}
|
||||
r := BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: ssekms, Key: "review7-rotation-key"}}
|
||||
sub := batchRootSubmitter(t)
|
||||
if otherLatest {
|
||||
batchAuthzPolicy(t.Context(), t, globalIAMSys, "review7-rotate-public", fmt.Sprintf(`
|
||||
{"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectVersion","s3:PutObject"],"Resource":["arn:aws:s3:::%s/*"]},
|
||||
{"Effect":"Deny","Action":["s3:GetObject","s3:GetObjectVersion"],"Resource":["arn:aws:s3:::%s/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/classification":"private"}}}`, bucket, bucket))
|
||||
cred := iamAdminUser(t.Context(), t, globalIAMSys, "review7-rotate-user", "review7-rotate-public")
|
||||
sub, err = newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
if err := sub.authorize(t.Context(), r.rotateAuthzRequests(before[1])...); !isBatchJobAuthzError(err) {
|
||||
t.Fatal("control: the older write in pool one must be denied")
|
||||
}
|
||||
}
|
||||
err = r.KeyRotate(t.Context(), z, selected, sub)
|
||||
if err != nil {
|
||||
t.Logf("rotation refused: %v", err)
|
||||
}
|
||||
for pool, want := range keys {
|
||||
after, readErr := z.serverPools[pool].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: nullVersionID})
|
||||
mustIAM(t, readErr)
|
||||
if otherLatest && pool == 1 && (!maps.Equal(after.UserDefined, before[pool].UserDefined) || !after.ModTime.Equal(before[pool].ModTime) || after.UserTags != before[pool].UserTags) {
|
||||
t.Error("rotation rewrote a copy denied by the submitter's tag-conditioned policy")
|
||||
}
|
||||
kind, _ := crypto.IsEncrypted(after.UserDefined)
|
||||
var got crypto.ObjectKey
|
||||
switch kind {
|
||||
case crypto.S3:
|
||||
got, readErr = crypto.S3.UnsealObjectKey(GlobalKMS, after.UserDefined, bucket, object)
|
||||
case crypto.S3KMS:
|
||||
got, readErr = crypto.S3KMS.UnsealObjectKey(GlobalKMS, after.UserDefined, bucket, object)
|
||||
default:
|
||||
t.Fatalf("pool %d: rotation lost encryption metadata", pool)
|
||||
}
|
||||
mustIAM(t, readErr)
|
||||
if got != want {
|
||||
t.Errorf("pool %d: rotation changed the data key without reencrypting its ciphertext (rotation error: %v)", pool, err)
|
||||
}
|
||||
data, readErr := readCopy(pool)
|
||||
if readErr != nil || string(data) != payload {
|
||||
t.Errorf("pool %d: rotation left unreadable ciphertext: %v", pool, readErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A version in a pool that a decommission or a rebalance is moving is not
|
||||
// rewritten there: only the source holds it, so the rotation is left to a
|
||||
// later run.
|
||||
func TestBatchReviewRotationLeavesMovingSource(t *testing.T) {
|
||||
resetTestGlobals()
|
||||
t.Cleanup(resetTestGlobals)
|
||||
z, _ := consistencyPools(t)
|
||||
globalIAMSys.initStore(z, nil)
|
||||
mustIAM(t, globalIAMSys.Load(t.Context(), true))
|
||||
const bucket = "review8-rotation-draining"
|
||||
mustIAM(t, z.MakeBucket(t.Context(), bucket, MakeBucketOptions{VersioningEnabled: true}))
|
||||
_, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
|
||||
mustIAM(t, err)
|
||||
oldKMS := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("review8-rotation-key")
|
||||
t.Cleanup(func() { GlobalKMS = oldKMS })
|
||||
const object = "rotation/draining"
|
||||
meta := make(map[string]string)
|
||||
key, err := newEncryptMetadata(t.Context(), crypto.S3, "review8-rotation-key", nil, bucket, object, meta, nil)
|
||||
mustIAM(t, err)
|
||||
body, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader("draining rotation"), key))
|
||||
mustIAM(t, err)
|
||||
before := putConsistencyObject(t, z, bucket, object, 0, string(body), ObjectOptions{Versioned: true, UserDefined: meta, MTime: UTCNow().Add(-time.Hour)})
|
||||
beforeInfo, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
|
||||
z.poolMetaMutex.Lock()
|
||||
z.poolMeta.Pools[0].Decommission = &PoolDecommissionInfo{}
|
||||
z.poolMetaMutex.Unlock()
|
||||
|
||||
r := BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: ssekms, Key: "review8-rotation-key"}}
|
||||
err = r.KeyRotate(t.Context(), z, beforeInfo, batchRootSubmitter(t))
|
||||
if !errors.Is(err, errBatchRotateMoving) {
|
||||
t.Errorf("rotation of a version only a draining pool holds returned %v", err)
|
||||
}
|
||||
after, readErr := z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, readErr)
|
||||
if after.UserDefined[crypto.MetaSealedKeyS3] != beforeInfo.UserDefined[crypto.MetaSealedKeyS3] {
|
||||
t.Fatal("rotation rewrote a draining pool")
|
||||
}
|
||||
// The job pauses and resumes in-process once the selected pool stops moving.
|
||||
job := BatchJobRequest{ID: "rotation-moving-resume", Started: UTCNow(), Submitter: batchRootSubmitter(t), KeyRotate: &r}
|
||||
batchTestJobPool(t.Context(), t, z)
|
||||
if err := r.Start(t.Context(), z, job); !isBatchJobUnavailable(err) {
|
||||
t.Fatalf("moving job did not pause: %v", err)
|
||||
}
|
||||
ri := &batchJobInfo{}
|
||||
mustIAM(t, ri.load(t.Context(), z, job))
|
||||
if ri.PausedReason == "" || ri.Failed || ri.Complete || ri.Object != "" || ri.ObjectsFailed != 0 {
|
||||
t.Fatalf("moving job report: %+v", ri)
|
||||
}
|
||||
z.poolMetaMutex.Lock()
|
||||
z.poolMeta.Pools[0].Decommission = nil
|
||||
z.poolMetaMutex.Unlock()
|
||||
mustIAM(t, r.Start(t.Context(), z, job))
|
||||
mustIAM(t, ri.load(t.Context(), z, job))
|
||||
if !ri.Complete || ri.Failed || ri.PausedReason != "" || ri.Objects != 1 {
|
||||
t.Fatalf("resumed rotation: %+v", ri)
|
||||
}
|
||||
}
|
||||
|
||||
// Once a move copied a version, the rotation leaves it to a later run, so the
|
||||
// source stays as the move's cleanup expects it.
|
||||
func TestBatchReviewRotationKeepsMigrationCleanup(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
kind string
|
||||
rotate bool
|
||||
}{{"decommission", false}, {"decommission", true}, {"rebalance", false}, {"rebalance", true}} {
|
||||
name := tc.kind + "/without-rotation"
|
||||
if tc.rotate {
|
||||
name = tc.kind + "/with-rotation"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resetTestGlobals()
|
||||
t.Cleanup(resetTestGlobals)
|
||||
z, _ := migrationTestPools(t)
|
||||
globalIAMSys.initStore(z, nil)
|
||||
mustIAM(t, globalIAMSys.Load(t.Context(), true))
|
||||
const bucket = "review8-rotation-migration"
|
||||
mustIAM(t, z.MakeBucket(t.Context(), bucket, MakeBucketOptions{VersioningEnabled: true}))
|
||||
_, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
|
||||
mustIAM(t, err)
|
||||
oldKMS := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("review8-rotation-key")
|
||||
t.Cleanup(func() { GlobalKMS = oldKMS })
|
||||
const object = "rotation/migration"
|
||||
meta := make(map[string]string)
|
||||
key, err := newEncryptMetadata(t.Context(), crypto.S3, "", nil, bucket, object, meta, nil)
|
||||
mustIAM(t, err)
|
||||
body, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader("rotation and migration"), key))
|
||||
mustIAM(t, err)
|
||||
before := putConsistencyObject(t, z, bucket, object, 0, string(body), ObjectOptions{Versioned: true, UserDefined: meta, MTime: UTCNow().Add(-time.Hour)})
|
||||
beforeInfo, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
move := migrationTestMover(t, z, 0, tc.kind)
|
||||
mustIAM(t, move(t.Context(), 0, bucket, migrationTestReader(t, z, 0, beforeInfo)))
|
||||
if _, err := z.serverPools[1].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID}); err != nil {
|
||||
t.Fatalf("control: migration did not copy the version: %v", err)
|
||||
}
|
||||
r := BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: ssekms, Key: "review8-rotation-key"}}
|
||||
var rotationErr error
|
||||
if tc.rotate {
|
||||
moved, err := z.serverPools[1].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
rotationErr = r.KeyRotate(t.Context(), z, beforeInfo, batchRootSubmitter(t))
|
||||
if !errors.Is(rotationErr, errBatchRotateMoving) {
|
||||
t.Errorf("rotation during a move returned %v", rotationErr)
|
||||
}
|
||||
after, err := z.serverPools[1].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if !maps.Equal(after.UserDefined, moved.UserDefined) || after.UserTags != moved.UserTags {
|
||||
t.Error("rotation during a move rewrote the moved copy")
|
||||
}
|
||||
}
|
||||
after, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if !after.ModTime.Equal(beforeInfo.ModTime) || !maps.Equal(after.UserDefined, beforeInfo.UserDefined) {
|
||||
t.Errorf("rotation changed a draining source after its version was copied (rotation error: %v)", rotationErr)
|
||||
}
|
||||
snapshot := []FileInfo{{VersionID: before.VersionID, ModTime: beforeInfo.ModTime}}
|
||||
if err := z.removeMovedVersions(t.Context(), bucket, object, 0, snapshot, nil); err != nil {
|
||||
t.Fatalf("rotation prevented migration cleanup: %v", err)
|
||||
}
|
||||
if _, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID}); !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
t.Fatalf("migration left a source version behind: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The rewrite keeps the tags and the expiry of the version, which a read
|
||||
// returns apart from its metadata.
|
||||
func TestBatchReviewRotationKeepsTagsAndExpiry(t *testing.T) {
|
||||
for _, mode := range []string{"single-pool", "multiple-pools"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
resetTestGlobals()
|
||||
t.Cleanup(resetTestGlobals)
|
||||
var z *erasureServerPools
|
||||
if mode == "single-pool" {
|
||||
z = purgeSinglePool(t, 4)
|
||||
} else {
|
||||
z, _ = consistencyPools(t)
|
||||
}
|
||||
globalIAMSys.initStore(z, nil)
|
||||
mustIAM(t, globalIAMSys.Load(t.Context(), true))
|
||||
const bucket = "review8-rotation-metadata"
|
||||
mustIAM(t, z.MakeBucket(t.Context(), bucket, MakeBucketOptions{VersioningEnabled: true}))
|
||||
_, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
|
||||
mustIAM(t, err)
|
||||
oldKMS := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("review8-rotation-metadata-key")
|
||||
t.Cleanup(func() { GlobalKMS = oldKMS })
|
||||
const object = "rotation/tagged"
|
||||
meta := make(map[string]string)
|
||||
key, err := newEncryptMetadata(t.Context(), crypto.S3, "", nil, bucket, object, meta, nil)
|
||||
mustIAM(t, err)
|
||||
meta[xhttp.AmzObjectTagging] = "classification=private"
|
||||
meta[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Add(-time.Minute).Format(time.RFC3339Nano)
|
||||
meta["expires"] = UTCNow().Add(time.Hour).Format(http.TimeFormat)
|
||||
body, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader("metadata preservation"), key))
|
||||
mustIAM(t, err)
|
||||
written := putConsistencyObject(t, z, bucket, object, 0, string(body), ObjectOptions{Versioned: true, UserDefined: meta})
|
||||
before, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: written.VersionID})
|
||||
mustIAM(t, err)
|
||||
if before.UserTags == "" || before.Expires.IsZero() {
|
||||
t.Fatal("control: stored version is missing the metadata to preserve")
|
||||
}
|
||||
r := BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: sses3}}
|
||||
mustIAM(t, r.KeyRotate(t.Context(), z, before, batchRootSubmitter(t)))
|
||||
after, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if after.UserDefined[crypto.MetaSealedKeyS3] == before.UserDefined[crypto.MetaSealedKeyS3] {
|
||||
t.Error("control: rotation did not rewrap the key")
|
||||
}
|
||||
if after.UserTags != before.UserTags {
|
||||
t.Errorf("rotation changed stored tags: got %q, want %q", after.UserTags, before.UserTags)
|
||||
}
|
||||
if !after.Expires.Equal(before.Expires) {
|
||||
t.Errorf("rotation changed Expires: got %v, want %v", after.Expires, before.Expires)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Two pools hold the same write with different tags, with or without
|
||||
// tagging timestamps. A rotation, also during a move, leaves the tags that
|
||||
// the merged version has.
|
||||
func TestBatchReviewRotationKeepsMergedTags(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
ordered, moving bool
|
||||
}{{false, false}, {false, true}, {true, false}, {true, true}} {
|
||||
t.Run(fmt.Sprintf("timestamp=%t/moving=%t", tc.ordered, tc.moving), func(t *testing.T) {
|
||||
resetTestGlobals()
|
||||
t.Cleanup(resetTestGlobals)
|
||||
z, _ := consistencyPools(t)
|
||||
globalIAMSys.initStore(z, nil)
|
||||
mustIAM(t, globalIAMSys.Load(t.Context(), true))
|
||||
const bucket = "review9-rotation-tag-tie"
|
||||
const object = "rotation/tag-tie"
|
||||
mustIAM(t, z.MakeBucket(t.Context(), bucket, MakeBucketOptions{VersioningEnabled: true}))
|
||||
_, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
|
||||
mustIAM(t, err)
|
||||
previousKMS := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("review9-tag-tie-key")
|
||||
t.Cleanup(func() { GlobalKMS = previousKMS })
|
||||
meta := make(map[string]string)
|
||||
key, err := newEncryptMetadata(t.Context(), crypto.S3, "", nil, bucket, object, meta, nil)
|
||||
mustIAM(t, err)
|
||||
meta[xhttp.AmzObjectTagging] = "classification=private"
|
||||
if tc.ordered {
|
||||
meta[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Add(-time.Minute).Format(time.RFC3339Nano)
|
||||
}
|
||||
other := maps.Clone(meta)
|
||||
other[xhttp.AmzObjectTagging] = "classification=public"
|
||||
body, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader("same write"), key))
|
||||
mustIAM(t, err)
|
||||
before := putConsistencyObject(t, z, bucket, object, 0, string(body), ObjectOptions{Versioned: true, UserDefined: meta, MTime: UTCNow().Add(-time.Hour)})
|
||||
putConsistencyObject(t, z, bucket, object, 1, string(body), ObjectOptions{Versioned: true, VersionID: before.VersionID, MTime: before.ModTime, UserDefined: other})
|
||||
prior, err := z.replicaObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if prior.UserTags != "classification=private" {
|
||||
t.Fatalf("control: logical tags=%q", prior.UserTags)
|
||||
}
|
||||
if tc.moving {
|
||||
migrationTestMover(t, z, 0, "decommission")
|
||||
}
|
||||
r := BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: sses3}}
|
||||
rotationErr := r.KeyRotate(t.Context(), z, before, batchRootSubmitter(t))
|
||||
after, err := z.replicaObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if after.UserTags != prior.UserTags {
|
||||
t.Fatalf("rotation changed logical tags from %q to %q (rotation error: %v)", prior.UserTags, after.UserTags, rotationErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// batchRotateFlipDisk starts a decommission of pool 0 at the flipAt-th
|
||||
// version read, after the rotation's first read of the version.
|
||||
type batchRotateFlipDisk struct {
|
||||
StorageAPI
|
||||
z *erasureServerPools
|
||||
reads *atomic.Int32
|
||||
flipAt int32
|
||||
flipped *atomic.Bool
|
||||
}
|
||||
|
||||
func (d batchRotateFlipDisk) ReadVersion(ctx context.Context, origvolume, volume, path, versionID string, opts ReadOptions) (FileInfo, error) {
|
||||
n := d.reads.Add(1)
|
||||
if n == d.flipAt && d.flipped.CompareAndSwap(false, true) {
|
||||
d.z.poolMetaMutex.Lock()
|
||||
d.z.poolMeta.Pools[0].Decommission = &PoolDecommissionInfo{}
|
||||
d.z.poolMetaMutex.Unlock()
|
||||
}
|
||||
return d.StorageAPI.ReadVersion(ctx, origvolume, volume, path, versionID, opts)
|
||||
}
|
||||
|
||||
// A move that starts while the rotation authorizes and rewraps the version
|
||||
// defers it: the state is checked again right before the write, where the
|
||||
// pools layer checks it.
|
||||
func TestBatchReviewRotationRechecksPoolBeforeWrite(t *testing.T) {
|
||||
resetTestGlobals()
|
||||
t.Cleanup(resetTestGlobals)
|
||||
z, _ := consistencyPools(t)
|
||||
globalIAMSys.initStore(z, nil)
|
||||
mustIAM(t, globalIAMSys.Load(t.Context(), true))
|
||||
const bucket = "review11-rotation-window"
|
||||
const object = "rotation/window"
|
||||
mustIAM(t, z.MakeBucket(t.Context(), bucket, MakeBucketOptions{VersioningEnabled: true}))
|
||||
_, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
|
||||
mustIAM(t, err)
|
||||
oldKMS := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("review11-rotation-key")
|
||||
t.Cleanup(func() { GlobalKMS = oldKMS })
|
||||
|
||||
meta := make(map[string]string)
|
||||
key, err := newEncryptMetadata(t.Context(), crypto.S3, "", nil, bucket, object, meta, nil)
|
||||
mustIAM(t, err)
|
||||
body, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader("rotation window"), key))
|
||||
mustIAM(t, err)
|
||||
before := putConsistencyObject(t, z, bucket, object, 0, string(body), ObjectOptions{
|
||||
Versioned: true,
|
||||
UserDefined: meta,
|
||||
MTime: UTCNow().Add(-time.Hour),
|
||||
})
|
||||
beforeInfo, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
|
||||
var reads atomic.Int32
|
||||
var flipped atomic.Bool
|
||||
firstPass := int32(0)
|
||||
for _, pool := range z.serverPools {
|
||||
firstPass += int32(pool.SetDriveCount())
|
||||
}
|
||||
var restore []func()
|
||||
for _, pool := range z.serverPools {
|
||||
restore = append(restore, conditionalPutSwapDisks(pool, object, func(d StorageAPI) StorageAPI {
|
||||
return batchRotateFlipDisk{
|
||||
StorageAPI: d,
|
||||
z: z,
|
||||
reads: &reads,
|
||||
flipAt: firstPass + 1,
|
||||
flipped: &flipped,
|
||||
}
|
||||
}))
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, fn := range restore {
|
||||
fn()
|
||||
}
|
||||
})
|
||||
|
||||
r := BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: sses3}}
|
||||
gotErr := r.KeyRotate(t.Context(), z, beforeInfo, batchRootSubmitter(t))
|
||||
if !flipped.Load() {
|
||||
t.Fatal("fault injection did not occur after the initial rotation read")
|
||||
}
|
||||
if !errors.Is(gotErr, errBatchRotateMoving) {
|
||||
t.Errorf("rotation did not defer after the pool became moving during pre-work: %v", gotErr)
|
||||
}
|
||||
after, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: before.VersionID})
|
||||
mustIAM(t, err)
|
||||
if after.UserDefined[crypto.MetaSealedKeyS3] != beforeInfo.UserDefined[crypto.MetaSealedKeyS3] {
|
||||
t.Error("rotation rewrote the pool after it became moving during pre-work")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
polplugin "github.com/minio/minio/internal/config/policy/plugin"
|
||||
"github.com/minio/minio/internal/crypto"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
xnet "github.com/pgsty/silo-pkg/v3/net"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
func batchPullJob(bucket string) BatchJobReplicateV1 {
|
||||
return BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: "remote"},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: bucket},
|
||||
}
|
||||
}
|
||||
|
||||
// A pull does not read the destination version it may replace, so a policy
|
||||
// that conditions the write on that version's tags refuses it; a version
|
||||
// condition sees the version the pull writes.
|
||||
func TestBatchPullDestinationConditions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
action policy.Action
|
||||
marker bool
|
||||
condition func(versionID string) string
|
||||
}{
|
||||
{"tags-put", policy.ReplicateObjectAction, false, func(string) string {
|
||||
return `{"StringEquals":{"s3:ExistingObjectTag/class":"protected"}}`
|
||||
}},
|
||||
{"tags-marker", policy.ReplicateDeleteAction, true, func(string) string {
|
||||
return `{"StringEquals":{"s3:ExistingObjectTag/class":"protected"}}`
|
||||
}},
|
||||
{"version-put", policy.PutObjectAction, false, func(v string) string {
|
||||
return fmt.Sprintf(`{"StringEquals":{"s3:versionid":%q}}`, v)
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
before := batchPutVersion(t, f.obj, f.bucket, "pull/guarded", map[string]string{xhttp.AmzObjectTagging: "class=protected"})
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "pull-guard", batchAuthzFullAccess+fmt.Sprintf(`,
|
||||
{"Effect":"Deny","Action":[%q],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":%s}`, tc.action, tc.condition(before.VersionID)))
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "pull-guard-user", "pull-guard")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
job := batchPullJob(f.bucket)
|
||||
err = job.ReplicateFromSource(f.ctx, f.obj, batchLockRemote(t, "replacement"), ObjectInfo{
|
||||
Name: before.Name, VersionID: before.VersionID, ModTime: UTCNow(), ETag: "batchetag",
|
||||
DeleteMarker: tc.marker, UserDefined: map[string]string{},
|
||||
}, false, sub)
|
||||
if !isBatchJobAuthzError(err) {
|
||||
t.Errorf("pull returned %v, want a refusal", err)
|
||||
}
|
||||
after, err := f.obj.GetObjectInfo(f.ctx, f.bucket, before.Name, ObjectOptions{VersionID: before.VersionID})
|
||||
if err != nil || after.ETag != before.ETag {
|
||||
t.Errorf("refused pull changed the destination version: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A pull does not encrypt what it writes; encryption headers reported by the
|
||||
// source do not satisfy a destination policy that requires encryption.
|
||||
func TestBatchPullEncryptionCondition(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "pull-encryption", `
|
||||
{"Effect":"Allow","Action":["s3:ReplicateObject"],"Resource":["arn:aws:s3:::batch-authz/*"]},
|
||||
{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"s3:x-amz-server-side-encryption":"aws:kms","s3:x-amz-server-side-encryption-aws-kms-key-id":"required-key"}}}`)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "pull-encryption-user", "pull-encryption")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
job := batchPullJob(f.bucket)
|
||||
version := mustGetUUID()
|
||||
err = job.ReplicateFromSource(f.ctx, f.obj, batchLockRemote(t, "content"), ObjectInfo{
|
||||
Name: "pull/encrypted", VersionID: version, ModTime: UTCNow(), ETag: "batchetag",
|
||||
UserDefined: map[string]string{xhttp.AmzServerSideEncryption: "aws:kms", xhttp.AmzServerSideEncryptionKmsID: "required-key"},
|
||||
}, false, sub)
|
||||
if err == nil {
|
||||
oi, gerr := f.obj.GetObjectInfo(f.ctx, f.bucket, "pull/encrypted", ObjectOptions{VersionID: version})
|
||||
mustIAM(t, gerr)
|
||||
if !crypto.S3KMS.IsEncrypted(oi.UserDefined) {
|
||||
t.Fatal("a pull was allowed by an encryption condition it did not meet")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A conditional Deny on an S3 Tables action covers the S3 data actions it
|
||||
// implies, under the tables resource form; the job refuses rather than skip it.
|
||||
func TestBatchJobTableConditionalDeny(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "table-context", batchAuthzFullAccess+`,
|
||||
{"Effect":"Deny","Action":["s3tables:GetTableData"],"Resource":["arn:aws:s3tables:::bucket/batch-authz/table/table-id"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}`)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "table-context-user", "table-context")
|
||||
if f.sys.isAllowedLocally(policy.Args{AccountName: cred.AccessKey, Action: policy.GetObjectAction, BucketName: f.bucket, ObjectName: "table-id/data", ConditionValues: map[string][]string{"SecureTransport": {"false"}}}) {
|
||||
t.Fatal("control: with the request context the Deny applies")
|
||||
}
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.GetObjectAction, bucket: f.bucket, object: "table-id/data"}); !isBatchJobAuthzError(err) {
|
||||
t.Fatalf("authorize returned %v, want a refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
type batchArchiveRevocation struct {
|
||||
ObjectLayer
|
||||
revoke func()
|
||||
}
|
||||
|
||||
func (a *batchArchiveRevocation) GetObjectNInfo(ctx context.Context, bucket, object string, rs *HTTPRangeSpec, h http.Header, opts ObjectOptions) (*GetObjectReader, error) {
|
||||
if object == "archive/second" {
|
||||
a.revoke()
|
||||
}
|
||||
return a.ObjectLayer.GetObjectNInfo(ctx, bucket, object, rs, h, opts)
|
||||
}
|
||||
|
||||
// Each snowball entry is authorized for the submitter as it is then.
|
||||
func TestBatchPushSnowballEntryRevalidation(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "archive-user", "")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
pluginURL, err := xnet.ParseHTTPURL("https://authz.invalid/")
|
||||
mustIAM(t, err)
|
||||
previous := newGlobalAuthZPluginFn()
|
||||
setGlobalAuthZPlugin(polplugin.New(polplugin.Args{
|
||||
URL: pluginURL, CloseRespFn: func(r io.ReadCloser) { _ = r.Close() },
|
||||
Transport: batchLockTransport(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"result":true}`)), Request: r}, nil
|
||||
}),
|
||||
}))
|
||||
t.Cleanup(func() { setGlobalAuthZPlugin(previous) })
|
||||
entries := []ObjectInfo{
|
||||
batchPutVersion(t, f.obj, f.bucket, "archive/first", nil),
|
||||
batchPutVersion(t, f.obj, f.bucket, "archive/second", nil),
|
||||
}
|
||||
remote, err := minio.New("archive.invalid", &minio.Options{
|
||||
Region: "us-east-1", Creds: credentials.NewStaticV4("batch-access", "batch-secret", ""),
|
||||
Transport: batchLockTransport(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
|
||||
}),
|
||||
})
|
||||
mustIAM(t, err)
|
||||
job := BatchJobReplicateV1{Source: BatchJobReplicateSource{Bucket: f.bucket}, Target: BatchJobReplicateTarget{Bucket: "remote"}}
|
||||
job.Source.Snowball.InMemory = ptr(true)
|
||||
job.Source.Snowball.Compress = ptr(false)
|
||||
job.Source.Snowball.SkipErrs = ptr(false)
|
||||
api := &batchArchiveRevocation{ObjectLayer: f.obj, revoke: func() { mustIAM(t, f.sys.DeleteUser(f.ctx, cred.AccessKey, false)) }}
|
||||
if err = job.writeAsArchive(f.ctx, api, remote, entries, "", sub); !isBatchJobAuthzError(err) {
|
||||
t.Fatalf("archive with a revoked submitter returned %v, want a refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A definition holds one job type; one with several is refused, so that what
|
||||
// is authorized is what runs.
|
||||
func TestBatchJobSeveralSections(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
older := f.putVersions(t, "data/a")
|
||||
job := f.expireYAML("") + `keyrotate:
|
||||
apiVersion: v1
|
||||
bucket: other
|
||||
encryption:
|
||||
type: sse-s3
|
||||
`
|
||||
rec, queued := f.submit(t, globalActiveCred, job)
|
||||
if queued != nil {
|
||||
f.run(t, queued)
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("submission status %d, want 400: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !f.versionExists(t, "data/a", older) {
|
||||
t.Fatal("a definition with two job types ran")
|
||||
}
|
||||
}
|
||||
|
||||
// Prefixes are walked once each in one order: a cursor in a later prefix is
|
||||
// after every key of the earlier ones, and an overlapping prefix is not
|
||||
// walked twice.
|
||||
func TestBatchExpirePrefixes(t *testing.T) {
|
||||
if got := batchCanonicalPrefixes([]string{"z/", "a/", "a/b/", "z/", ""}); len(got) != 1 || got[0] != "" {
|
||||
t.Fatalf("canonical prefixes %q", got)
|
||||
}
|
||||
if got := batchCanonicalPrefixes([]string{"z/", "a/b/", "a/"}); strings.Join(got, ",") != "a/,z/" {
|
||||
t.Fatalf("canonical prefixes %q", got)
|
||||
}
|
||||
f := newBatchAuthzFixture(t)
|
||||
done := f.putVersions(t, "a/done")
|
||||
todo := f.putVersions(t, "z/todo")
|
||||
batchPutVersion(t, f.obj, f.bucket, "x/data", batchLockMeta("LEGALHOLD"))
|
||||
batchPutVersion(t, f.obj, f.bucket, "x/data", nil)
|
||||
rec, job := f.submit(t, globalActiveCred, fmt.Sprintf(`expire:
|
||||
apiVersion: v1
|
||||
bucket: %s
|
||||
prefix: ["z/", "x/", "a/", "x/"]
|
||||
rules:
|
||||
- type: object
|
||||
purge:
|
||||
retainVersions: 1
|
||||
`, f.bucket))
|
||||
if job == nil {
|
||||
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// A run that completed a/ and stopped.
|
||||
ri := &batchJobInfo{JobID: job.ID, JobType: string(job.Type()), StartTime: job.Started, Bucket: f.bucket, Object: "a/done"}
|
||||
mustIAM(t, ri.updateAfter(f.ctx, f.obj, 0, *job))
|
||||
f.run(t, job)
|
||||
if !f.versionExists(t, "a/done", done) {
|
||||
t.Error("resume did again a key before the cursor")
|
||||
}
|
||||
if f.versionExists(t, "z/todo", todo) {
|
||||
t.Error("resume skipped a later prefix")
|
||||
}
|
||||
if got := f.jobInfo(t, job).VersionsSkippedLocked; got != 1 {
|
||||
t.Errorf("a locked version under two equal prefixes was counted %d times", got)
|
||||
}
|
||||
}
|
||||
|
||||
type batchPartialWalk struct {
|
||||
ObjectLayer
|
||||
version ObjectInfo
|
||||
cancel context.CancelFunc
|
||||
read chan struct{}
|
||||
}
|
||||
|
||||
func (a *batchPartialWalk) Walk(_ context.Context, _ string, _ string, results chan<- itemOrErr[ObjectInfo], _ WalkOptions) error {
|
||||
go func() {
|
||||
defer close(results)
|
||||
results <- itemOrErr[ObjectInfo]{Item: a.version}
|
||||
if a.cancel != nil {
|
||||
<-a.read
|
||||
a.cancel()
|
||||
return
|
||||
}
|
||||
results <- itemOrErr[ObjectInfo]{Err: errors.New("test: listing failed within a key")}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *batchPartialWalk) GetObjectInfo(context.Context, string, string, ObjectOptions) (ObjectInfo, error) {
|
||||
if a.read != nil {
|
||||
close(a.read)
|
||||
}
|
||||
return a.version, nil
|
||||
}
|
||||
|
||||
// A rotation whose walk fails or is canceled within a key keeps that key
|
||||
// after its cursor and does not report completion.
|
||||
func TestBatchKeyRotationIncompleteWalk(t *testing.T) {
|
||||
for _, canceled := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("canceled=%v", canceled), func(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
ctx, cancel := context.WithCancel(f.ctx)
|
||||
defer cancel()
|
||||
meta := batchLockMeta("LEGALHOLD")
|
||||
meta[crypto.MetaSealedKeyS3] = "test-sealed-key"
|
||||
api := &batchPartialWalk{ObjectLayer: f.obj, version: ObjectInfo{Bucket: f.bucket, Name: "rotation/key", VersionID: mustGetUUID(), UserDefined: meta}}
|
||||
if canceled {
|
||||
api.cancel, api.read = cancel, make(chan struct{})
|
||||
}
|
||||
job := BatchJobRequest{
|
||||
ID: "keyrotate-partial" + getKeySeparator() + "0", Started: UTCNow(), Submitter: batchRootSubmitter(t),
|
||||
KeyRotate: &BatchJobKeyRotateV1{Bucket: f.bucket, Encryption: BatchJobKeyRotateEncryption{Type: sses3}},
|
||||
}
|
||||
_ = job.KeyRotate.Start(ctx, api, job)
|
||||
ri := f.jobInfo(t, &job)
|
||||
if ri.Complete || ri.Object == api.version.Name || ri.VersionsSkippedLocked != 0 {
|
||||
t.Fatalf("incomplete key committed: complete=%v cursor=%q skips=%d", ri.Complete, ri.Object, ri.VersionsSkippedLocked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
"github.com/puzpuzpuz/xsync/v3"
|
||||
)
|
||||
|
||||
// batchBlockedIAMStore holds the lazy load of one user's policy mapping, or
|
||||
// of one policy document, until released or until the load's context ends.
|
||||
type batchBlockedIAMStore struct {
|
||||
IAMStorageAPI
|
||||
mapping string // access key whose mapping load blocks
|
||||
doc string // policy whose document load blocks
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *batchBlockedIAMStore) block(ctx context.Context) error {
|
||||
s.once.Do(func() { close(s.started) })
|
||||
select {
|
||||
case <-s.release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *batchBlockedIAMStore) loadMappedPolicy(ctx context.Context, name string, userType IAMUserType, isGroup bool, m *xsync.MapOf[string, MappedPolicy]) error {
|
||||
if name == s.mapping && !isGroup {
|
||||
if err := s.block(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.IAMStorageAPI.loadMappedPolicy(ctx, name, userType, isGroup, m)
|
||||
}
|
||||
|
||||
func (s *batchBlockedIAMStore) loadPolicyDoc(ctx context.Context, name string, out map[string]PolicyDoc) error {
|
||||
if name == s.doc {
|
||||
if err := s.block(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.IAMStorageAPI.loadPolicyDoc(ctx, name, out)
|
||||
}
|
||||
|
||||
// The batch path and the request path share one IAM cache. A lazy load that
|
||||
// batch authorization needs, of a mapping that is never cached (a user whose
|
||||
// policies come from a group) or of an evicted policy document, must not
|
||||
// hold request authentication and cached authorization while storage is
|
||||
// slow, and must end at the deadline of the job's context.
|
||||
func TestBatchAuthorizeSlowLoadKeepsCachedAuthorizationAvailable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// submitter returns the submitter whose authorization lazily loads,
|
||||
// and arranges for blocked to hold that load.
|
||||
submitter func(t *testing.T, f *batchAuthzFixture, blocked *batchBlockedIAMStore) *batchJobSubmitter
|
||||
}{
|
||||
{"mapping", func(t *testing.T, f *batchAuthzFixture, blocked *batchBlockedIAMStore) *batchJobSubmitter {
|
||||
// No mapping of the user itself is stored, so every lookup of
|
||||
// its policies reads storage before it falls back to the group.
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "snapshot-group-user", "")
|
||||
_, err := f.sys.AddUsersToGroup(f.ctx, "snapshot-group", []string{cred.AccessKey})
|
||||
mustIAM(t, err)
|
||||
_, err = f.sys.PolicyDBSet(f.ctx, "snapshot-group", "snapshot-full", regUser, true)
|
||||
mustIAM(t, err)
|
||||
blocked.mapping = cred.AccessKey
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
return sub
|
||||
}},
|
||||
{"document", func(t *testing.T, f *batchAuthzFixture, blocked *batchBlockedIAMStore) *batchJobSubmitter {
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "snapshot-evicted", batchAuthzFullAccess)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "snapshot-doc-user", "snapshot-evicted")
|
||||
cache := f.sys.store.lock()
|
||||
delete(cache.iamPolicyDocsMap, "snapshot-evicted")
|
||||
f.sys.store.unlock()
|
||||
blocked.doc = "snapshot-evicted"
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
return sub
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "snapshot-full", batchAuthzFullAccess)
|
||||
cached := iamAdminUser(f.ctx, t, f.sys, "snapshot-cached-user", "snapshot-full")
|
||||
store := f.sys.store
|
||||
original := store.IAMStorageAPI
|
||||
blocked := &batchBlockedIAMStore{IAMStorageAPI: original, started: make(chan struct{}), release: make(chan struct{})}
|
||||
sub := tc.submitter(t, f, blocked)
|
||||
req := batchAuthzRequest{action: policy.GetObjectAction, bucket: f.bucket, object: "key", versionID: mustGetUUID()}
|
||||
if err := sub.authorize(f.ctx, req); err != nil {
|
||||
t.Fatalf("control: %v", err)
|
||||
}
|
||||
store.IAMStorageAPI = blocked
|
||||
var wg sync.WaitGroup
|
||||
var once sync.Once
|
||||
unblock := func() { once.Do(func() { close(blocked.release) }) }
|
||||
t.Cleanup(func() { unblock(); wg.Wait(); store.IAMStorageAPI = original })
|
||||
|
||||
slowDone := make(chan error, 1)
|
||||
wg.Go(func() { slowDone <- sub.authorize(f.ctx, req) })
|
||||
select {
|
||||
case <-blocked.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the lazy load did not start")
|
||||
}
|
||||
cachedDone := make(chan bool, 2)
|
||||
wg.Go(func() {
|
||||
_, ok := f.sys.GetUser(f.ctx, cached.AccessKey)
|
||||
cachedDone <- ok
|
||||
})
|
||||
wg.Go(func() {
|
||||
cachedDone <- f.sys.IsAllowed(policy.Args{AccountName: cached.AccessKey, Action: policy.GetObjectAction, BucketName: f.bucket, ObjectName: "key"})
|
||||
})
|
||||
for range 2 {
|
||||
select {
|
||||
case ok := <-cachedDone:
|
||||
if !ok {
|
||||
t.Fatal("cached authentication or authorization was rejected")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("a slow IAM load during batch authorization blocked cached authentication")
|
||||
}
|
||||
}
|
||||
unblock()
|
||||
if err := <-slowDone; err != nil {
|
||||
t.Fatalf("authorization after the load completed: %v", err)
|
||||
}
|
||||
|
||||
// A load that does not return ends with the job's context, as an
|
||||
// unavailable dependency, not with the storage layer's patience.
|
||||
blocked.release = make(chan struct{})
|
||||
once = sync.Once{}
|
||||
unblock = func() { once.Do(func() { close(blocked.release) }) }
|
||||
ctx, cancel := context.WithTimeout(f.ctx, 200*time.Millisecond)
|
||||
defer cancel()
|
||||
deadlineDone := make(chan error, 1)
|
||||
wg.Go(func() { deadlineDone <- sub.authorize(ctx, req) })
|
||||
select {
|
||||
case err := <-deadlineDone:
|
||||
if !isBatchJobUnavailable(err) {
|
||||
t.Fatalf("a load that ended with the job's context: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("a lazy load outlived the job's context")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A policy that a mapping still names after it was deleted takes no part in
|
||||
// the decision, for a batch job as for a request: the remaining mapped
|
||||
// policies decide. An STS parent user's mapping can name a deleted policy,
|
||||
// because deleting a policy checks only user and group mappings for use.
|
||||
func TestBatchAuthorizeSkipsDeletedMappedPolicy(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "mapped-full", batchAuthzFullAccess)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "mapped-deleted", `{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/*"]}`)
|
||||
parent := iamAdminUser(f.ctx, t, f.sys, "mapped-parent", "")
|
||||
_, err := f.sys.PolicyDBSet(f.ctx, parent.AccessKey, "mapped-full,mapped-deleted", stsUser, false)
|
||||
mustIAM(t, err)
|
||||
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix()}, globalActiveCred.SecretKey)
|
||||
mustIAM(t, err)
|
||||
cred.ParentUser = parent.AccessKey
|
||||
_, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "")
|
||||
mustIAM(t, err)
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
req := batchAuthzRequest{action: policy.GetObjectAction, bucket: f.bucket, object: "key", versionID: mustGetUUID()}
|
||||
if err := sub.authorize(f.ctx, req); err != nil {
|
||||
t.Fatalf("control: %v", err)
|
||||
}
|
||||
p, err := sub.principal(f.ctx)
|
||||
mustIAM(t, err)
|
||||
args := policy.Args{AccountName: cred.AccessKey, Action: req.action, BucketName: req.bucket, ObjectName: req.object, Claims: p.claims, ConditionValues: p.conditionValues(req)}
|
||||
|
||||
mustIAM(t, f.sys.DeletePolicy(f.ctx, "mapped-deleted", true))
|
||||
if !f.sys.IsAllowed(args) {
|
||||
t.Fatal("the request path denied the remaining mapped policy")
|
||||
}
|
||||
if err := sub.authorize(f.ctx, req); err != nil {
|
||||
t.Fatalf("batch authorization diverged from the request after a mapped policy was deleted: %v", err)
|
||||
}
|
||||
|
||||
// With every mapped policy deleted, both deny; the job is refused, not
|
||||
// paused, as nothing is unavailable.
|
||||
mustIAM(t, f.sys.DeletePolicy(f.ctx, "mapped-full", true))
|
||||
if f.sys.IsAllowed(args) {
|
||||
t.Fatal("the request path allowed without any mapped policy")
|
||||
}
|
||||
if err := sub.authorize(f.ctx, req); !isBatchJobAuthzError(err) {
|
||||
t.Fatalf("batch authorization without any mapped policy: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7/pkg/tags"
|
||||
"github.com/minio/minio/internal/arn"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
"github.com/pgsty/silo-pkg/v3/policy/condition"
|
||||
"github.com/puzpuzpuz/xsync/v3"
|
||||
)
|
||||
|
||||
// A batch job acts on objects long after the request that submitted it has
|
||||
// gone. It therefore records who submitted it and acts only as that
|
||||
// principal:
|
||||
//
|
||||
// - StartBatchJob binds the authenticated credential to the job
|
||||
// (batchJobSubmitter). Nothing in the submitted definition names it.
|
||||
// - Every start of the job, and every object it touches, resolves the
|
||||
// binding against IAM again. A credential that is gone, disabled,
|
||||
// expired, revoked or edited since submission fails the job; a reused
|
||||
// access key does not match the binding.
|
||||
// - Each action is evaluated with the submitter's policies and session
|
||||
// policy but without a request. A policy statement that would need
|
||||
// request context the job does not have (source address, transport,
|
||||
// headers and so on) refuses the action instead of being skipped.
|
||||
//
|
||||
// A job without a binding was queued by an earlier release. It is never
|
||||
// authorized, whoever submitted it, and has to be submitted again.
|
||||
|
||||
//go:generate msgp -file $GOFILE -unexported
|
||||
|
||||
// batchJobSubmitter is the server-assigned identity of the credential that
|
||||
// submitted a batch job.
|
||||
type batchJobSubmitter struct {
|
||||
Version int `msg:"v"`
|
||||
Type string `msg:"t"`
|
||||
AccessKey string `msg:"ak"`
|
||||
// ParentUser is the parent of a derived credential.
|
||||
ParentUser string `msg:"pu"`
|
||||
// UpdatedAt and RevokedBefore are the revision of the identity record;
|
||||
// any edit of the identity changes them. They are unset for root.
|
||||
UpdatedAt time.Time `msg:"ua"`
|
||||
RevokedBefore time.Time `msg:"rb"`
|
||||
// Binding is a keyed fingerprint of the credential itself, see
|
||||
// batchSubmitterBinding.
|
||||
Binding []byte `msg:"bd"`
|
||||
}
|
||||
|
||||
const batchJobSubmitterVersion = 1
|
||||
|
||||
// Submitter types. A credential is classified in the order IsAllowed
|
||||
// evaluates it.
|
||||
const (
|
||||
batchSubmitterRoot = "root"
|
||||
batchSubmitterUser = "user"
|
||||
batchSubmitterTemporary = "temporary"
|
||||
batchSubmitterService = "service-account"
|
||||
)
|
||||
|
||||
//msgp:ignore batchJobAuthzError
|
||||
|
||||
// batchJobAuthzError refuses a batch job, or one of its actions, for its
|
||||
// submitter. It ends the job: it is persisted as the job's failure and is
|
||||
// never retried.
|
||||
type batchJobAuthzError struct {
|
||||
reason string
|
||||
}
|
||||
|
||||
func (e batchJobAuthzError) Error() string {
|
||||
return e.reason
|
||||
}
|
||||
|
||||
func isBatchJobAuthzError(err error) bool {
|
||||
var e batchJobAuthzError
|
||||
return errors.As(err, &e)
|
||||
}
|
||||
|
||||
var (
|
||||
errBatchJobResubmit = batchJobAuthzError{reason: "the batch job does not record who submitted it; submit it again"}
|
||||
errBatchJobChanged = batchJobAuthzError{reason: "the credential that submitted the batch job has changed since submission; submit it again"}
|
||||
errBatchJobInvalid = batchJobAuthzError{reason: "the credential that submitted the batch job is no longer valid"}
|
||||
)
|
||||
|
||||
func batchSubmitterType(cred auth.Credentials) string {
|
||||
switch {
|
||||
case cred.IsTemp():
|
||||
return batchSubmitterTemporary
|
||||
case cred.IsServiceAccount():
|
||||
return batchSubmitterService
|
||||
default:
|
||||
return batchSubmitterUser
|
||||
}
|
||||
}
|
||||
|
||||
// batchSubmitterBinding is a fingerprint of one credential keyed with its own
|
||||
// secret. It changes when the secret, the session token or the parent
|
||||
// changes, and a new credential that reuses the access key does not have it.
|
||||
func batchSubmitterBinding(typ string, cred auth.Credentials) []byte {
|
||||
mac := hmac.New(sha256.New, []byte(cred.SecretKey))
|
||||
for _, s := range []string{"silo batch-job submitter v1", typ, cred.AccessKey, cred.ParentUser, cred.SessionToken} {
|
||||
var n [8]byte
|
||||
binary.BigEndian.PutUint64(n[:], uint64(len(s)))
|
||||
mac.Write(n[:])
|
||||
mac.Write([]byte(s))
|
||||
}
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
// identityRecord returns the stored identity of a non-root access key.
|
||||
func (sys *IAMSys) identityRecord(accessKey string) (UserIdentity, bool, error) {
|
||||
if !sys.Initialized() {
|
||||
return UserIdentity{}, false, errServerNotInitialized
|
||||
}
|
||||
u, ok := sys.store.GetUser(accessKey)
|
||||
return u, ok, nil
|
||||
}
|
||||
|
||||
// newBatchJobSubmitter binds the credential that authenticated a
|
||||
// StartBatchJob request.
|
||||
func newBatchJobSubmitter(cred auth.Credentials) (*batchJobSubmitter, error) {
|
||||
root := globalActiveCred
|
||||
if cred.AccessKey == "" {
|
||||
return nil, errBatchJobInvalid
|
||||
}
|
||||
if cred.AccessKey == root.AccessKey {
|
||||
if subtle.ConstantTimeCompare([]byte(cred.SecretKey), []byte(root.SecretKey)) != 1 {
|
||||
return nil, errBatchJobChanged
|
||||
}
|
||||
return &batchJobSubmitter{
|
||||
Version: batchJobSubmitterVersion,
|
||||
Type: batchSubmitterRoot,
|
||||
AccessKey: root.AccessKey,
|
||||
Binding: batchSubmitterBinding(batchSubmitterRoot, root),
|
||||
}, nil
|
||||
}
|
||||
u, ok, err := globalIAMSys.identityRecord(cred.AccessKey)
|
||||
if err != nil {
|
||||
return nil, batchUnavailable("IAM identity lookup", err)
|
||||
}
|
||||
// The record must be the one the request was authenticated with.
|
||||
if !ok || u.Credentials.AccessKey != cred.AccessKey || u.Credentials.ParentUser != cred.ParentUser ||
|
||||
subtle.ConstantTimeCompare([]byte(u.Credentials.SecretKey), []byte(cred.SecretKey)) != 1 ||
|
||||
subtle.ConstantTimeCompare([]byte(u.Credentials.SessionToken), []byte(cred.SessionToken)) != 1 {
|
||||
return nil, errBatchJobChanged
|
||||
}
|
||||
typ := batchSubmitterType(u.Credentials)
|
||||
return &batchJobSubmitter{
|
||||
Version: batchJobSubmitterVersion,
|
||||
Type: typ,
|
||||
AccessKey: u.Credentials.AccessKey,
|
||||
ParentUser: u.Credentials.ParentUser,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
RevokedBefore: u.RevokedBefore,
|
||||
Binding: batchSubmitterBinding(typ, u.Credentials),
|
||||
}, nil
|
||||
}
|
||||
|
||||
//msgp:ignore batchJobPrincipal batchAuthzRequest
|
||||
|
||||
// batchJobPrincipal is a submitter resolved against current IAM state.
|
||||
type batchJobPrincipal struct {
|
||||
ctx context.Context
|
||||
typ string
|
||||
accessKey string
|
||||
parentUser string
|
||||
groups []string
|
||||
claims map[string]any
|
||||
owner bool
|
||||
temp bool // evaluated as a temporary credential
|
||||
service bool // evaluated as a service account
|
||||
}
|
||||
|
||||
// principal resolves the binding against current IAM state. It fails if the
|
||||
// credential no longer exists, is no longer valid, or is no longer the one
|
||||
// that was bound.
|
||||
func (s *batchJobSubmitter) principal(ctx context.Context) (batchJobPrincipal, error) {
|
||||
if s == nil || s.AccessKey == "" {
|
||||
return batchJobPrincipal{}, errBatchJobResubmit
|
||||
}
|
||||
if s.Version != batchJobSubmitterVersion {
|
||||
return batchJobPrincipal{}, batchJobAuthzError{reason: fmt.Sprintf("unsupported batch job submitter version %d; submit the job again", s.Version)}
|
||||
}
|
||||
if !batchIAMReady() {
|
||||
return batchJobPrincipal{}, batchUnavailable("IAM is not initialized", errServerNotInitialized)
|
||||
}
|
||||
root := globalActiveCred
|
||||
if s.Type == batchSubmitterRoot {
|
||||
if s.AccessKey != root.AccessKey || !hmac.Equal(s.Binding, batchSubmitterBinding(batchSubmitterRoot, root)) {
|
||||
return batchJobPrincipal{}, errBatchJobChanged
|
||||
}
|
||||
if !globalAPIConfig.permitRootAccess() {
|
||||
return batchJobPrincipal{}, errBatchJobInvalid
|
||||
}
|
||||
return batchJobPrincipal{ctx: ctx, typ: batchSubmitterRoot, accessKey: root.AccessKey, owner: true}, nil
|
||||
}
|
||||
switch s.Type {
|
||||
case batchSubmitterUser, batchSubmitterTemporary, batchSubmitterService:
|
||||
default:
|
||||
return batchJobPrincipal{}, errBatchJobResubmit
|
||||
}
|
||||
if s.AccessKey == root.AccessKey {
|
||||
return batchJobPrincipal{}, errBatchJobChanged
|
||||
}
|
||||
u, ok, err := globalIAMSys.CheckKey(ctx, s.AccessKey)
|
||||
if err != nil {
|
||||
return batchJobPrincipal{}, batchUnavailable("IAM credential lookup", err)
|
||||
}
|
||||
if !ok {
|
||||
// Deleted, disabled, expired or revoked.
|
||||
return batchJobPrincipal{}, errBatchJobInvalid
|
||||
}
|
||||
cred := u.Credentials
|
||||
typ := batchSubmitterType(cred)
|
||||
if typ != s.Type || cred.AccessKey != s.AccessKey || cred.ParentUser != s.ParentUser ||
|
||||
!u.UpdatedAt.Equal(s.UpdatedAt) || !u.RevokedBefore.Equal(s.RevokedBefore) ||
|
||||
!hmac.Equal(s.Binding, batchSubmitterBinding(typ, cred)) {
|
||||
return batchJobPrincipal{}, errBatchJobChanged
|
||||
}
|
||||
if cred.IsExpired() || cred.Status == auth.AccountOff {
|
||||
return batchJobPrincipal{}, errBatchJobInvalid
|
||||
}
|
||||
p := batchJobPrincipal{
|
||||
ctx: ctx,
|
||||
typ: typ,
|
||||
accessKey: cred.AccessKey,
|
||||
parentUser: cred.ParentUser,
|
||||
groups: cred.Groups,
|
||||
claims: map[string]any{},
|
||||
temp: cred.IsTemp(),
|
||||
service: !cred.IsTemp() && cred.IsServiceAccount(),
|
||||
}
|
||||
if typ != batchSubmitterUser {
|
||||
// Verify the token as a request would, with the current signing keys.
|
||||
jwtClaims, err := extractJWTClaims(u)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTokenSigningKeyUnavailable) {
|
||||
return batchJobPrincipal{}, batchUnavailable("token signing key", err)
|
||||
}
|
||||
return batchJobPrincipal{}, errBatchJobInvalid
|
||||
}
|
||||
p.claims = jwtClaims.Map()
|
||||
if err := decodeBatchSessionPolicy(p.claims); err != nil {
|
||||
return batchJobPrincipal{}, errBatchJobInvalid
|
||||
}
|
||||
}
|
||||
p.owner = cred.ParentUser == root.AccessKey && cred.AccessKey != siteReplicatorSvcAcc
|
||||
if p.owner && !globalAPIConfig.permitRootAccess() {
|
||||
return batchJobPrincipal{}, errBatchJobInvalid
|
||||
}
|
||||
if _, ok := p.claims[policy.SessionPolicyName]; ok {
|
||||
p.owner = false
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// decodeBatchSessionPolicy decodes a session policy claim for evaluation.
|
||||
// A request decodes it while verifying its token, except when an
|
||||
// authorization plugin is configured; a batch job decodes it always, so the
|
||||
// session policy also limits what the plugin allows.
|
||||
func decodeBatchSessionPolicy(claims map[string]any) error {
|
||||
sp, ok := claims[policy.SessionPolicyName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
s, ok := sp.(string)
|
||||
if !ok {
|
||||
return errAuthentication
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return errAuthentication
|
||||
}
|
||||
claims[sessionPolicyNameExtracted] = string(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
// batchAuthzRequest is one action a batch job is about to take.
|
||||
type batchAuthzRequest struct {
|
||||
action policy.Action
|
||||
bucket string
|
||||
object string // empty for a bucket action
|
||||
// versionID is the version an object version action addresses.
|
||||
versionID string
|
||||
// tags are the current tags of the object version acted on.
|
||||
tags string
|
||||
// tagsUnknown is set when the job does not read the version it acts
|
||||
// on, so s3:ExistingObjectTag is context it does not have.
|
||||
tagsUnknown bool
|
||||
// values are request values the job supplies itself, such as the
|
||||
// listing prefix or the encryption and retention it writes, keyed by
|
||||
// condition key name.
|
||||
values map[string][]string
|
||||
}
|
||||
|
||||
// batchAuthzMarker marks the condition values of a batch job for an
|
||||
// authorization plugin. Requests cannot set s3:authType.
|
||||
const batchAuthzMarker = "batch-job"
|
||||
|
||||
// Condition keys a batch job always provides, as a request would: identity,
|
||||
// time, the addressed version and the stored tags. Keys a request takes from
|
||||
// itself are provided only when the job sets them (see batchAuthzRequest
|
||||
// values); every other key is request context the job does not have.
|
||||
var batchServerConditionKeys = map[string]bool{
|
||||
condition.AWSCurrentTime.ToKey().String(): true,
|
||||
condition.AWSEpochTime.ToKey().String(): true,
|
||||
condition.AWSPrincipalType.ToKey().String(): true,
|
||||
condition.AWSUserID.ToKey().String(): true,
|
||||
condition.AWSUsername.ToKey().String(): true,
|
||||
condition.AWSGroups.ToKey().String(): true,
|
||||
condition.S3VersionID.ToKey().String(): true,
|
||||
condition.ExistingObjectTag.ToKey().String(): true,
|
||||
}
|
||||
|
||||
// Request keys a job may supply, by the name they have in condition values.
|
||||
var batchRequestConditionKeys = map[string]condition.KeyName{
|
||||
condition.S3Prefix.Name(): condition.S3Prefix,
|
||||
condition.S3XAmzServerSideEncryption.Name(): condition.S3XAmzServerSideEncryption,
|
||||
condition.S3XAmzServerSideEncryptionAwsKmsKeyID.Name(): condition.S3XAmzServerSideEncryptionAwsKmsKeyID,
|
||||
condition.S3ObjectLockMode.Name(): condition.S3ObjectLockMode,
|
||||
condition.S3ObjectLockRetainUntilDate.Name(): condition.S3ObjectLockRetainUntilDate,
|
||||
condition.S3ObjectLockLegalHold.Name(): condition.S3ObjectLockLegalHold,
|
||||
condition.RequestObjectTagKeys.Name(): condition.RequestObjectTagKeys,
|
||||
}
|
||||
|
||||
// suppliedConditionKeys returns the condition keys whose values req provides.
|
||||
func (req batchAuthzRequest) suppliedConditionKeys() map[string]bool {
|
||||
supplied := make(map[string]bool, len(batchServerConditionKeys)+len(req.values))
|
||||
for k := range batchServerConditionKeys {
|
||||
supplied[k] = true
|
||||
}
|
||||
if req.tagsUnknown {
|
||||
delete(supplied, condition.ExistingObjectTag.ToKey().String())
|
||||
}
|
||||
for name := range req.values {
|
||||
if key, ok := batchRequestConditionKeys[name]; ok {
|
||||
supplied[key.ToKey().String()] = true
|
||||
}
|
||||
if strings.HasPrefix(name, condition.RequestObjectTag.Name()+"/") {
|
||||
supplied[condition.RequestObjectTag.ToKey().String()] = true
|
||||
}
|
||||
}
|
||||
return supplied
|
||||
}
|
||||
|
||||
func batchKeySupplied(supplied map[string]bool, name string) bool {
|
||||
if i := strings.IndexByte(name, '/'); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
if strings.HasPrefix(name, "jwt:") || strings.HasPrefix(name, "ldap:") {
|
||||
// Claims of the verified token, as a request has them.
|
||||
return true
|
||||
}
|
||||
return supplied[name]
|
||||
}
|
||||
|
||||
// conditionValues are the values a request with the same identity would
|
||||
// carry, without any value taken from the request itself.
|
||||
func (p batchJobPrincipal) conditionValues(req batchAuthzRequest) map[string][]string {
|
||||
now := UTCNow()
|
||||
username := p.accessKey
|
||||
if p.temp || p.service {
|
||||
username = p.parentUser
|
||||
}
|
||||
principalType := "User"
|
||||
if len(p.claims) > 0 {
|
||||
principalType = "AssumedRole"
|
||||
}
|
||||
if username == globalActiveCred.AccessKey {
|
||||
principalType = "Account"
|
||||
}
|
||||
// Identity and time, then the claims over them, as a request has them
|
||||
// (an LDAP session's short username replaces its parent DN).
|
||||
values := map[string][]string{
|
||||
"CurrentTime": {now.Format(time.RFC3339)},
|
||||
"EpochTime": {strconv.FormatInt(now.Unix(), 10)},
|
||||
"principaltype": {principalType},
|
||||
"userid": {username},
|
||||
"username": {username},
|
||||
}
|
||||
for k, v := range p.claims {
|
||||
if s, ok := v.(string); ok {
|
||||
values[strings.ToLower(strings.TrimPrefix(k, "ldap"))] = []string{s}
|
||||
}
|
||||
}
|
||||
// No claim stands for a value of the object or of the job, which
|
||||
// follow and always win.
|
||||
for k := range values {
|
||||
if k == "versionid" || k == "prefix" || k == "authtype" ||
|
||||
strings.HasPrefix(k, "existingobjecttag/") || strings.HasPrefix(k, "requestobjecttag") ||
|
||||
strings.HasPrefix(k, "x-amz-") || strings.HasPrefix(k, "object-lock-") {
|
||||
delete(values, k)
|
||||
}
|
||||
}
|
||||
// For an authorization plugin: this is a batch job, not a request.
|
||||
values["authType"] = []string{batchAuthzMarker}
|
||||
if req.versionID != "" {
|
||||
values["versionid"] = []string{req.versionID}
|
||||
}
|
||||
if req.tags != "" {
|
||||
if t, err := tags.ParseObjectTags(req.tags); err == nil {
|
||||
for k, v := range t.ToMap() {
|
||||
values[pathJoin("ExistingObjectTag", k)] = []string{v}
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range req.values {
|
||||
values[k] = v
|
||||
}
|
||||
if grpsVal, ok := p.claims["groups"]; ok {
|
||||
if grpsIs, ok := grpsVal.([]any); ok {
|
||||
grps := []string{}
|
||||
for _, gI := range grpsIs {
|
||||
if g, ok := gI.(string); ok {
|
||||
grps = append(grps, g)
|
||||
}
|
||||
}
|
||||
if len(grps) > 0 {
|
||||
values["groups"] = grps
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := values["groups"]; !ok && len(p.groups) > 0 {
|
||||
values["groups"] = p.groups
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// authorize evaluates one action of the job as its submitter.
|
||||
func (p batchJobPrincipal) authorize(req batchAuthzRequest) error {
|
||||
args := policy.Args{
|
||||
AccountName: p.accessKey,
|
||||
Groups: p.groups,
|
||||
Action: req.action,
|
||||
BucketName: req.bucket,
|
||||
ObjectName: req.object,
|
||||
ConditionValues: p.conditionValues(req),
|
||||
IsOwner: p.owner,
|
||||
Claims: p.claims,
|
||||
}
|
||||
return p.authorizeArgs(args, &req)
|
||||
}
|
||||
|
||||
// authorizeArgs uses the same policy snapshot for request-context checks and
|
||||
// the decision. No boolean IAM evaluator re-reads and silently drops policies.
|
||||
func (p batchJobPrincipal) authorizeArgs(args policy.Args, req *batchAuthzRequest) error {
|
||||
denied := batchJobAuthzError{reason: "the batch job submitter is not allowed " + string(args.Action)}
|
||||
if req != nil {
|
||||
denied.reason = batchDeniedReason(*req)
|
||||
}
|
||||
ctx := p.ctx
|
||||
if ctx == nil {
|
||||
ctx = GlobalContext
|
||||
}
|
||||
plugin := newGlobalAuthZPluginFn()
|
||||
var snap *batchPolicySnapshot
|
||||
if plugin == nil && !args.IsOwner {
|
||||
snap = globalIAMSys.store.batchSnapshot(ctx)
|
||||
defer snap.release()
|
||||
}
|
||||
policies, err := globalIAMSys.batchLocalPolicies(p, args, plugin == nil, snap)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNoSuchPolicy) || errors.Is(err, errAuthentication) || errors.Is(err, errNoSuchUser) || errors.Is(err, errNoSuchGroup) {
|
||||
return denied
|
||||
}
|
||||
return batchUnavailable("IAM policy lookup", err)
|
||||
}
|
||||
if req != nil {
|
||||
supplied := req.suppliedConditionKeys()
|
||||
for _, pol := range policies {
|
||||
for _, st := range pol.Statements {
|
||||
if batchStatementNeedsRequest(st, args, supplied) {
|
||||
return batchJobAuthzError{reason: denied.reason + ": a policy statement depends on request context that a batch job does not have"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if plugin != nil {
|
||||
ok, err := plugin.IsAllowedContext(ctx, args)
|
||||
if err != nil {
|
||||
return batchUnavailable("authorization plugin", err)
|
||||
}
|
||||
if !ok {
|
||||
return denied
|
||||
}
|
||||
// The plugin does not see a session policy; it only limits further.
|
||||
if has, allowed := p.sessionPolicyDecision(args); has && !allowed {
|
||||
return denied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if args.IsOwner {
|
||||
return nil
|
||||
}
|
||||
if p.service {
|
||||
if p.claims[parentClaim] != p.parentUser {
|
||||
return denied
|
||||
}
|
||||
if _, ok := p.claims[iamPolicyClaimNameSA()].(string); !ok {
|
||||
return denied
|
||||
}
|
||||
}
|
||||
if has, allowed := p.sessionPolicyDecision(args); has && !allowed {
|
||||
return denied
|
||||
}
|
||||
ownerDerived := (p.temp || p.service) && p.parentUser == globalActiveCred.AccessKey
|
||||
if !ownerDerived {
|
||||
if p.service {
|
||||
args.AccountName = p.parentUser
|
||||
}
|
||||
if len(policies) == 0 || !policies[0].IsAllowed(args) {
|
||||
return denied
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func batchDeniedReason(req batchAuthzRequest) string {
|
||||
resource := req.bucket
|
||||
if req.object != "" {
|
||||
resource = pathJoin(req.bucket, req.object)
|
||||
}
|
||||
if req.versionID != "" {
|
||||
resource += " (versionId " + req.versionID + ")"
|
||||
}
|
||||
return fmt.Sprintf("the batch job submitter is not allowed %s on %s", req.action, resource)
|
||||
}
|
||||
|
||||
// sessionPolicyDecision evaluates the session policy the way IsAllowed would
|
||||
// for this credential.
|
||||
func (p batchJobPrincipal) sessionPolicyDecision(args policy.Args) (bool, bool) {
|
||||
switch {
|
||||
case p.temp:
|
||||
return isAllowedBySessionPolicy(args)
|
||||
case p.service:
|
||||
if p.claims[iamPolicyClaimNameSA()] == inheritedPolicyType {
|
||||
return false, false
|
||||
}
|
||||
return isAllowedBySessionPolicyForServiceAccount(args)
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// batchIAMLoadTimeout bounds one storage read of a lazy IAM load during
|
||||
// batch authorization, as MergePolicies bounds a policy document load.
|
||||
const batchIAMLoadTimeout = 5 * time.Second
|
||||
|
||||
// batchPolicySnapshot reads the mappings and policy documents of a submitter
|
||||
// at one cache revision while cached authentication and authorization
|
||||
// proceed.
|
||||
//
|
||||
// It holds the store's writer mutex, so no writer changes the cache between
|
||||
// its reads, and the cache read lock, which it releases around each storage
|
||||
// read: a lazy mapping load or a policy document load. Request
|
||||
// authentication then neither waits for storage nor queues behind a writer
|
||||
// waiting for the cache lock, which taking the write lock back after each
|
||||
// read would cause. It writes nothing to the cache but the lazily loaded
|
||||
// mappings, which the request path stores under the read lock as well.
|
||||
//
|
||||
//msgp:ignore batchPolicySnapshot
|
||||
type batchPolicySnapshot struct {
|
||||
*IAMStoreSys
|
||||
ctx context.Context
|
||||
cache *iamCache
|
||||
mu sync.Mutex
|
||||
reads int // storage reads in progress; the read lock is released while any
|
||||
}
|
||||
|
||||
// batchSnapshot takes the snapshot; release it with release. rlock and
|
||||
// runlock are the cache's read lock (IAMStorageAPI), not IAMStoreSys.lock,
|
||||
// which also takes writeMu.
|
||||
func (store *IAMStoreSys) batchSnapshot(ctx context.Context) *batchPolicySnapshot {
|
||||
store.writeMu.Lock()
|
||||
return &batchPolicySnapshot{IAMStoreSys: store, ctx: ctx, cache: store.rlock()}
|
||||
}
|
||||
|
||||
func (s *batchPolicySnapshot) release() {
|
||||
s.runlock()
|
||||
s.writeMu.Unlock()
|
||||
}
|
||||
|
||||
// read runs one storage read without the cache lock, bounded by the job's
|
||||
// context and batchIAMLoadTimeout. Concurrent reads (the group fallback of
|
||||
// policyDBGetGroups) release the lock before the first and take it back
|
||||
// after the last.
|
||||
func (s *batchPolicySnapshot) read(fn func(ctx context.Context) error) error {
|
||||
s.mu.Lock()
|
||||
if s.reads == 0 {
|
||||
s.runlock()
|
||||
}
|
||||
s.reads++
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.reads--
|
||||
if s.reads == 0 {
|
||||
s.rlock()
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(s.ctx, batchIAMLoadTimeout)
|
||||
defer cancel()
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
func (s *batchPolicySnapshot) loadMappedPolicy(_ context.Context, name string, userType IAMUserType, isGroup bool, m *xsync.MapOf[string, MappedPolicy]) error {
|
||||
return s.read(func(ctx context.Context) error {
|
||||
return s.IAMStoreSys.loadMappedPolicy(ctx, name, userType, isGroup, m)
|
||||
})
|
||||
}
|
||||
|
||||
// loadGroup would write the cache under the read lock. The snapshot resolves
|
||||
// the policies of users, which never loads a group.
|
||||
func (s *batchPolicySnapshot) loadGroup(context.Context, string, map[string]GroupInfo) error {
|
||||
return errors.New("batch authorization does not load groups")
|
||||
}
|
||||
|
||||
func (s *batchPolicySnapshot) loadPolicyDoc(name string, out map[string]PolicyDoc) error {
|
||||
return s.read(func(ctx context.Context) error { return s.IAMStoreSys.loadPolicyDoc(ctx, name, out) })
|
||||
}
|
||||
|
||||
// batchLocalPolicies returns every local policy that can take part in the
|
||||
// decision on args: the mapped policies that IsAllowed would merge when
|
||||
// withMapped is set (no authorization plugin), and the session policy.
|
||||
func (sys *IAMSys) batchLocalPolicies(p batchJobPrincipal, args policy.Args, withMapped bool, snap *batchPolicySnapshot) ([]policy.Policy, error) {
|
||||
var out []policy.Policy
|
||||
if withMapped && args.IsOwner {
|
||||
// IsAllowed does not evaluate any policy for the owner.
|
||||
return nil, nil
|
||||
}
|
||||
if withMapped {
|
||||
// The caller holds snap through evaluation. Lazy mapping loads and
|
||||
// selected documents therefore belong to one cache revision.
|
||||
cache := snap.cache
|
||||
mappedPolicies := func(name string) ([]string, error) {
|
||||
names, _, err := cache.policyDBGet(snap, name, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups, err := cache.policyDBGetGroups(snap, len(names) > 0, args.Groups...)
|
||||
return append(names, groups...), err
|
||||
}
|
||||
var names []string
|
||||
ownerDerived := (p.temp || p.service) && p.parentUser == globalActiveCred.AccessKey
|
||||
switch {
|
||||
case p.typ == batchSubmitterRoot || ownerDerived:
|
||||
case p.temp || p.service:
|
||||
if roleArn := args.GetRoleArn(); roleArn != "" {
|
||||
a, err := arn.Parse(roleArn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, newMappedPolicy(sys.rolesMap[a]).toSlice()...)
|
||||
} else {
|
||||
mapped, err := mappedPolicies(p.parentUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, mapped...)
|
||||
// The claimed policies apply only when nothing is mapped.
|
||||
if len(mapped) == 0 {
|
||||
if claimed, ok := policy.GetPoliciesFromClaims(args.Claims, iamPolicyClaimNameOpenID()); ok {
|
||||
names = append(names, claimed.ToSlice()...)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
mapped, err := mappedPolicies(p.accessKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, mapped...)
|
||||
}
|
||||
var selected []policy.Policy
|
||||
for _, name := range names {
|
||||
doc, ok := cache.iamPolicyDocsMap[name]
|
||||
if !ok {
|
||||
loaded := make(map[string]PolicyDoc)
|
||||
err := snap.loadPolicyDoc(name, loaded)
|
||||
if errors.Is(err, errNoSuchPolicy) {
|
||||
// Confirmed absent, as when a mapping still names a
|
||||
// deleted policy: it takes no part in the decision, as
|
||||
// in MergePolicies. Any other failure could hide a Deny.
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, ok = loaded[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("load IAM policy %q returned no document", name)
|
||||
}
|
||||
}
|
||||
selected = append(selected, doc.Policy)
|
||||
}
|
||||
out = append(out, policy.MergePolicies(selected...))
|
||||
}
|
||||
if p.temp || p.service {
|
||||
if p.service && p.claims[iamPolicyClaimNameSA()] == inheritedPolicyType {
|
||||
return out, nil
|
||||
}
|
||||
if sp, ok := p.claims[sessionPolicyNameExtracted]; ok {
|
||||
s, ok := sp.(string)
|
||||
if !ok {
|
||||
return nil, errAuthentication
|
||||
}
|
||||
parsed, err := policy.ParseConfig(strings.NewReader(s))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *parsed)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// batchStatementNeedsRequest reports whether a statement that applies to
|
||||
// args depends on a condition key that the job does not supply. Applying
|
||||
// such a statement without its value could grant (Allow) or drop (Deny)
|
||||
// what a request with that context would not, so the job refuses the action.
|
||||
// Resources are matched without conditions; a statement is taken to apply
|
||||
// whenever it might.
|
||||
func batchStatementNeedsRequest(st policy.Statement, args policy.Args, supplied map[string]bool) bool {
|
||||
if (!st.Actions.Match(args.Action) && !st.Actions.IsEmpty()) || st.NotActions.Match(args.Action) {
|
||||
return false
|
||||
}
|
||||
// Policy variables in resources or condition values.
|
||||
if data, err := json.Marshal(st); err == nil {
|
||||
s := string(data)
|
||||
for {
|
||||
i := strings.Index(s, "${")
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
s = s[i+2:]
|
||||
j := strings.IndexByte(s, '}')
|
||||
if j < 0 {
|
||||
break
|
||||
}
|
||||
if !batchKeySupplied(supplied, s[:j]) {
|
||||
return true
|
||||
}
|
||||
s = s[j+1:]
|
||||
}
|
||||
}
|
||||
if !batchStatementMayApply(st, args) {
|
||||
return false
|
||||
}
|
||||
for key := range st.Conditions.Keys() {
|
||||
if !batchKeySupplied(supplied, key.String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// batchStatementMayApply matches the resources of a statement, in both forms
|
||||
// a bucket request can take. A statement whose resources the evaluator
|
||||
// translates (S3 Tables and vectors actions cover S3 data actions) is taken
|
||||
// to apply.
|
||||
func batchStatementMayApply(st policy.Statement, args policy.Args) bool {
|
||||
for _, actions := range []policy.ActionSet{st.Actions, st.NotActions} {
|
||||
for action := range actions {
|
||||
if policy.TableAction(action).IsValid() || policy.VectorsAction(action).IsValid() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, resources := range []policy.ResourceSet{st.Resources, st.NotResources} {
|
||||
for r := range resources {
|
||||
if r.Type != policy.ResourceARNS3 && r.Type != policy.ResourceARNAll {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
resources := []string{args.BucketName + "/" + strings.TrimPrefix(args.ObjectName, "/")}
|
||||
if args.ObjectName == "" {
|
||||
resources = []string{args.BucketName, args.BucketName + "/"}
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if len(st.Resources) > 0 && !st.Resources.Match(resource, args.ConditionValues) {
|
||||
continue
|
||||
}
|
||||
if len(st.NotResources) > 0 && st.NotResources.Match(resource, args.ConditionValues) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// authorize resolves the submitter and evaluates each request.
|
||||
func (s *batchJobSubmitter) authorize(ctx context.Context, reqs ...batchAuthzRequest) error {
|
||||
p, err := s.principal(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, req := range reqs {
|
||||
if err := p.authorize(req); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startAuthzRequests are the bucket actions a job needs before it acts on any
|
||||
// object: the listings it walks. Object actions are authorized per object.
|
||||
func (j BatchJobRequest) startAuthzRequests() []batchAuthzRequest {
|
||||
list := func(bucket string, prefixes []string) []batchAuthzRequest {
|
||||
if len(prefixes) == 0 {
|
||||
prefixes = []string{""}
|
||||
}
|
||||
var reqs []batchAuthzRequest
|
||||
for _, prefix := range prefixes {
|
||||
values := map[string][]string{condition.S3Prefix.Name(): {prefix}}
|
||||
reqs = append(reqs,
|
||||
batchAuthzRequest{action: policy.ListBucketAction, bucket: bucket, values: values},
|
||||
batchAuthzRequest{action: policy.ListBucketVersionsAction, bucket: bucket, values: values},
|
||||
)
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
// The same order as Type() and the job pool.
|
||||
switch {
|
||||
case j.Replicate != nil:
|
||||
if !j.Replicate.RemoteToLocal() {
|
||||
return list(j.Replicate.Source.Bucket, j.Replicate.Source.Prefix.F())
|
||||
}
|
||||
case j.KeyRotate != nil:
|
||||
return list(j.KeyRotate.Bucket, []string{j.KeyRotate.Prefix})
|
||||
case j.Expire != nil:
|
||||
return list(j.Expire.Bucket, batchCanonicalPrefixes(j.Expire.Prefix.F()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jobSections counts the job types a definition holds. A job is one of them.
|
||||
func (j BatchJobRequest) jobSections() int {
|
||||
n := 0
|
||||
for _, set := range []bool{j.Replicate != nil, j.KeyRotate != nil, j.Expire != nil} {
|
||||
if set {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var errBatchJobSections = batchJobAuthzError{reason: "a batch job must define exactly one job type"}
|
||||
|
||||
// refuse ends the job for its submitter.
|
||||
func (ri *batchJobInfo) refuse(err error) {
|
||||
ri.mu.Lock()
|
||||
defer ri.mu.Unlock()
|
||||
if ri.FailureReason == "" {
|
||||
ri.FailureReason = err.Error()
|
||||
}
|
||||
ri.Complete = false
|
||||
ri.Failed = true
|
||||
ri.PausedReason = ""
|
||||
}
|
||||
|
||||
func (ri *batchJobInfo) refused() bool {
|
||||
ri.mu.RLock()
|
||||
defer ri.mu.RUnlock()
|
||||
return ri.FailureReason != ""
|
||||
}
|
||||
|
||||
// startAuthorized checks the submitter of a job that is about to run. It
|
||||
// returns done for a job that must not run now: one refused before, which
|
||||
// stays refused, or one refused now, which is persisted as failed first.
|
||||
// An error that is not a decision leaves the job to a later start.
|
||||
func (ri *batchJobInfo) startAuthorized(ctx context.Context, api ObjectLayer, job BatchJobRequest) (done bool, err error) {
|
||||
if !ri.refused() {
|
||||
err := job.Submitter.authorize(ctx, job.startAuthzRequests()...)
|
||||
if err == nil && job.jobSections() != 1 {
|
||||
err = errBatchJobSections
|
||||
}
|
||||
if err == nil {
|
||||
ri.mu.Lock()
|
||||
ri.PausedReason = ""
|
||||
ri.mu.Unlock()
|
||||
// Persist the report version before acting. Older binaries must not
|
||||
// resume an authorization-bearing job with server authority.
|
||||
if err := ri.updateAfter(ctx, api, 0, job); err != nil {
|
||||
return true, batchUnavailable("saving job progress", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if isBatchJobUnavailable(err) {
|
||||
return true, ri.savePaused(ctx, api, job, err)
|
||||
}
|
||||
if !isBatchJobAuthzError(err) {
|
||||
return true, err
|
||||
}
|
||||
ri.refuse(err)
|
||||
batchLogIf(ctx, fmt.Errorf("batch job %s refused: %w", job.ID, err))
|
||||
}
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
if err := ri.updateAfter(ctx, api, 0, job); err != nil {
|
||||
return true, batchUnavailable("saving job refusal", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/tinylib/msgp/msgp"
|
||||
)
|
||||
|
||||
// DecodeMsg implements msgp.Decodable
|
||||
func (z *batchJobSubmitter) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, err = dc.ReadMapHeader()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, err = dc.ReadMapKeyPtr()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "v":
|
||||
z.Version, err = dc.ReadInt()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Version")
|
||||
return
|
||||
}
|
||||
case "t":
|
||||
z.Type, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Type")
|
||||
return
|
||||
}
|
||||
case "ak":
|
||||
z.AccessKey, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AccessKey")
|
||||
return
|
||||
}
|
||||
case "pu":
|
||||
z.ParentUser, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "ParentUser")
|
||||
return
|
||||
}
|
||||
case "ua":
|
||||
z.UpdatedAt, err = dc.ReadTime()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "UpdatedAt")
|
||||
return
|
||||
}
|
||||
case "rb":
|
||||
z.RevokedBefore, err = dc.ReadTime()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "RevokedBefore")
|
||||
return
|
||||
}
|
||||
case "bd":
|
||||
z.Binding, err = dc.ReadBytes(z.Binding)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Binding")
|
||||
return
|
||||
}
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// EncodeMsg implements msgp.Encodable
|
||||
func (z *batchJobSubmitter) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
// map header, size 7
|
||||
// write "v"
|
||||
err = en.Append(0x87, 0xa1, 0x76)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteInt(z.Version)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Version")
|
||||
return
|
||||
}
|
||||
// write "t"
|
||||
err = en.Append(0xa1, 0x74)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteString(z.Type)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Type")
|
||||
return
|
||||
}
|
||||
// write "ak"
|
||||
err = en.Append(0xa2, 0x61, 0x6b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteString(z.AccessKey)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AccessKey")
|
||||
return
|
||||
}
|
||||
// write "pu"
|
||||
err = en.Append(0xa2, 0x70, 0x75)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteString(z.ParentUser)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "ParentUser")
|
||||
return
|
||||
}
|
||||
// write "ua"
|
||||
err = en.Append(0xa2, 0x75, 0x61)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteTime(z.UpdatedAt)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "UpdatedAt")
|
||||
return
|
||||
}
|
||||
// write "rb"
|
||||
err = en.Append(0xa2, 0x72, 0x62)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteTime(z.RevokedBefore)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "RevokedBefore")
|
||||
return
|
||||
}
|
||||
// write "bd"
|
||||
err = en.Append(0xa2, 0x62, 0x64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteBytes(z.Binding)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Binding")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// MarshalMsg implements msgp.Marshaler
|
||||
func (z *batchJobSubmitter) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.Require(b, z.Msgsize())
|
||||
// map header, size 7
|
||||
// string "v"
|
||||
o = append(o, 0x87, 0xa1, 0x76)
|
||||
o = msgp.AppendInt(o, z.Version)
|
||||
// string "t"
|
||||
o = append(o, 0xa1, 0x74)
|
||||
o = msgp.AppendString(o, z.Type)
|
||||
// string "ak"
|
||||
o = append(o, 0xa2, 0x61, 0x6b)
|
||||
o = msgp.AppendString(o, z.AccessKey)
|
||||
// string "pu"
|
||||
o = append(o, 0xa2, 0x70, 0x75)
|
||||
o = msgp.AppendString(o, z.ParentUser)
|
||||
// string "ua"
|
||||
o = append(o, 0xa2, 0x75, 0x61)
|
||||
o = msgp.AppendTime(o, z.UpdatedAt)
|
||||
// string "rb"
|
||||
o = append(o, 0xa2, 0x72, 0x62)
|
||||
o = msgp.AppendTime(o, z.RevokedBefore)
|
||||
// string "bd"
|
||||
o = append(o, 0xa2, 0x62, 0x64)
|
||||
o = msgp.AppendBytes(o, z.Binding)
|
||||
return
|
||||
}
|
||||
|
||||
// UnmarshalMsg implements msgp.Unmarshaler
|
||||
func (z *batchJobSubmitter) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
var field []byte
|
||||
_ = field
|
||||
var zb0001 uint32
|
||||
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
for zb0001 > 0 {
|
||||
zb0001--
|
||||
field, bts, err = msgp.ReadMapKeyZC(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
switch msgp.UnsafeString(field) {
|
||||
case "v":
|
||||
z.Version, bts, err = msgp.ReadIntBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Version")
|
||||
return
|
||||
}
|
||||
case "t":
|
||||
z.Type, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Type")
|
||||
return
|
||||
}
|
||||
case "ak":
|
||||
z.AccessKey, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "AccessKey")
|
||||
return
|
||||
}
|
||||
case "pu":
|
||||
z.ParentUser, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "ParentUser")
|
||||
return
|
||||
}
|
||||
case "ua":
|
||||
z.UpdatedAt, bts, err = msgp.ReadTimeBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "UpdatedAt")
|
||||
return
|
||||
}
|
||||
case "rb":
|
||||
z.RevokedBefore, bts, err = msgp.ReadTimeBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "RevokedBefore")
|
||||
return
|
||||
}
|
||||
case "bd":
|
||||
z.Binding, bts, err = msgp.ReadBytesBytes(bts, z.Binding)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Binding")
|
||||
return
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
o = bts
|
||||
return
|
||||
}
|
||||
|
||||
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
|
||||
func (z *batchJobSubmitter) Msgsize() (s int) {
|
||||
s = 1 + 2 + msgp.IntSize + 2 + msgp.StringPrefixSize + len(z.Type) + 3 + msgp.StringPrefixSize + len(z.AccessKey) + 3 + msgp.StringPrefixSize + len(z.ParentUser) + 3 + msgp.TimeSize + 3 + msgp.TimeSize + 3 + msgp.BytesPrefixSize + len(z.Binding)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/tinylib/msgp/msgp"
|
||||
)
|
||||
|
||||
func TestMarshalUnmarshalbatchJobSubmitter(t *testing.T) {
|
||||
v := batchJobSubmitter{}
|
||||
bts, err := v.MarshalMsg(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
left, err := v.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) > 0 {
|
||||
t.Errorf("%d bytes left over after UnmarshalMsg(): %q", len(left), left)
|
||||
}
|
||||
|
||||
left, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) > 0 {
|
||||
t.Errorf("%d bytes left over after Skip(): %q", len(left), left)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarshalMsgbatchJobSubmitter(b *testing.B) {
|
||||
v := batchJobSubmitter{}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v.MarshalMsg(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAppendMsgbatchJobSubmitter(b *testing.B) {
|
||||
v := batchJobSubmitter{}
|
||||
bts := make([]byte, 0, v.Msgsize())
|
||||
bts, _ = v.MarshalMsg(bts[0:0])
|
||||
b.SetBytes(int64(len(bts)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bts, _ = v.MarshalMsg(bts[0:0])
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshalbatchJobSubmitter(b *testing.B) {
|
||||
v := batchJobSubmitter{}
|
||||
bts, _ := v.MarshalMsg(nil)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(bts)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := v.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodebatchJobSubmitter(t *testing.T) {
|
||||
v := batchJobSubmitter{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
|
||||
m := v.Msgsize()
|
||||
if buf.Len() > m {
|
||||
t.Log("WARNING: TestEncodeDecodebatchJobSubmitter Msgsize() is inaccurate")
|
||||
}
|
||||
|
||||
vn := batchJobSubmitter{}
|
||||
err := msgp.Decode(&buf, &vn)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
msgp.Encode(&buf, &v)
|
||||
err = msgp.NewReader(&buf).Skip()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEncodebatchJobSubmitter(b *testing.B) {
|
||||
v := batchJobSubmitter{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
b.SetBytes(int64(buf.Len()))
|
||||
en := msgp.NewWriter(msgp.Nowhere)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v.EncodeMsg(en)
|
||||
}
|
||||
en.Flush()
|
||||
}
|
||||
|
||||
func BenchmarkDecodebatchJobSubmitter(b *testing.B) {
|
||||
v := batchJobSubmitter{}
|
||||
var buf bytes.Buffer
|
||||
msgp.Encode(&buf, &v)
|
||||
b.SetBytes(int64(buf.Len()))
|
||||
rd := msgp.NewEndlessReader(buf.Bytes(), b)
|
||||
dc := msgp.NewReader(rd)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
err := v.DecodeMsg(dc)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
)
|
||||
|
||||
type batchDiscoveryLayer struct {
|
||||
ObjectLayer
|
||||
walk func(context.Context, chan<- itemOrErr[ObjectInfo]) error
|
||||
read func(context.Context, string) ([]byte, error)
|
||||
}
|
||||
|
||||
func (a batchDiscoveryLayer) Walk(ctx context.Context, _, _ string, results chan<- itemOrErr[ObjectInfo], _ WalkOptions) error {
|
||||
return a.walk(ctx, results)
|
||||
}
|
||||
|
||||
func (a batchDiscoveryLayer) GetObjectNInfo(ctx context.Context, _, object string, _ *HTTPRangeSpec, _ http.Header, opts ObjectOptions) (*GetObjectReader, error) {
|
||||
data, err := a.read(ctx, object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewGetObjectReaderFromReader(bytes.NewReader(data), ObjectInfo{Name: object}, opts)
|
||||
}
|
||||
|
||||
// A partially successful startup scan must retry the missing work without
|
||||
// re-admitting work that finished while the scan was waiting to retry.
|
||||
func TestBatchDiscoveryRecoversInProcess(t *testing.T) {
|
||||
for _, failure := range []string{"walk-start", "walk-stream", "manifest-read"} {
|
||||
t.Run(failure, func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
pool := &BatchJobPool{ctx: ctx, jobCh: make(chan *BatchJobRequest, 10)}
|
||||
attempts := 0
|
||||
pool.objLayer = batchDiscoveryLayer{
|
||||
walk: func(_ context.Context, results chan<- itemOrErr[ObjectInfo]) error {
|
||||
attempts++
|
||||
if attempts == 1 && failure == "walk-start" {
|
||||
return errErasureReadQuorum
|
||||
}
|
||||
defer close(results)
|
||||
results <- itemOrErr[ObjectInfo]{Item: ObjectInfo{Name: "finished"}}
|
||||
if attempts == 1 && failure == "walk-stream" {
|
||||
results <- itemOrErr[ObjectInfo]{Err: errErasureReadQuorum}
|
||||
} else {
|
||||
results <- itemOrErr[ObjectInfo]{Item: ObjectInfo{Name: "pending"}}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
read: func(_ context.Context, name string) ([]byte, error) {
|
||||
if attempts == 1 && failure == "manifest-read" && name == "pending" {
|
||||
return nil, errErasureReadQuorum
|
||||
}
|
||||
job := BatchJobRequest{ID: name}
|
||||
return job.MarshalMsg(nil)
|
||||
},
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
pool.resume(func() time.Duration { return 0 })
|
||||
}()
|
||||
synctest.Wait()
|
||||
if failure != "walk-start" {
|
||||
select {
|
||||
case job := <-pool.jobCh:
|
||||
if job.ID != "finished" || !pool.beginJob(job) {
|
||||
t.Fatal("first scan did not admit the healthy manifest")
|
||||
}
|
||||
pool.finishJob(job, nil)
|
||||
default:
|
||||
t.Fatal("first scan lost the healthy manifest")
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(20 * time.Minute):
|
||||
t.Fatal("discovery failed to finish after storage recovered")
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("discovery attempts = %d, want 2", attempts)
|
||||
}
|
||||
want := 1
|
||||
if failure == "walk-start" {
|
||||
want = 2
|
||||
}
|
||||
if len(pool.jobCh) != want {
|
||||
t.Fatalf("queued %d jobs, want %d; completed work was re-admitted or pending work was lost", len(pool.jobCh), want)
|
||||
}
|
||||
for len(pool.jobCh) > 0 {
|
||||
job := <-pool.jobCh
|
||||
if failure != "walk-start" && job.ID != "pending" {
|
||||
t.Fatalf("re-admitted %q", job.ID)
|
||||
}
|
||||
if !pool.beginJob(job) {
|
||||
t.Fatal("recovered job has no owner")
|
||||
}
|
||||
pool.finishJob(job, nil)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDiscoverySkipsUnusableManifests(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
pool := &BatchJobPool{ctx: t.Context(), jobCh: make(chan *BatchJobRequest, 1)}
|
||||
attempts := 0
|
||||
pool.objLayer = batchDiscoveryLayer{
|
||||
walk: func(_ context.Context, results chan<- itemOrErr[ObjectInfo]) error {
|
||||
attempts++
|
||||
defer close(results)
|
||||
for _, name := range []string{"canceled", "deleted", "malformed", "future", "empty-id"} {
|
||||
results <- itemOrErr[ObjectInfo]{Item: ObjectInfo{Name: name}}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
read: func(_ context.Context, name string) ([]byte, error) {
|
||||
switch name {
|
||||
case "deleted":
|
||||
return nil, errConfigNotFound
|
||||
case "malformed":
|
||||
return []byte("bad manifest"), nil
|
||||
case "future":
|
||||
return []byte("SILO-BATCH-V999\n"), nil
|
||||
case "empty-id":
|
||||
return (&BatchJobRequest{}).MarshalMsg(nil)
|
||||
}
|
||||
// The administrator cancels after Walk, during manifest load.
|
||||
mustIAM(t, pool.canceler(name, true))
|
||||
job := BatchJobRequest{ID: name}
|
||||
return job.MarshalMsg(nil)
|
||||
},
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
pool.resume(func() time.Duration { return 0 })
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Hour):
|
||||
t.Fatal("unusable manifests kept discovery retrying")
|
||||
}
|
||||
if len(pool.jobCh) != 0 || attempts != 1 {
|
||||
t.Fatalf("canceled work was queued or retried: queue=%d scans=%d", len(pool.jobCh), attempts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBatchDiscoveryShutdownDuringBackoff(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
attempts := 0
|
||||
pool := &BatchJobPool{ctx: ctx, objLayer: batchDiscoveryLayer{
|
||||
walk: func(context.Context, chan<- itemOrErr[ObjectInfo]) error {
|
||||
attempts++
|
||||
return errErasureReadQuorum
|
||||
},
|
||||
}}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
pool.resume(func() time.Duration { return 0 })
|
||||
}()
|
||||
synctest.Wait()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("discovery abandoned a transient startup error")
|
||||
default:
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("discovery did not stop on shutdown")
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("discovery scanned after shutdown: %d", attempts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBatchDiscoveryKeepsNewlyFinishedJobsTerminal(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
pool := &BatchJobPool{ctx: t.Context(), jobCh: make(chan *BatchJobRequest, 1)}
|
||||
attempts := 0
|
||||
pool.objLayer = batchDiscoveryLayer{
|
||||
walk: func(_ context.Context, results chan<- itemOrErr[ObjectInfo]) error {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
// This job was submitted and failed while startup discovery
|
||||
// had not yet read any manifests. The retry must not rerun it.
|
||||
job := &BatchJobRequest{ID: "new-job"}
|
||||
mustIAM(t, pool.queueJob(job))
|
||||
if !pool.beginJob(<-pool.jobCh) {
|
||||
t.Fatal("submitted job has no owner")
|
||||
}
|
||||
pool.finishJob(job, errErasureWriteQuorum)
|
||||
return errErasureReadQuorum
|
||||
}
|
||||
defer close(results)
|
||||
if owner := pool.jobOwners["new-job"]; owner == nil || owner.state != "terminal" || owner.req != nil || owner.cancel != nil {
|
||||
t.Fatalf("terminal marker lost or retained the job payload: %+v", owner)
|
||||
}
|
||||
results <- itemOrErr[ObjectInfo]{Item: ObjectInfo{Name: "new-job"}}
|
||||
return nil
|
||||
},
|
||||
read: func(_ context.Context, name string) ([]byte, error) {
|
||||
return (&BatchJobRequest{ID: name}).MarshalMsg(nil)
|
||||
},
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
pool.resume(func() time.Duration { return 0 })
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Hour):
|
||||
t.Fatal("discovery did not recover")
|
||||
}
|
||||
if attempts != 2 || len(pool.jobCh) != 0 {
|
||||
t.Fatalf("discovery re-admitted a failed job: scans=%d queue=%d", attempts, len(pool.jobCh))
|
||||
}
|
||||
if !pool.discoveryDone || len(pool.jobOwners) != 0 {
|
||||
t.Fatal("completed discovery retained terminal job markers")
|
||||
}
|
||||
job := &BatchJobRequest{ID: "after-discovery"}
|
||||
mustIAM(t, pool.queueJob(job))
|
||||
if !pool.beginJob(<-pool.jobCh) {
|
||||
t.Fatal("new submission after discovery has no owner")
|
||||
}
|
||||
pool.finishJob(job, nil)
|
||||
if len(pool.jobOwners) != 0 {
|
||||
t.Fatal("normal completion after discovery leaked an owner")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// A completed expire job is not run again when the server restarts, as
|
||||
// replication and key rotation jobs are not: in particular it is not checked
|
||||
// against its submitter again, which could turn it into a failed job.
|
||||
|
||||
// An expire job that completed before the upgrade records no submitter.
|
||||
func TestBatchExpireCompletedLegacyStaysComplete(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
older := f.putVersions(t, "data/a")
|
||||
job := &BatchJobRequest{}
|
||||
mustIAM(t, yaml.Unmarshal([]byte(f.expireYAML("")), job))
|
||||
job.ID = "expire-legacydone" + getKeySeparator() + "0"
|
||||
job.User = globalActiveCred.AccessKey
|
||||
job.Started = time.Now()
|
||||
mustIAM(t, job.save(f.ctx, f.obj))
|
||||
ri := &batchJobInfo{JobID: job.ID, JobType: string(job.Type()), StartTime: job.Started, Complete: true, Objects: 3}
|
||||
mustIAM(t, ri.updateAfter(f.ctx, f.obj, 0, *job))
|
||||
|
||||
loaded := &BatchJobRequest{}
|
||||
mustIAM(t, loaded.load(f.ctx, f.obj, getJobPath(*job)))
|
||||
f.run(t, loaded) // resumed after a restart
|
||||
got := f.jobInfo(t, job)
|
||||
if !got.Complete || got.Failed || got.FailureReason != "" {
|
||||
t.Fatalf("complete=%v failed=%v reason=%q, want the completed state kept", got.Complete, got.Failed, got.FailureReason)
|
||||
}
|
||||
if !f.versionExists(t, "data/a", older) {
|
||||
t.Error("a completed job ran again")
|
||||
}
|
||||
}
|
||||
|
||||
// A new expire job completed with a temporary credential that has expired
|
||||
// since.
|
||||
func TestBatchExpireCompletedAfterCredentialExpiry(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
|
||||
parent := iamAdminUser(f.ctx, t, f.sys, "batch-parent", "batch-full")
|
||||
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{parentClaim: parent.AccessKey, "exp": UTCNow().Add(3 * time.Second).Unix()}, globalActiveCred.SecretKey)
|
||||
mustIAM(t, err)
|
||||
cred.ParentUser = parent.AccessKey
|
||||
_, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "")
|
||||
mustIAM(t, err)
|
||||
older := f.putVersions(t, "data/a")
|
||||
rec, job := f.submit(t, cred, f.expireYAML(""))
|
||||
if job == nil {
|
||||
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
f.run(t, job)
|
||||
if f.versionExists(t, "data/a", older) {
|
||||
t.Fatal("the job did not expire the older version")
|
||||
}
|
||||
if ri := f.jobInfo(t, job); !ri.Complete {
|
||||
t.Fatalf("complete=%v failed=%v", ri.Complete, ri.Failed)
|
||||
}
|
||||
time.Sleep(time.Until(cred.Expiration) + time.Second)
|
||||
|
||||
loaded := &BatchJobRequest{}
|
||||
mustIAM(t, loaded.load(f.ctx, f.obj, getJobPath(*job)))
|
||||
f.run(t, loaded) // resumed after a restart
|
||||
if ri := f.jobInfo(t, job); !ri.Complete || ri.Failed || ri.FailureReason != "" {
|
||||
t.Errorf("complete=%v failed=%v reason=%q after a restart, want the completed state kept", ri.Complete, ri.Failed, ri.FailureReason)
|
||||
}
|
||||
|
||||
// Control: a job that did not complete is still refused once its
|
||||
// credential has expired.
|
||||
f.putVersions(t, "data/b")
|
||||
pending := &BatchJobRequest{}
|
||||
mustIAM(t, yaml.Unmarshal([]byte(f.expireYAML("")), pending))
|
||||
pending.ID = "expire-pending" + getKeySeparator() + "0"
|
||||
pending.User = cred.AccessKey
|
||||
pending.Started = time.Now()
|
||||
pending.Submitter = loaded.Submitter
|
||||
mustIAM(t, pending.save(f.ctx, f.obj))
|
||||
f.run(t, pending)
|
||||
if ri := f.jobInfo(t, pending); ri.Complete || !ri.Failed || ri.FailureReason == "" {
|
||||
t.Errorf("pending job with an expired submitter: complete=%v failed=%v reason=%q, want refused", ri.Complete, ri.Failed, ri.FailureReason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/config/storageclass"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
// A batch expiration that keeps no versions purges each key it matched under
|
||||
// the purge's lock. What it authorizes for the submitter, and checks for
|
||||
// Object Lock, are the versions the purge deletes then, not the versions the
|
||||
// walk saw before.
|
||||
|
||||
// batchPurgeHook runs change once, after the walk of a job and before its
|
||||
// first purge of all versions.
|
||||
type batchPurgeHook struct {
|
||||
ObjectLayer
|
||||
once sync.Once
|
||||
change func()
|
||||
}
|
||||
|
||||
func (h *batchPurgeHook) DeleteObject(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
if opts.DeleteAllVersions {
|
||||
h.once.Do(h.change)
|
||||
}
|
||||
return h.ObjectLayer.DeleteObject(ctx, bucket, object, opts)
|
||||
}
|
||||
|
||||
func (f *batchAuthzFixture) purgeYAML(prefix string) string {
|
||||
return fmt.Sprintf(`expire:
|
||||
apiVersion: v1
|
||||
bucket: %s
|
||||
prefix: %q
|
||||
rules:
|
||||
- type: object
|
||||
purge:
|
||||
retainVersions: 0
|
||||
`, f.bucket, prefix)
|
||||
}
|
||||
|
||||
// runChanged submits a purge of prefix as cred and runs it, with change made
|
||||
// between its walk and its purge, and returns the job's final state.
|
||||
func (f *batchAuthzFixture) runChanged(t *testing.T, cred auth.Credentials, prefix string, change func()) *batchJobInfo {
|
||||
t.Helper()
|
||||
rec, job := f.submit(t, cred, f.purgeYAML(prefix))
|
||||
if job == nil {
|
||||
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
hook := &batchPurgeHook{ObjectLayer: f.obj, change: change}
|
||||
if err := job.Expire.Start(f.ctx, hook, *job); err != nil {
|
||||
t.Logf("start: %v", err)
|
||||
}
|
||||
return f.jobInfo(t, job)
|
||||
}
|
||||
|
||||
// putOlder writes a version of key older than every version it has, as a
|
||||
// replicated or pulled version can be, with metadata meta.
|
||||
func (f *batchAuthzFixture) putOlder(t *testing.T, key, versionID string, meta map[string]string) {
|
||||
t.Helper()
|
||||
data := []byte("older " + key)
|
||||
_, err := f.obj.PutObject(f.ctx, f.bucket, key, mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""),
|
||||
ObjectOptions{Versioned: true, VersionID: versionID, MTime: UTCNow().Add(-time.Hour), UserDefined: meta})
|
||||
mustIAM(t, err)
|
||||
}
|
||||
|
||||
func batchPurgeVersionIDs(t *testing.T, f *batchAuthzFixture, key string) []string {
|
||||
t.Helper()
|
||||
var ids []string
|
||||
marker, vmarker := "", ""
|
||||
for {
|
||||
res, err := f.obj.ListObjectVersions(f.ctx, f.bucket, key, marker, vmarker, "", 1000)
|
||||
mustIAM(t, err)
|
||||
for _, o := range res.Objects {
|
||||
if o.Name == key {
|
||||
ids = append(ids, o.VersionID)
|
||||
}
|
||||
}
|
||||
if !res.IsTruncated {
|
||||
return ids
|
||||
}
|
||||
marker, vmarker = res.NextMarker, res.NextVersionIDMarker
|
||||
}
|
||||
}
|
||||
|
||||
// A version that the submitter may not expire, because of its tags or its
|
||||
// version ID, is refused although the walk did not see it so: the job fails
|
||||
// and the key keeps all its versions.
|
||||
func TestBatchExpirePurgeAuthorizesCurrentVersions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
action string // the action a conditional Deny denies
|
||||
deny string // its condition
|
||||
// change returns the version IDs that change made.
|
||||
change func(t *testing.T, f *batchAuthzFixture, key, older string) []string
|
||||
}{
|
||||
{
|
||||
name: "tagged-after-walk",
|
||||
action: "s3:GetObjectVersion",
|
||||
deny: `{"StringEquals":{"s3:ExistingObjectTag/keep":["yes"]}}`,
|
||||
change: func(t *testing.T, f *batchAuthzFixture, key, older string) []string {
|
||||
_, err := f.obj.PutObjectTags(f.ctx, f.bucket, key, "keep=yes", ObjectOptions{VersionID: older})
|
||||
mustIAM(t, err)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "version-after-walk",
|
||||
action: "s3:DeleteObjectVersion",
|
||||
deny: `{"StringEquals":{"s3:versionid":["%s"]}}`,
|
||||
change: func(t *testing.T, f *batchAuthzFixture, key, older string) []string {
|
||||
f.putOlder(t, key, batchPurgeDeniedVersion, nil)
|
||||
return []string{batchPurgeDeniedVersion}
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
deny := tc.deny
|
||||
if strings.Contains(deny, "%s") {
|
||||
deny = fmt.Sprintf(deny, batchPurgeDeniedVersion)
|
||||
}
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-purge", batchAuthzFullAccess+`,
|
||||
{"Effect":"Deny","Action":["`+tc.action+`"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":`+deny+`}`)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "batch-purge-user", "batch-purge")
|
||||
const key = "purge/key"
|
||||
older := f.putVersions(t, key)
|
||||
want := batchPurgeVersionIDs(t, f, key)
|
||||
ri := f.runChanged(t, cred, "purge/", func() {
|
||||
want = append(want, tc.change(t, f, key, older)...)
|
||||
})
|
||||
for _, id := range want {
|
||||
if !f.versionExists(t, key, id) {
|
||||
t.Errorf("version %s was purged although the submitter may not delete it", id)
|
||||
}
|
||||
}
|
||||
if !ri.Failed || ri.Complete || ri.FailureReason == "" {
|
||||
t.Errorf("job complete=%v failed=%v reason=%q, want a refused job", ri.Complete, ri.Failed, ri.FailureReason)
|
||||
}
|
||||
if ri.Objects != 0 || ri.KeysRefused != 0 {
|
||||
t.Errorf("job counts objects=%d refused=%d", ri.Objects, ri.KeysRefused)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const batchPurgeDeniedVersion = "6c4b2f8e-1d3a-4e5b-9c7d-0a1b2c3d4e5f"
|
||||
|
||||
// A version that Object Lock protects refuses the whole key, also when it
|
||||
// was added after the walk. The job completes with the key counted as
|
||||
// refused, not failed.
|
||||
func TestBatchExpirePurgeRefusesLockedAfterWalk(t *testing.T) {
|
||||
for _, kind := range batchLockKinds {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
enableBucketObjectLock(t, f.bucket)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "batch-user", "batch-full")
|
||||
const key = "purge/locked"
|
||||
f.putVersions(t, key)
|
||||
want := batchPurgeVersionIDs(t, f, key)
|
||||
locked := mustGetUUID()
|
||||
ri := f.runChanged(t, cred, "purge/", func() {
|
||||
f.putOlder(t, key, locked, batchLockMeta(kind))
|
||||
want = append(want, locked)
|
||||
})
|
||||
for _, id := range want {
|
||||
if !f.versionExists(t, key, id) {
|
||||
t.Errorf("version %s of a key with a locked version was purged", id)
|
||||
}
|
||||
}
|
||||
if !ri.Complete || ri.Failed || ri.KeysRefused != 1 || ri.ObjectsFailed != 0 || ri.Objects != 0 {
|
||||
t.Errorf("job complete=%v failed=%v refused=%d failed objects=%d objects=%d, want complete with 1 refused key",
|
||||
ri.Complete, ri.Failed, ri.KeysRefused, ri.ObjectsFailed, ri.Objects)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// transition marks version versionID of key as transitioned to a remote
|
||||
// tier, as a lifecycle transition leaves it.
|
||||
func (f *batchAuthzFixture) transition(t *testing.T, key, versionID string) {
|
||||
t.Helper()
|
||||
set := f.obj.(*erasureServerPools).serverPools[0]
|
||||
infos, errs := readAllFileInfo(f.ctx, set.getHashedSet(key).getDisks(), "", f.bucket, key, versionID, false, false)
|
||||
mustIAM(t, errs[0])
|
||||
fi := infos[0]
|
||||
fi.TransitionStatus, fi.TransitionTier, fi.TransitionedObjName = lifecycle.TransitionComplete, "WARM", "remote/"+key+"/"+versionID
|
||||
mustIAM(t, set.DecomTieredObject(f.ctx, f.bucket, key, fi, ObjectOptions{}))
|
||||
}
|
||||
|
||||
// A key with a transitioned version is refused as a whole, as a key with a
|
||||
// locked version is: the job completes with the key counted as refused, not
|
||||
// failed, and the key keeps every version.
|
||||
func TestBatchExpirePurgeRefusesTieredKey(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "batch-user", "batch-full")
|
||||
const key = "purge/tiered"
|
||||
older := f.putVersions(t, key)
|
||||
f.transition(t, key, older)
|
||||
want := batchPurgeVersionIDs(t, f, key)
|
||||
ri := f.runChanged(t, cred, "purge/", func() {})
|
||||
for _, id := range want {
|
||||
if !f.versionExists(t, key, id) {
|
||||
t.Errorf("version %s of a key with a transitioned version was purged", id)
|
||||
}
|
||||
}
|
||||
if !ri.Complete || ri.Failed || ri.KeysRefused != 1 || ri.ObjectsFailed != 0 || ri.Objects != 0 {
|
||||
t.Errorf("job complete=%v failed=%v refused=%d failed objects=%d objects=%d, want complete with 1 refused key",
|
||||
ri.Complete, ri.Failed, ri.KeysRefused, ri.ObjectsFailed, ri.Objects)
|
||||
}
|
||||
}
|
||||
|
||||
// Controls: a purge by root and by an authorized user removes every version,
|
||||
// including one added after the walk.
|
||||
func TestBatchExpirePurgeAuthorizedControls(t *testing.T) {
|
||||
for _, root := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("root=%v", root), func(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
cred := globalActiveCred
|
||||
if !root {
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
|
||||
cred = iamAdminUser(f.ctx, t, f.sys, "batch-user", "batch-full")
|
||||
}
|
||||
const key = "purge/key"
|
||||
f.putVersions(t, key)
|
||||
added := mustGetUUID()
|
||||
ri := f.runChanged(t, cred, "purge/", func() {
|
||||
f.putOlder(t, key, added, map[string]string{xhttp.AmzObjectTagging: "keep=yes"})
|
||||
})
|
||||
if ids := batchPurgeVersionIDs(t, f, key); len(ids) != 0 {
|
||||
t.Errorf("an authorized purge kept %v", ids)
|
||||
}
|
||||
if !ri.Complete || ri.Failed || ri.Objects != 3 || ri.KeysRefused != 0 {
|
||||
t.Errorf("job complete=%v failed=%v objects=%d refused=%d, want complete with 3 objects",
|
||||
ri.Complete, ri.Failed, ri.Objects, ri.KeysRefused)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Cases found in the sixth review round.
|
||||
|
||||
// batchPurgeBudgetChange runs change once the first purge stopped at its
|
||||
// budget after removing versions.
|
||||
type batchPurgeBudgetChange struct {
|
||||
ObjectLayer
|
||||
change func()
|
||||
calls int
|
||||
}
|
||||
|
||||
func (h *batchPurgeBudgetChange) DeleteObject(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
oi, err := h.ObjectLayer.DeleteObject(ctx, bucket, object, opts)
|
||||
if opts.DeleteAllVersions {
|
||||
h.calls++
|
||||
if h.calls == 1 && errors.Is(err, errDeleteAllBudget) && opts.DeleteAllResult.Removed > 0 {
|
||||
h.change()
|
||||
}
|
||||
}
|
||||
return oi, err
|
||||
}
|
||||
|
||||
// A purge that continues after its budget authorizes the versions again, as
|
||||
// they are then; the refusal and the versions already removed persist
|
||||
// across another start.
|
||||
func TestBatchExpirePurgeBudgetReauthorizes(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
const key = "purge/budget"
|
||||
oldest := batchPutVersion(t, f.obj, f.bucket, key, nil)
|
||||
middle := batchPutVersion(t, f.obj, f.bucket, key, nil)
|
||||
latest := batchPutVersion(t, f.obj, f.bucket, key, nil)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "review6-budget-deny", batchAuthzFullAccess+`,
|
||||
{"Effect":"Deny","Action":["s3:GetObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/keep":"yes"}}}`)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "review6-budget-user", "review6-budget-deny")
|
||||
rec, job := f.submit(t, cred, f.purgeYAML("purge/"))
|
||||
if job == nil {
|
||||
t.Fatalf("submit: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
oldBudget := deleteAllBudget
|
||||
deleteAllBudget.versions = 1
|
||||
t.Cleanup(func() { deleteAllBudget = oldBudget })
|
||||
hook := &batchPurgeBudgetChange{ObjectLayer: f.obj, change: func() {
|
||||
_, err := f.obj.PutObjectTags(f.ctx, f.bucket, key, "keep=yes", ObjectOptions{VersionID: middle.VersionID})
|
||||
mustIAM(t, err)
|
||||
}}
|
||||
mustIAM(t, job.Expire.Start(f.ctx, hook, *job))
|
||||
ri := f.jobInfo(t, job)
|
||||
if hook.calls != 2 || !ri.Failed || ri.Complete || ri.FailureReason == "" || ri.Objects != 1 || ri.KeysRefused != 0 {
|
||||
t.Fatalf("continuation: calls=%d complete=%v failed=%v reason=%q objects=%d refused=%d", hook.calls, ri.Complete, ri.Failed, ri.FailureReason, ri.Objects, ri.KeysRefused)
|
||||
}
|
||||
if f.versionExists(t, key, oldest.VersionID) || !f.versionExists(t, key, middle.VersionID) || !f.versionExists(t, key, latest.VersionID) {
|
||||
t.Fatal("only the first authorized version may be removed")
|
||||
}
|
||||
_, err := f.obj.DeleteObjectTags(f.ctx, f.bucket, key, ObjectOptions{VersionID: middle.VersionID})
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, job.Expire.Start(f.ctx, hook, *job))
|
||||
after := f.jobInfo(t, job)
|
||||
if hook.calls != 2 || after.FailureReason != ri.FailureReason || after.Objects != 1 || after.Complete {
|
||||
t.Fatal("persisted refusal must prevent a fresh purge after the tags change")
|
||||
}
|
||||
}
|
||||
|
||||
// The purge's lock excludes tag writers from its authorization through its
|
||||
// last delete, for a directory key in a lock domain of its own on one pool
|
||||
// and for a key in a second pool.
|
||||
func TestBatchExpirePurgeExcludesTagWriter(t *testing.T) {
|
||||
for _, mode := range []string{ldDsync, "two-pools"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
var z *erasureServerPools
|
||||
var bucket string
|
||||
pool, key := 0, "purge/tags/"
|
||||
if mode == "two-pools" {
|
||||
z, bucket = consistencyPools(t)
|
||||
pool = 1
|
||||
} else {
|
||||
z, bucket = ldPool(t, mode)
|
||||
for i := 0; ; i++ {
|
||||
key = fmt.Sprintf("purge/tags-%d/", i)
|
||||
if z.serverPools[0].getHashedSetIndex(encodeDirObject(key)) == 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
before := putConsistencyObject(t, z, bucket, encodeDirObject(key), pool, "", ObjectOptions{Versioned: true})
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
var released atomic.Bool
|
||||
unblock := func() {
|
||||
if released.CompareAndSwap(false, true) {
|
||||
close(release)
|
||||
}
|
||||
}
|
||||
defer unblock()
|
||||
purged := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := z.DeleteObject(ctx, bucket, key, ObjectOptions{
|
||||
DeleteAllVersions: true,
|
||||
DeleteAllLatest: &deleteAllMatch{VersionID: before.VersionID, ModTime: before.ModTime},
|
||||
deleteAllAuthorize: func(versions []ObjectInfo) error {
|
||||
close(entered)
|
||||
select {
|
||||
case <-release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
},
|
||||
})
|
||||
purged <- err
|
||||
}()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("purge never reached authorization")
|
||||
}
|
||||
written := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := z.PutObjectTags(ctx, bucket, key, "keep=yes", ObjectOptions{VersionID: before.VersionID})
|
||||
written <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-written:
|
||||
t.Errorf("tag writer crossed the purge's authorization lock: %v", err)
|
||||
case <-time.After(ldWait):
|
||||
unblock()
|
||||
err := <-written
|
||||
if !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
t.Errorf("writer must observe the completed purge: %v", err)
|
||||
}
|
||||
}
|
||||
unblock()
|
||||
mustIAM(t, <-purged)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// batchTagWriteFault fails metadata updates of one key, so that a tag write
|
||||
// reaches only the drives it does not wrap.
|
||||
type batchTagWriteFault struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
}
|
||||
|
||||
func (d batchTagWriteFault) UpdateMetadata(ctx context.Context, bucket, object string, fi FileInfo, opts UpdateMetadataOpts) error {
|
||||
if bucket == d.bucket && object == d.object {
|
||||
return errDiskNotFound
|
||||
}
|
||||
return d.StorageAPI.UpdateMetadata(ctx, bucket, object, fi, opts)
|
||||
}
|
||||
|
||||
// A failed tag write can leave a revision that the submitter may not expire
|
||||
// on a minority of drives. The purge removes it with the version, so it is
|
||||
// authorized too.
|
||||
func TestBatchExpirePurgeAuthorizesDriveRevisions(t *testing.T) {
|
||||
resetTestGlobals()
|
||||
t.Cleanup(resetTestGlobals)
|
||||
// One drive of sixteen holds the failed tag write, so the read quorum of
|
||||
// the metadata must stay below sixteen. Earlier tests can leave a
|
||||
// storage class of parity 0 behind (newTestConfig on a single drive).
|
||||
previous := globalStorageClass
|
||||
globalStorageClass.Update(storageclass.Config{Standard: storageclass.StorageClass{Parity: 4}})
|
||||
t.Cleanup(func() { globalStorageClass.Update(previous) })
|
||||
z := purgeSinglePool(t, 16)
|
||||
globalIAMSys.initStore(z, nil)
|
||||
mustIAM(t, globalIAMSys.Load(t.Context(), true))
|
||||
f := &batchAuthzFixture{ctx: t.Context(), sys: globalIAMSys, obj: z, bucket: "review6-batch-authz"}
|
||||
mustIAM(t, z.MakeBucket(f.ctx, f.bucket, MakeBucketOptions{VersioningEnabled: true}))
|
||||
batchTestJobPool(f.ctx, t, z)
|
||||
const key = "purge/minority-tags"
|
||||
older := f.putVersions(t, key)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "review6-minority-deny", strings.ReplaceAll(batchAuthzFullAccess+`,
|
||||
{"Effect":"Deny","Action":["s3:GetObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/keep":"yes"}}}`, "batch-authz", f.bucket))
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "review6-minority-user", "review6-minority-deny")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.GetObjectVersionAction, bucket: f.bucket, object: key, versionID: older, tags: "keep=yes"}); !isBatchJobAuthzError(err) {
|
||||
t.Fatal("control: this tag revision must deny the read needed for expiration")
|
||||
}
|
||||
set := z.serverPools[0].getHashedSet(key)
|
||||
original := set.getDisks
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
for i := 1; i < len(wrapped); i++ {
|
||||
wrapped[i] = batchTagWriteFault{StorageAPI: wrapped[i], bucket: f.bucket, object: key}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return wrapped }
|
||||
_, err = f.obj.PutObjectTags(f.ctx, f.bucket, key, "keep=yes", ObjectOptions{VersionID: older, UserDefined: map[string]string{
|
||||
ReservedMetadataPrefixLower + TaggingTimestamp: UTCNow().Format(time.RFC3339Nano),
|
||||
}})
|
||||
set.getDisks = original
|
||||
if err == nil {
|
||||
t.Fatal("control: the tag write must fail its quorum")
|
||||
}
|
||||
fi, err := disks[0].ReadVersion(f.ctx, "", f.bucket, key, older, ReadOptions{})
|
||||
mustIAM(t, err)
|
||||
if fi.Metadata[xhttp.AmzObjectTagging] != "keep=yes" {
|
||||
t.Fatal("control: the successful drive must hold the denied tag revision")
|
||||
}
|
||||
history, err := z.deleteAllHistory(f.ctx, f.bucket, key)
|
||||
mustIAM(t, err)
|
||||
for _, v := range history {
|
||||
for _, c := range v.copies {
|
||||
if c.fi.Metadata[xhttp.AmzObjectTagging] != "" {
|
||||
t.Fatal("control: the quorum history must still have the original tags")
|
||||
}
|
||||
}
|
||||
}
|
||||
ri := f.runChanged(t, cred, "purge/", func() {})
|
||||
if !ri.Failed || ri.Complete || !strings.Contains(ri.FailureReason, "is not allowed") {
|
||||
t.Errorf("denied physical revision must refuse the job: complete=%v failed=%v reason=%q", ri.Complete, ri.Failed, ri.FailureReason)
|
||||
}
|
||||
if _, err := disks[0].ReadVersion(f.ctx, "", f.bucket, key, older, ReadOptions{}); err != nil {
|
||||
t.Errorf("denied physical revision was removed: %v", err)
|
||||
}
|
||||
if got := batchPurgeVersionIDs(t, f, key); len(got) != 2 {
|
||||
t.Errorf("denial must keep the whole key; remaining versions=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Cases found in the seventh review round.
|
||||
|
||||
// The time the authorization takes counts against the purge's budget: a
|
||||
// late allow and a deny both delete nothing.
|
||||
func TestBatchExpirePurgeBudgetIncludesAuthorization(t *testing.T) {
|
||||
z := purgeSinglePool(t, 16)
|
||||
p := newPurgeTest(t, z, "review7-purge-auth-time", MakeBucketOptions{VersioningEnabled: true}, "")
|
||||
const key = "purge/key"
|
||||
p.version(0, key, "older", 2)
|
||||
p.version(0, key, "latest", 1)
|
||||
exp := p.matched(key)
|
||||
oldBudget := deleteAllBudget
|
||||
deleteAllBudget.duration = 10 * time.Millisecond
|
||||
t.Cleanup(func() { deleteAllBudget = oldBudget })
|
||||
denied := errors.New("review7: revision denied")
|
||||
for _, decision := range []error{nil, denied} {
|
||||
called := false
|
||||
var res deleteAllResult
|
||||
_, err := z.DeleteObject(t.Context(), p.bucket, key, ObjectOptions{
|
||||
DeleteAllVersions: true,
|
||||
DeleteAllLatest: &deleteAllMatch{VersionID: exp.VersionID, ModTime: exp.ModTime},
|
||||
DeleteAllResult: &res,
|
||||
deleteAllAuthorize: func(versions []ObjectInfo) error {
|
||||
called = true
|
||||
if len(versions) < 2 {
|
||||
t.Error("authorization did not receive both approved versions")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return decision
|
||||
},
|
||||
})
|
||||
want := decision
|
||||
if want == nil {
|
||||
want = errDeleteAllBudget
|
||||
}
|
||||
if !called || !errors.Is(err, want) || res.Started || res.Complete || res.Removed != 0 {
|
||||
t.Errorf("late authorization: called=%v err=%v result=%+v", called, err, res)
|
||||
}
|
||||
if got := p.held(0, key); len(got) != 2 {
|
||||
t.Fatalf("purge removed versions after late authorization: %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A listing outage pauses without moving the cursor or refusing the job;
|
||||
// a later run completes it in both modes.
|
||||
func TestBatchExpireWalkFailureResumes(t *testing.T) {
|
||||
for _, purgeAll := range []bool{false, true} {
|
||||
name := "retain-one"
|
||||
if purgeAll {
|
||||
name = "retain-zero"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
const key = "data/key"
|
||||
older := f.putVersions(t, key)
|
||||
definition := f.expireYAML("data/")
|
||||
if purgeAll {
|
||||
definition = f.purgeYAML("data/")
|
||||
}
|
||||
rec, job := f.submit(t, globalActiveCred, definition)
|
||||
if job == nil {
|
||||
t.Fatalf("submit: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
listErr := errors.New("review7: transient listing failure")
|
||||
err := job.Expire.Start(f.ctx, batchWalkError{ObjectLayer: f.obj, err: listErr}, *job)
|
||||
if !errors.Is(err, listErr) {
|
||||
t.Fatalf("listing error: %v", err)
|
||||
}
|
||||
ri := f.jobInfo(t, job)
|
||||
if ri.Failed || ri.Complete || ri.PausedReason == "" || ri.FailureReason != "" || ri.Object != "" || !f.versionExists(t, key, older) {
|
||||
t.Fatalf("listing failure changed progress or became terminal: failed=%v complete=%v reason=%q cursor=%q", ri.Failed, ri.Complete, ri.FailureReason, ri.Object)
|
||||
}
|
||||
mustIAM(t, job.Expire.Start(f.ctx, f.obj, *job))
|
||||
ri = f.jobInfo(t, job)
|
||||
wantRemoved := int64(1)
|
||||
if purgeAll {
|
||||
wantRemoved = 2
|
||||
}
|
||||
if ri.Failed || !ri.Complete || ri.FailureReason != "" || ri.Objects != wantRemoved || f.versionExists(t, key, older) {
|
||||
t.Fatalf("resume did not complete: failed=%v complete=%v reason=%q removed=%d", ri.Failed, ri.Complete, ri.FailureReason, ri.Objects)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+338
-174
@@ -27,6 +27,7 @@ import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7/pkg/tags"
|
||||
@@ -34,6 +35,7 @@ import (
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
xioutil "github.com/minio/minio/internal/ioutil"
|
||||
"github.com/pgsty/silo-pkg/v3/env"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
"github.com/pgsty/silo-pkg/v3/wildcard"
|
||||
"github.com/pgsty/silo-pkg/v3/workers"
|
||||
"github.com/pgsty/silo-pkg/v3/xtime"
|
||||
@@ -289,16 +291,6 @@ type BatchJobExpire struct {
|
||||
|
||||
var _ yaml.Unmarshaler = &BatchJobExpire{}
|
||||
|
||||
// RedactSensitive will redact any sensitive information in b.
|
||||
func (r *BatchJobExpire) RedactSensitive() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
if r.NotificationCfg.Token != "" {
|
||||
r.NotificationCfg.Token = redactedText
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalYAML - BatchJobExpire extends default unmarshal to extract line, col information.
|
||||
func (r *BatchJobExpire) UnmarshalYAML(val *yaml.Node) error {
|
||||
type expireJob BatchJobExpire
|
||||
@@ -345,32 +337,6 @@ func (r BatchJobExpire) Notify(ctx context.Context, body io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Expire expires object versions which have already matched supplied filter conditions
|
||||
func (r *BatchJobExpire) Expire(ctx context.Context, api ObjectLayer, vc *versioning.Versioning, objsToDel []ObjectToDelete) []error {
|
||||
opts := ObjectOptions{
|
||||
PrefixEnabledFn: vc.PrefixEnabled,
|
||||
VersionSuspended: vc.Suspended(),
|
||||
}
|
||||
|
||||
allErrs := make([]error, 0, len(objsToDel))
|
||||
|
||||
for {
|
||||
count := len(objsToDel)
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
if count > maxDeleteList {
|
||||
count = maxDeleteList
|
||||
}
|
||||
_, errs := api.DeleteObjects(ctx, r.Bucket, objsToDel[:count], opts)
|
||||
allErrs = append(allErrs, errs...)
|
||||
// Next batch of deletion
|
||||
objsToDel = objsToDel[count:]
|
||||
}
|
||||
|
||||
return allErrs
|
||||
}
|
||||
|
||||
const (
|
||||
batchExpireName = "batch-expire.bin"
|
||||
batchExpireFormat = 1
|
||||
@@ -381,22 +347,230 @@ const (
|
||||
batchExpireJobDefaultRetryDelay = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
type objInfoCache map[string]*ObjectInfo
|
||||
|
||||
func newObjInfoCache() objInfoCache {
|
||||
return objInfoCache(make(map[string]*ObjectInfo))
|
||||
type expireObjInfo struct {
|
||||
ObjectInfo
|
||||
ExpireAll bool
|
||||
DeleteMarkerCount int64
|
||||
}
|
||||
|
||||
func (oiCache objInfoCache) Add(toDel ObjectToDelete, oi *ObjectInfo) {
|
||||
oiCache[fmt.Sprintf("%s-%s", toDel.ObjectName, toDel.VersionID)] = oi
|
||||
// isErrObjectLocked reports a version that Object Lock protects.
|
||||
func isErrObjectLocked(err error) bool {
|
||||
var locked ObjectLocked
|
||||
return errors.As(err, &locked)
|
||||
}
|
||||
|
||||
func (oiCache objInfoCache) Get(toDel ObjectToDelete) (*ObjectInfo, bool) {
|
||||
oi, ok := oiCache[fmt.Sprintf("%s-%s", toDel.ObjectName, toDel.VersionID)]
|
||||
return oi, ok
|
||||
func (r *BatchJobExpire) tagsFiltered() bool {
|
||||
for _, rule := range r.Rules {
|
||||
if len(rule.Tags) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func batchObjsForDelete(ctx context.Context, r *BatchJobExpire, ri *batchJobInfo, job BatchJobRequest, api ObjectLayer, wk *workers.Workers, expireCh <-chan []expireObjInfo) {
|
||||
// expireAuthzRequests are the actions the job takes on one version: it reads
|
||||
// the version, and its tags when a rule filters by them, and deletes it.
|
||||
func (r *BatchJobExpire) expireAuthzRequests(name, versionID, userTags string) []batchAuthzRequest {
|
||||
if versionID == "" {
|
||||
versionID = nullVersionID
|
||||
}
|
||||
reqs := []batchAuthzRequest{
|
||||
{action: policy.GetObjectAction, bucket: r.Bucket, object: name, versionID: versionID, tags: userTags},
|
||||
{action: policy.GetObjectVersionAction, bucket: r.Bucket, object: name, versionID: versionID, tags: userTags},
|
||||
{action: policy.DeleteObjectAction, bucket: r.Bucket, object: name, versionID: versionID, tags: userTags},
|
||||
{action: policy.DeleteObjectVersionAction, bucket: r.Bucket, object: name, versionID: versionID, tags: userTags},
|
||||
}
|
||||
if r.tagsFiltered() {
|
||||
reqs = append(reqs,
|
||||
batchAuthzRequest{action: policy.GetObjectTaggingAction, bucket: r.Bucket, object: name, versionID: versionID, tags: userTags},
|
||||
batchAuthzRequest{action: policy.GetObjectVersionTaggingAction, bucket: r.Bucket, object: name, versionID: versionID, tags: userTags},
|
||||
)
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
|
||||
// expireVersion deletes one version that is older than the versions the rule
|
||||
// retains. Under the object's write lock, with the version as it is then, it
|
||||
// leaves a version that Object Lock protects (ObjectLocked) and refuses one
|
||||
// that the submitter may not delete (batchJobAuthzError). An unreadable
|
||||
// version is not deleted.
|
||||
func (r *BatchJobExpire) expireVersion(ctx context.Context, api ObjectLayer, vc *versioning.Versioning, job BatchJobRequest, exp expireObjInfo) error {
|
||||
p, err := job.Submitter.principal(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
versionID := exp.VersionID
|
||||
if versionID == "" {
|
||||
// An empty version ID would add a delete marker instead.
|
||||
versionID = nullVersionID
|
||||
}
|
||||
opts := ObjectOptions{
|
||||
VersionID: versionID,
|
||||
Versioned: vc.PrefixEnabled(exp.Name),
|
||||
VersionSuspended: vc.Suspended(),
|
||||
}
|
||||
opts.SetEvalRetentionBypassFn(func(goi ObjectInfo, gerr error) error {
|
||||
// A delete marker reads as MethodNotAllowed; any other error,
|
||||
// including a missing version, stops the delete.
|
||||
marker := isErrMethodNotAllowed(gerr) && (goi.DeleteMarker || !goi.VersionPurgeStatus.Empty())
|
||||
if gerr != nil && !marker {
|
||||
return gerr
|
||||
}
|
||||
if enforceRetentionForDeletion(ctx, goi) {
|
||||
return ObjectLocked{Bucket: r.Bucket, Object: exp.Name, VersionID: versionID}
|
||||
}
|
||||
for _, req := range r.expireAuthzRequests(exp.Name, versionID, goi.UserTags) {
|
||||
if err := p.authorize(req); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
_, err = api.DeleteObject(ctx, r.Bucket, exp.Name, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
// expireVersions deletes the versions of one batch entry and records the
|
||||
// outcome in res. It returns an error that ends the job.
|
||||
func (r *BatchJobExpire) expireVersions(ctx context.Context, api ObjectLayer, vc *versioning.Versioning, job BatchJobRequest, ri *batchJobInfo, exp expireObjInfo, retryAttempts int, delay time.Duration, res *batchCounts) error {
|
||||
for attempts := 1; attempts <= retryAttempts; attempts++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
stopFn := globalBatchJobsMetrics.trace(batchJobMetricExpire, ri.JobID, attempts)
|
||||
err := r.expireVersion(ctx, api, vc, job, exp)
|
||||
stopFn(exp, err)
|
||||
switch {
|
||||
case err == nil:
|
||||
if exp.DeleteMarker {
|
||||
ri.countRemoved(r.Bucket, 0, 1)
|
||||
} else {
|
||||
ri.countRemoved(r.Bucket, 1, 0)
|
||||
}
|
||||
return nil
|
||||
case isBatchJobStopped(err):
|
||||
return err
|
||||
case isErrObjectLocked(err):
|
||||
// Left in place, not retried and not a failure.
|
||||
res.versionsSkipped++
|
||||
return nil
|
||||
case isErrVersionNotFound(err) || isErrObjectNotFound(err):
|
||||
// Removed concurrently.
|
||||
return nil
|
||||
}
|
||||
batchLogIf(ctx, fmt.Errorf("Failed to expire %s/%s versionID=%s due to %v (attempts=%d)", r.Bucket, exp.Name, exp.VersionID, err, attempts))
|
||||
if attempts < retryAttempts {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
if exp.DeleteMarker {
|
||||
res.deleteMarkersFailed++
|
||||
} else {
|
||||
res.objectsFailed++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expireAllVersions purges every version of a key whose rule retains none,
|
||||
// with the purge of exactly that key (erasureServerPools.deleteAllVersions),
|
||||
// while the version the walk matched is still its latest. Under its lock,
|
||||
// before it deletes anything, the purge refuses the key when a version is
|
||||
// transitioned or Object Lock protects one, and has every write it would
|
||||
// delete, each copy and each revision a drive holds, authorized for the
|
||||
// submitter, with the version ID and tags it has then.
|
||||
//
|
||||
// What the purge removed is counted at once; a refused key is counted in res
|
||||
// as refused, and the versions a failed purge left in res as failures. A
|
||||
// failed purge is retried up to retryAttempts times, each time with the
|
||||
// version the walk matched; a purge that stopped at its budget after
|
||||
// removing versions continues without spending an attempt. A key whose
|
||||
// latest version changed since the walk is not expired. It returns an error
|
||||
// that ends the job: an authorization failure or the end of ctx.
|
||||
func (r *BatchJobExpire) expireAllVersions(ctx context.Context, api ObjectLayer, job BatchJobRequest, ri *batchJobInfo, exp expireObjInfo, retryAttempts int, res *batchCounts) error {
|
||||
var objects, markers int64
|
||||
matched := deleteAllMatch{VersionID: exp.VersionID, ModTime: exp.ModTime}
|
||||
for attempts := 1; attempts <= retryAttempts; attempts++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
stopFn := globalBatchJobsMetrics.trace(batchJobMetricExpire, ri.JobID, attempts)
|
||||
latest := matched
|
||||
var purge deleteAllResult
|
||||
p, err := job.Submitter.principal(ctx)
|
||||
if err == nil {
|
||||
_, err = api.DeleteObject(ctx, exp.Bucket, encodeDirObject(exp.Name), ObjectOptions{
|
||||
DeleteAllVersions: true,
|
||||
DeleteAllLatest: &latest,
|
||||
DeleteAllResult: &purge,
|
||||
deleteAllAuthorize: r.expireAllAuthorize(p, exp.Name),
|
||||
})
|
||||
}
|
||||
// Removed versions are gone for any later run: count them now.
|
||||
if purge.Removed > 0 {
|
||||
ri.countRemoved(r.Bucket, int64(purge.Removed-purge.Markers), int64(purge.Markers))
|
||||
objects += int64(purge.Removed - purge.Markers)
|
||||
markers += int64(purge.Markers)
|
||||
}
|
||||
stopFn(exp, err)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case isBatchJobStopped(err):
|
||||
return err
|
||||
case errors.Is(err, errDeleteAllChanged):
|
||||
// Changed since the walk matched it: not expired.
|
||||
return nil
|
||||
case isErrObjectLocked(err), errors.Is(err, errDeleteAllTiered):
|
||||
// Refused as a whole: nothing removed, not retried and not a
|
||||
// failure. A later run of the job checks the key again.
|
||||
res.keysRefused++
|
||||
return nil
|
||||
case errors.Is(err, errDeleteAllBudget) && purge.Removed > 0:
|
||||
attempts-- // progress
|
||||
continue
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
batchLogIf(ctx, fmt.Errorf("Failed to expire %s/%s due to %v (attempts=%d)", exp.Bucket, exp.Name, err, attempts))
|
||||
}
|
||||
res.expireAllFailed(exp, objects, markers)
|
||||
return nil
|
||||
}
|
||||
|
||||
// expireAllAuthorize returns the authorization of a purge of all versions of
|
||||
// name for p: each version the purge would delete, as it holds it, needs the
|
||||
// actions of expiring it (expireAuthzRequests).
|
||||
func (r *BatchJobExpire) expireAllAuthorize(p batchJobPrincipal, name string) func([]ObjectInfo) error {
|
||||
return func(versions []ObjectInfo) error {
|
||||
for _, v := range versions {
|
||||
for _, req := range r.expireAuthzRequests(name, v.VersionID, v.UserTags) {
|
||||
if err := p.authorize(req); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// expireAllFailed counts the versions of info, as the walk saw them, that a
|
||||
// failed purge of all its versions did not remove: those beyond the objects
|
||||
// and markers it removed, at least one.
|
||||
func (c *batchCounts) expireAllFailed(info expireObjInfo, objects, markers int64) {
|
||||
failedObjects := max(int64(info.NumVersions)-info.DeleteMarkerCount-objects, 0)
|
||||
failedMarkers := max(info.DeleteMarkerCount-markers, 0)
|
||||
if failedObjects+failedMarkers == 0 {
|
||||
failedObjects = 1
|
||||
}
|
||||
c.objectsFailed += failedObjects
|
||||
c.deleteMarkersFailed += failedMarkers
|
||||
}
|
||||
|
||||
// batchObjsForDelete expires the batches of expireCh. walkDone reports, once
|
||||
// expireCh is closed, whether the walk reached its end: only then is its last
|
||||
// key known to be complete.
|
||||
func batchObjsForDelete(ctx context.Context, refuse func(error), walkDone func() bool, r *BatchJobExpire, ri *batchJobInfo, job BatchJobRequest, api ObjectLayer, wk *workers.Workers, expireCh <-chan []expireObjInfo) {
|
||||
vc, _ := globalBucketVersioningSys.Get(r.Bucket)
|
||||
retryAttempts := job.Expire.Retry.Attempts
|
||||
if retryAttempts <= 0 {
|
||||
@@ -407,130 +581,53 @@ func batchObjsForDelete(ctx context.Context, r *BatchJobExpire, ri *batchJobInfo
|
||||
delay = batchExpireJobDefaultRetryDelay
|
||||
}
|
||||
|
||||
var i int
|
||||
progress := newBatchProgress(ri, r.Bucket)
|
||||
defer func() {
|
||||
wk.Wait()
|
||||
if walkDone() {
|
||||
progress.finish()
|
||||
}
|
||||
}()
|
||||
for toExpire := range expireCh {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
if i > 0 {
|
||||
seq := progress.begin()
|
||||
if seq > 0 {
|
||||
if wait := globalBatchConfig.ExpirationWait(); wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
i++
|
||||
wk.Take()
|
||||
go func(toExpire []expireObjInfo) {
|
||||
go func(seq uint64, toExpire []expireObjInfo) {
|
||||
defer wk.Give()
|
||||
|
||||
toExpireAll := make([]expireObjInfo, 0, len(toExpire))
|
||||
toDel := make([]ObjectToDelete, 0, len(toExpire))
|
||||
oiCache := newObjInfoCache()
|
||||
var res batchCounts
|
||||
for _, exp := range toExpire {
|
||||
var err error
|
||||
if exp.ExpireAll {
|
||||
toExpireAll = append(toExpireAll, exp)
|
||||
continue
|
||||
err = r.expireAllVersions(ctx, api, job, ri, exp, retryAttempts, &res)
|
||||
} else {
|
||||
err = r.expireVersions(ctx, api, vc, job, ri, exp, retryAttempts, delay, &res)
|
||||
}
|
||||
// Cache ObjectInfo value via pointers for
|
||||
// subsequent use to track objects which
|
||||
// couldn't be deleted.
|
||||
od := ObjectToDelete{
|
||||
ObjectV: ObjectV{
|
||||
ObjectName: exp.Name,
|
||||
VersionID: exp.VersionID,
|
||||
},
|
||||
}
|
||||
toDel = append(toDel, od)
|
||||
oiCache.Add(od, &exp.ObjectInfo)
|
||||
}
|
||||
|
||||
// DeleteObject(deletePrefix: true) to expire all versions of an object
|
||||
for _, exp := range toExpireAll {
|
||||
var success bool
|
||||
for attempts := 1; attempts <= retryAttempts; attempts++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ri.trackMultipleObjectVersions(exp, success)
|
||||
return
|
||||
default:
|
||||
if err != nil {
|
||||
// The batch is not done; a later run does it again.
|
||||
if isBatchJobStopped(err) {
|
||||
refuse(err)
|
||||
}
|
||||
stopFn := globalBatchJobsMetrics.trace(batchJobMetricExpire, ri.JobID, attempts)
|
||||
_, err := api.DeleteObject(ctx, exp.Bucket, encodeDirObject(exp.Name), ObjectOptions{
|
||||
DeletePrefix: true,
|
||||
DeletePrefixObject: true, // use prefix delete on exact object (this is an optimization to avoid fan-out calls)
|
||||
})
|
||||
if err != nil {
|
||||
stopFn(exp, err)
|
||||
batchLogIf(ctx, fmt.Errorf("Failed to expire %s/%s due to %v (attempts=%d)", exp.Bucket, exp.Name, err, attempts))
|
||||
} else {
|
||||
stopFn(exp, err)
|
||||
success = true
|
||||
break
|
||||
}
|
||||
}
|
||||
ri.trackMultipleObjectVersions(exp, success)
|
||||
}
|
||||
|
||||
// DeleteMultiple objects
|
||||
toDelCopy := make([]ObjectToDelete, len(toDel))
|
||||
for attempts := 1; attempts <= retryAttempts; attempts++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
globalBatchJobsMetrics.save(ri.JobID, ri)
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
stopFn := globalBatchJobsMetrics.trace(batchJobMetricExpire, ri.JobID, attempts)
|
||||
// Copying toDel to select from objects whose
|
||||
// deletion failed
|
||||
copy(toDelCopy, toDel)
|
||||
var failed int
|
||||
errs := r.Expire(ctx, api, vc, toDel)
|
||||
// reslice toDel in preparation for next retry attempt
|
||||
toDel = toDel[:0]
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
stopFn(toDelCopy[i], err)
|
||||
batchLogIf(ctx, fmt.Errorf("Failed to expire %s/%s versionID=%s due to %v (attempts=%d)", ri.Bucket, toDelCopy[i].ObjectName, toDelCopy[i].VersionID,
|
||||
err, attempts))
|
||||
failed++
|
||||
if oi, ok := oiCache.Get(toDelCopy[i]); ok {
|
||||
ri.trackCurrentBucketObject(r.Bucket, *oi, false, attempts)
|
||||
}
|
||||
if attempts != retryAttempts {
|
||||
// retry
|
||||
toDel = append(toDel, toDelCopy[i])
|
||||
}
|
||||
} else {
|
||||
stopFn(toDelCopy[i], nil)
|
||||
if oi, ok := oiCache.Get(toDelCopy[i]); ok {
|
||||
ri.trackCurrentBucketObject(r.Bucket, *oi, true, attempts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalBatchJobsMetrics.save(ri.JobID, ri)
|
||||
|
||||
if failed == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Add a delay between retry attempts
|
||||
if attempts < retryAttempts {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
}(toExpire)
|
||||
if n := len(toExpire); n > 0 {
|
||||
progress.done(seq, toExpire[n-1].Name, res)
|
||||
}
|
||||
globalBatchJobsMetrics.save(ri.JobID, ri)
|
||||
}(seq, toExpire)
|
||||
}
|
||||
}
|
||||
|
||||
type expireObjInfo struct {
|
||||
ObjectInfo
|
||||
ExpireAll bool
|
||||
DeleteMarkerCount int64
|
||||
}
|
||||
|
||||
// Start the batch expiration job, resumes if there was a pending job via "job.ID"
|
||||
func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJobRequest) error {
|
||||
ri := &batchJobInfo{
|
||||
@@ -539,6 +636,15 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
|
||||
StartTime: job.Started,
|
||||
}
|
||||
if err := ri.loadOrInit(ctx, api, job); err != nil {
|
||||
return batchUnavailable("loading job progress", err)
|
||||
}
|
||||
// A completed job is not run again, as replication and key rotation
|
||||
// jobs are not: its submitter is not checked again either, so a
|
||||
// credential that expired since cannot turn it into a failed job.
|
||||
if ri.Complete {
|
||||
return nil
|
||||
}
|
||||
if done, err := ri.startAuthorized(ctx, api, job); done || err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -560,27 +666,47 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
|
||||
|
||||
ctx, cancelCause := context.WithCancelCause(ctx)
|
||||
defer cancelCause(nil)
|
||||
// refuse ends the job for its submitter: record why before stopping.
|
||||
refuse := func(err error) {
|
||||
if isBatchJobUnavailable(err) {
|
||||
ri.pause(err)
|
||||
} else {
|
||||
ri.refuse(err)
|
||||
}
|
||||
cancelCause(err)
|
||||
}
|
||||
|
||||
// The walk stops at a listing failure: work after it is left to a later
|
||||
// run, so the cursor cannot move past what was not listed.
|
||||
walkCtx, stopWalk := context.WithCancel(ctx)
|
||||
defer stopWalk()
|
||||
results := make(chan itemOrErr[ObjectInfo], workerSize)
|
||||
go func() {
|
||||
prefixes := r.Prefix.F()
|
||||
if len(prefixes) == 0 {
|
||||
prefixes = []string{""}
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
// One ascending walk over disjoint prefixes, which the cursor
|
||||
// describes.
|
||||
for _, prefix := range batchCanonicalPrefixes(r.Prefix.F()) {
|
||||
if walkCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
prefixResultCh := make(chan itemOrErr[ObjectInfo], workerSize)
|
||||
err := api.Walk(ctx, r.Bucket, prefix, prefixResultCh, WalkOptions{
|
||||
err := api.Walk(walkCtx, r.Bucket, prefix, prefixResultCh, WalkOptions{
|
||||
Marker: lastObject,
|
||||
LatestOnly: false, // we need to visit all versions of the object to implement purge: retainVersions
|
||||
VersionsSort: WalkVersionsSortDesc,
|
||||
})
|
||||
if err != nil {
|
||||
cancelCause(err)
|
||||
if walkCtx.Err() == nil {
|
||||
cancelCause(batchListingError(err))
|
||||
}
|
||||
xioutil.SafeClose(results)
|
||||
return
|
||||
}
|
||||
for result := range prefixResultCh {
|
||||
results <- result
|
||||
select {
|
||||
case results <- result:
|
||||
case <-walkCtx.Done():
|
||||
// Stopped: let the walk end without a reader.
|
||||
}
|
||||
}
|
||||
}
|
||||
xioutil.SafeClose(results)
|
||||
@@ -588,7 +714,9 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
|
||||
|
||||
// Goroutine to periodically save batch-expire job's in-memory state
|
||||
saverQuitCh := make(chan struct{})
|
||||
saverDoneCh := make(chan struct{})
|
||||
go func() {
|
||||
defer close(saverDoneCh)
|
||||
saveTicker := time.NewTicker(10 * time.Second)
|
||||
defer saveTicker.Stop()
|
||||
quit := false
|
||||
@@ -615,9 +743,10 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
|
||||
|
||||
expireCh := make(chan []expireObjInfo, workerSize)
|
||||
expireDoneCh := make(chan struct{})
|
||||
var walkDone atomic.Bool
|
||||
go func() {
|
||||
defer close(expireDoneCh)
|
||||
batchObjsForDelete(ctx, r, ri, job, api, wk, expireCh)
|
||||
batchObjsForDelete(ctx, refuse, walkDone.Load, r, ri, job, api, wk, expireCh)
|
||||
}()
|
||||
|
||||
var (
|
||||
@@ -629,6 +758,8 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
|
||||
done bool
|
||||
)
|
||||
deleteMarkerCountMap := map[string]int64{}
|
||||
// pushToExpire is called between keys only, so that a batch holds all
|
||||
// the versions of its keys and its last key is done with it.
|
||||
pushToExpire := func() {
|
||||
// set preObject deleteMarkerCount
|
||||
if len(toDel) > 0 {
|
||||
@@ -653,6 +784,7 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
|
||||
}
|
||||
}
|
||||
}
|
||||
var listingErr error
|
||||
for {
|
||||
select {
|
||||
case result, ok := <-results:
|
||||
@@ -662,7 +794,14 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
|
||||
}
|
||||
if result.Err != nil {
|
||||
failed = true
|
||||
listingErr = batchListingError(result.Err)
|
||||
batchLogIf(ctx, result.Err)
|
||||
stopWalk()
|
||||
done = true
|
||||
break
|
||||
}
|
||||
if !batchResumeAfter(lastObject, result.Item.Name) {
|
||||
// Completed by the run that set the cursor.
|
||||
continue
|
||||
}
|
||||
if result.Item.DeleteMarker {
|
||||
@@ -720,7 +859,6 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
|
||||
toDel = append(toDel, expireObjInfo{
|
||||
ObjectInfo: result.Item,
|
||||
})
|
||||
pushToExpire()
|
||||
case <-ctx.Done():
|
||||
done = true
|
||||
}
|
||||
@@ -729,16 +867,15 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
|
||||
}
|
||||
}
|
||||
|
||||
if context.Cause(ctx) != nil {
|
||||
xioutil.SafeClose(expireCh)
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
pushToExpire()
|
||||
// Send any remaining objects downstream
|
||||
if len(toDel) > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case expireCh <- toDel:
|
||||
if context.Cause(ctx) == nil {
|
||||
walkDone.Store(!failed)
|
||||
pushToExpire()
|
||||
// Send any remaining objects downstream
|
||||
if len(toDel) > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case expireCh <- toDel:
|
||||
}
|
||||
}
|
||||
}
|
||||
xioutil.SafeClose(expireCh)
|
||||
@@ -746,13 +883,40 @@ func (r *BatchJobExpire) Start(ctx context.Context, api ObjectLayer, job BatchJo
|
||||
<-expireDoneCh // waits for the expire goroutine to complete
|
||||
wk.Wait() // waits for all expire workers to retire
|
||||
|
||||
ri.Complete = !failed && ri.ObjectsFailed == 0
|
||||
ri.Failed = failed || ri.ObjectsFailed > 0
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
|
||||
// Close the saverQuitCh - this also triggers saving in-memory state
|
||||
// immediately one last time before we exit this method.
|
||||
// Persist the final state once the saver has stopped, so that its last
|
||||
// save cannot overwrite it.
|
||||
xioutil.SafeClose(saverQuitCh)
|
||||
<-saverDoneCh
|
||||
refused := ri.refused()
|
||||
cause := context.Cause(ctx)
|
||||
if cause == nil {
|
||||
cause = listingErr
|
||||
}
|
||||
if cause != nil && !refused {
|
||||
if isBatchJobUnavailable(cause) {
|
||||
return ri.savePaused(ctx, api, job, cause)
|
||||
}
|
||||
if isErrBucketNotFound(cause) {
|
||||
ri.mu.Lock()
|
||||
ri.Failed = true
|
||||
ri.mu.Unlock()
|
||||
}
|
||||
saveCtx, cancel := context.WithTimeout(GlobalContext, 30*time.Second)
|
||||
batchLogIf(saveCtx, ri.updateAfter(saveCtx, api, 0, job))
|
||||
cancel()
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
return cause
|
||||
}
|
||||
|
||||
ri.mu.Lock()
|
||||
itemsFailed := ri.expireFailures()
|
||||
ri.Complete = !refused && !failed && !itemsFailed
|
||||
ri.Failed = refused || failed || itemsFailed
|
||||
ri.mu.Unlock()
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
saveCtx, cancel := context.WithTimeout(GlobalContext, 30*time.Second)
|
||||
batchLogIf(saveCtx, ri.updateAfter(saveCtx, api, 0, job))
|
||||
cancel()
|
||||
|
||||
// Notify expire jobs final status to the configured endpoint
|
||||
buf, _ := json.Marshal(ri)
|
||||
|
||||
+633
-182
File diff suppressed because it is too large
Load Diff
+178
-9
@@ -179,6 +179,24 @@ func (z *BatchJobRequest) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
case "Submitter":
|
||||
if dc.IsNil() {
|
||||
err = dc.ReadNil()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Submitter")
|
||||
return
|
||||
}
|
||||
z.Submitter = nil
|
||||
} else {
|
||||
if z.Submitter == nil {
|
||||
z.Submitter = new(batchJobSubmitter)
|
||||
}
|
||||
err = z.Submitter.DecodeMsg(dc)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Submitter")
|
||||
return
|
||||
}
|
||||
}
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
@@ -192,9 +210,9 @@ func (z *BatchJobRequest) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
|
||||
// EncodeMsg implements msgp.Encodable
|
||||
func (z *BatchJobRequest) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
// map header, size 6
|
||||
// map header, size 7
|
||||
// write "ID"
|
||||
err = en.Append(0x86, 0xa2, 0x49, 0x44)
|
||||
err = en.Append(0x87, 0xa2, 0x49, 0x44)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -274,15 +292,32 @@ func (z *BatchJobRequest) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// write "Submitter"
|
||||
err = en.Append(0xa9, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x72)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if z.Submitter == nil {
|
||||
err = en.WriteNil()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = z.Submitter.EncodeMsg(en)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Submitter")
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// MarshalMsg implements msgp.Marshaler
|
||||
func (z *BatchJobRequest) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.Require(b, z.Msgsize())
|
||||
// map header, size 6
|
||||
// map header, size 7
|
||||
// string "ID"
|
||||
o = append(o, 0x86, 0xa2, 0x49, 0x44)
|
||||
o = append(o, 0x87, 0xa2, 0x49, 0x44)
|
||||
o = msgp.AppendString(o, z.ID)
|
||||
// string "User"
|
||||
o = append(o, 0xa4, 0x55, 0x73, 0x65, 0x72)
|
||||
@@ -323,6 +358,17 @@ func (z *BatchJobRequest) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// string "Submitter"
|
||||
o = append(o, 0xa9, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x72)
|
||||
if z.Submitter == nil {
|
||||
o = msgp.AppendNil(o)
|
||||
} else {
|
||||
o, err = z.Submitter.MarshalMsg(o)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Submitter")
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -413,6 +459,23 @@ func (z *BatchJobRequest) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
case "Submitter":
|
||||
if msgp.IsNil(bts) {
|
||||
bts, err = msgp.ReadNilBytes(bts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
z.Submitter = nil
|
||||
} else {
|
||||
if z.Submitter == nil {
|
||||
z.Submitter = new(batchJobSubmitter)
|
||||
}
|
||||
bts, err = z.Submitter.UnmarshalMsg(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "Submitter")
|
||||
return
|
||||
}
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
@@ -445,6 +508,12 @@ func (z *BatchJobRequest) Msgsize() (s int) {
|
||||
} else {
|
||||
s += z.Expire.Msgsize()
|
||||
}
|
||||
s += 10
|
||||
if z.Submitter == nil {
|
||||
s += msgp.NilSize
|
||||
} else {
|
||||
s += z.Submitter.Msgsize()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -568,6 +637,30 @@ func (z *batchJobInfo) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
err = msgp.WrapError(err, "BytesFailed")
|
||||
return
|
||||
}
|
||||
case "vsl":
|
||||
z.VersionsSkippedLocked, err = dc.ReadInt64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "VersionsSkippedLocked")
|
||||
return
|
||||
}
|
||||
case "kr":
|
||||
z.KeysRefused, err = dc.ReadInt64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "KeysRefused")
|
||||
return
|
||||
}
|
||||
case "fr":
|
||||
z.FailureReason, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "FailureReason")
|
||||
return
|
||||
}
|
||||
case "pr":
|
||||
z.PausedReason, err = dc.ReadString()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "PausedReason")
|
||||
return
|
||||
}
|
||||
default:
|
||||
err = dc.Skip()
|
||||
if err != nil {
|
||||
@@ -581,9 +674,9 @@ func (z *batchJobInfo) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
|
||||
// EncodeMsg implements msgp.Encodable
|
||||
func (z *batchJobInfo) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
// map header, size 17
|
||||
// map header, size 21
|
||||
// write "v"
|
||||
err = en.Append(0xde, 0x0, 0x11, 0xa1, 0x76)
|
||||
err = en.Append(0xde, 0x0, 0x15, 0xa1, 0x76)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -752,15 +845,55 @@ func (z *batchJobInfo) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
err = msgp.WrapError(err, "BytesFailed")
|
||||
return
|
||||
}
|
||||
// write "vsl"
|
||||
err = en.Append(0xa3, 0x76, 0x73, 0x6c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteInt64(z.VersionsSkippedLocked)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "VersionsSkippedLocked")
|
||||
return
|
||||
}
|
||||
// write "kr"
|
||||
err = en.Append(0xa2, 0x6b, 0x72)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteInt64(z.KeysRefused)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "KeysRefused")
|
||||
return
|
||||
}
|
||||
// write "fr"
|
||||
err = en.Append(0xa2, 0x66, 0x72)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteString(z.FailureReason)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "FailureReason")
|
||||
return
|
||||
}
|
||||
// write "pr"
|
||||
err = en.Append(0xa2, 0x70, 0x72)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = en.WriteString(z.PausedReason)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "PausedReason")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// MarshalMsg implements msgp.Marshaler
|
||||
func (z *batchJobInfo) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.Require(b, z.Msgsize())
|
||||
// map header, size 17
|
||||
// map header, size 21
|
||||
// string "v"
|
||||
o = append(o, 0xde, 0x0, 0x11, 0xa1, 0x76)
|
||||
o = append(o, 0xde, 0x0, 0x15, 0xa1, 0x76)
|
||||
o = msgp.AppendInt(o, z.Version)
|
||||
// string "jid"
|
||||
o = append(o, 0xa3, 0x6a, 0x69, 0x64)
|
||||
@@ -810,6 +943,18 @@ func (z *batchJobInfo) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
// string "bf"
|
||||
o = append(o, 0xa2, 0x62, 0x66)
|
||||
o = msgp.AppendInt64(o, z.BytesFailed)
|
||||
// string "vsl"
|
||||
o = append(o, 0xa3, 0x76, 0x73, 0x6c)
|
||||
o = msgp.AppendInt64(o, z.VersionsSkippedLocked)
|
||||
// string "kr"
|
||||
o = append(o, 0xa2, 0x6b, 0x72)
|
||||
o = msgp.AppendInt64(o, z.KeysRefused)
|
||||
// string "fr"
|
||||
o = append(o, 0xa2, 0x66, 0x72)
|
||||
o = msgp.AppendString(o, z.FailureReason)
|
||||
// string "pr"
|
||||
o = append(o, 0xa2, 0x70, 0x72)
|
||||
o = msgp.AppendString(o, z.PausedReason)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -933,6 +1078,30 @@ func (z *batchJobInfo) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
err = msgp.WrapError(err, "BytesFailed")
|
||||
return
|
||||
}
|
||||
case "vsl":
|
||||
z.VersionsSkippedLocked, bts, err = msgp.ReadInt64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "VersionsSkippedLocked")
|
||||
return
|
||||
}
|
||||
case "kr":
|
||||
z.KeysRefused, bts, err = msgp.ReadInt64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "KeysRefused")
|
||||
return
|
||||
}
|
||||
case "fr":
|
||||
z.FailureReason, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "FailureReason")
|
||||
return
|
||||
}
|
||||
case "pr":
|
||||
z.PausedReason, bts, err = msgp.ReadStringBytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "PausedReason")
|
||||
return
|
||||
}
|
||||
default:
|
||||
bts, err = msgp.Skip(bts)
|
||||
if err != nil {
|
||||
@@ -947,6 +1116,6 @@ func (z *batchJobInfo) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
|
||||
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
|
||||
func (z *batchJobInfo) Msgsize() (s int) {
|
||||
s = 3 + 2 + msgp.IntSize + 4 + msgp.StringPrefixSize + len(z.JobID) + 3 + msgp.StringPrefixSize + len(z.JobType) + 3 + msgp.TimeSize + 3 + msgp.TimeSize + 3 + msgp.IntSize + 3 + msgp.IntSize + 4 + msgp.BoolSize + 4 + msgp.BoolSize + 5 + msgp.StringPrefixSize + len(z.Bucket) + 5 + msgp.StringPrefixSize + len(z.Object) + 3 + msgp.Int64Size + 3 + msgp.Int64Size + 4 + msgp.Int64Size + 4 + msgp.Int64Size + 3 + msgp.Int64Size + 3 + msgp.Int64Size
|
||||
s = 3 + 2 + msgp.IntSize + 4 + msgp.StringPrefixSize + len(z.JobID) + 3 + msgp.StringPrefixSize + len(z.JobType) + 3 + msgp.TimeSize + 3 + msgp.TimeSize + 3 + msgp.IntSize + 3 + msgp.IntSize + 4 + msgp.BoolSize + 4 + msgp.BoolSize + 5 + msgp.StringPrefixSize + len(z.Bucket) + 5 + msgp.StringPrefixSize + len(z.Object) + 3 + msgp.Int64Size + 3 + msgp.Int64Size + 4 + msgp.Int64Size + 4 + msgp.Int64Size + 3 + msgp.Int64Size + 3 + msgp.Int64Size + 4 + msgp.Int64Size + 3 + msgp.Int64Size + 3 + msgp.StringPrefixSize + len(z.FailureReason) + 3 + msgp.StringPrefixSize + len(z.PausedReason)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/minio/minio/internal/amztime"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
objectlock "github.com/minio/minio/internal/bucket/object/lock"
|
||||
"github.com/minio/minio/internal/crypto"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
var batchLockKinds = []string{"COMPLIANCE", "GOVERNANCE", "LEGALHOLD"}
|
||||
|
||||
func batchLockMeta(kind string) map[string]string {
|
||||
m := map[string]string{}
|
||||
switch kind {
|
||||
case "COMPLIANCE", "GOVERNANCE":
|
||||
m[strings.ToLower(xhttp.AmzObjectLockMode)] = kind
|
||||
m[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] = amztime.ISO8601Format(UTCNow().Add(24 * time.Hour))
|
||||
case "LEGALHOLD":
|
||||
m[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = "ON"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func batchPutVersion(t *testing.T, obj ObjectLayer, bucket, key string, meta map[string]string) ObjectInfo {
|
||||
t.Helper()
|
||||
data := []byte("batch lock " + key)
|
||||
oi, err := obj.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""),
|
||||
ObjectOptions{Versioned: true, UserDefined: meta})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return oi
|
||||
}
|
||||
|
||||
func batchVersionExists(t *testing.T, obj ObjectLayer, bucket, key, versionID string) bool {
|
||||
t.Helper()
|
||||
_, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: versionID})
|
||||
switch {
|
||||
case err == nil:
|
||||
return true
|
||||
case isErrVersionNotFound(err) || isErrObjectNotFound(err):
|
||||
return false
|
||||
}
|
||||
t.Fatal(err)
|
||||
return false
|
||||
}
|
||||
|
||||
// batchJobStatusJSON returns the status response of a job as decoded JSON.
|
||||
func batchJobStatusJSON(t *testing.T, jobID string) map[string]any {
|
||||
t.Helper()
|
||||
cred := globalActiveCred
|
||||
req, err := newTestSignedRequestV4(http.MethodGet, "http://localhost/minio/admin/v3/status-job?jobId="+jobID, 0, nil, cred.AccessKey, cred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = req.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req = req.WithContext(newContext(req, rec, "BatchJobStatus"))
|
||||
adminAPIHandlers{}.BatchJobStatus(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct{ LastMetric map[string]any }
|
||||
if err = json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.LastMetric
|
||||
}
|
||||
|
||||
// Batch expiration keeps versions under retention or legal hold, on both
|
||||
// purge paths, and reports them; unlocked versions are still expired.
|
||||
func TestBatchExpireObjectLock(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, endpoints: []string{"PutObject"},
|
||||
objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
defer r5Capacity(obj.(*erasureServerPools))()
|
||||
enableBucketObjectLock(t, bucket)
|
||||
f := &batchAuthzFixture{ctx: t.Context(), obj: obj, bucket: bucket}
|
||||
batchTestJobPool(t.Context(), t, obj)
|
||||
|
||||
type version struct{ key, id string }
|
||||
var locked, unlocked []version
|
||||
for _, retain := range []int{0, 1} {
|
||||
for _, kind := range batchLockKinds {
|
||||
key := fmt.Sprintf("r%d/%s", retain, kind)
|
||||
older := batchPutVersion(t, obj, bucket, key, batchLockMeta(kind))
|
||||
batchPutVersion(t, obj, bucket, key, nil)
|
||||
locked = append(locked, version{key, older.VersionID})
|
||||
}
|
||||
key := fmt.Sprintf("r%d/plain", retain)
|
||||
older := batchPutVersion(t, obj, bucket, key, nil)
|
||||
batchPutVersion(t, obj, bucket, key, nil)
|
||||
unlocked = append(unlocked, version{key, older.VersionID})
|
||||
}
|
||||
|
||||
var jobs []*BatchJobRequest
|
||||
for _, retain := range []int{0, 1} {
|
||||
job := fmt.Sprintf(`expire:
|
||||
apiVersion: v1
|
||||
bucket: %s
|
||||
prefix: r%d/
|
||||
rules:
|
||||
- type: object
|
||||
purge:
|
||||
retainVersions: %d
|
||||
`, bucket, retain, retain)
|
||||
rec, queued := f.submit(t, globalActiveCred, job)
|
||||
if queued == nil {
|
||||
t.Fatalf("%s: submission status %d: %s", instance, rec.Code, rec.Body.String())
|
||||
}
|
||||
// Running a job again, as a resumed job does, must not count
|
||||
// the versions it left a second time.
|
||||
f.run(t, queued)
|
||||
f.run(t, queued)
|
||||
jobs = append(jobs, queued)
|
||||
}
|
||||
|
||||
for _, v := range locked {
|
||||
if !batchVersionExists(t, obj, bucket, v.key, v.id) {
|
||||
t.Errorf("%s: %s: locked version %s was expired", instance, v.key, v.id)
|
||||
}
|
||||
}
|
||||
for _, v := range unlocked {
|
||||
if batchVersionExists(t, obj, bucket, v.key, v.id) {
|
||||
t.Errorf("%s: %s: control: unlocked version %s was not expired", instance, v.key, v.id)
|
||||
}
|
||||
}
|
||||
for i, field := range []string{"keysRefused", "versionsSkippedLocked"} {
|
||||
m := batchJobStatusJSON(t, jobs[i].ID)
|
||||
if got, _ := m[field].(float64); got != float64(len(batchLockKinds)) {
|
||||
t.Errorf("%s: retain %d: %s = %v, want %d", instance, i, field, m[field], len(batchLockKinds))
|
||||
}
|
||||
if m["complete"] != true || m["failed"] != false {
|
||||
t.Errorf("%s: retain %d: complete=%v failed=%v", instance, i, m["complete"], m["failed"])
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type batchLockTransport func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f batchLockTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
// batchLockRemote is a source that serves replacement for every read.
|
||||
func batchLockRemote(t *testing.T, replacement string) *minio.Core {
|
||||
t.Helper()
|
||||
core, err := minio.NewCore("source.invalid", &minio.Options{
|
||||
Region: "us-east-1", Creds: credentials.NewStaticV4("batch-access", "batch-secret", ""),
|
||||
Transport: batchLockTransport(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{
|
||||
"Content-Length": []string{strconv.Itoa(len(replacement))},
|
||||
"Last-Modified": []string{UTCNow().Format(http.TimeFormat)},
|
||||
"Etag": []string{"\"batchetag\""},
|
||||
}, Body: io.NopCloser(strings.NewReader(replacement)), ContentLength: int64(len(replacement)), Request: r}, nil
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return core
|
||||
}
|
||||
|
||||
func batchRootSubmitter(t *testing.T) *batchJobSubmitter {
|
||||
t.Helper()
|
||||
sub, err := newBatchJobSubmitter(globalActiveCred)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// A pull leaves a destination version under Object Lock as it is, whether a
|
||||
// single PUT, a multipart completion or a delete marker would replace it.
|
||||
func TestBatchPullObjectLock(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, endpoints: []string{"PutObject"},
|
||||
objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
defer r5Capacity(obj.(*erasureServerPools))()
|
||||
enableBucketObjectLock(t, bucket)
|
||||
const replacement = "replacement from remote"
|
||||
core := batchLockRemote(t, replacement)
|
||||
sub := batchRootSubmitter(t)
|
||||
job := BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: "remote-bucket"},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: bucket},
|
||||
}
|
||||
for _, kind := range batchLockKinds {
|
||||
for _, path := range []string{"put", "multipart", "marker", "directory"} {
|
||||
key := "pull/" + kind + "/" + path
|
||||
if path == "directory" {
|
||||
key += "/"
|
||||
}
|
||||
before := batchPutVersion(t, obj, bucket, key, batchLockMeta(kind))
|
||||
src := ObjectInfo{Name: key, VersionID: before.VersionID, ModTime: UTCNow(), ETag: "batchetag", UserDefined: map[string]string{}}
|
||||
switch path {
|
||||
case "multipart":
|
||||
src.ETag = "batchetag-1"
|
||||
case "marker":
|
||||
src.DeleteMarker = true
|
||||
}
|
||||
err := job.ReplicateFromSource(t.Context(), obj, core, src, false, sub)
|
||||
if !isErrObjectLocked(err) {
|
||||
t.Errorf("%s: %s: pull returned %v, want ObjectLocked", instance, key, err)
|
||||
}
|
||||
after, gerr := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: before.VersionID})
|
||||
if gerr != nil {
|
||||
t.Errorf("%s: %s: locked version lost: %v", instance, key, gerr)
|
||||
continue
|
||||
}
|
||||
if !enforceRetentionForDeletion(t.Context(), after) || after.ETag != before.ETag {
|
||||
t.Errorf("%s: %s: locked version was replaced", instance, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Controls: a new version, and a new delete marker that hides a
|
||||
// locked version without replacing it.
|
||||
locked := batchPutVersion(t, obj, bucket, "pull/new", batchLockMeta("COMPLIANCE"))
|
||||
newVersion := mustGetUUID()
|
||||
if err := job.ReplicateFromSource(t.Context(), obj, core, ObjectInfo{
|
||||
Name: "pull/new", VersionID: newVersion, ModTime: UTCNow(), ETag: "batchetag", UserDefined: map[string]string{},
|
||||
}, false, sub); err != nil {
|
||||
t.Fatalf("%s: new version: %v", instance, err)
|
||||
}
|
||||
if got := readCopyChecksumObject(t, obj, bucket, "pull/new", ObjectOptions{VersionID: newVersion}); string(got) != replacement {
|
||||
t.Errorf("%s: new version content %q", instance, got)
|
||||
}
|
||||
marker := mustGetUUID()
|
||||
if err := job.ReplicateFromSource(t.Context(), obj, core, ObjectInfo{
|
||||
Name: "pull/new", VersionID: marker, ModTime: UTCNow(), DeleteMarker: true,
|
||||
}, false, sub); err != nil {
|
||||
t.Fatalf("%s: new marker: %v", instance, err)
|
||||
}
|
||||
if !batchVersionExists(t, obj, bucket, "pull/new", locked.VersionID) {
|
||||
t.Errorf("%s: a new marker removed a locked version", instance)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// A pull applies retention as a PUT to the destination would: copied
|
||||
// retention is validated, and a version without one gets the bucket default.
|
||||
func TestBatchPullRetentionMetadata(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, endpoints: []string{"PutObject"},
|
||||
objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
defer r5Capacity(obj.(*erasureServerPools))()
|
||||
meta, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.ObjectLockConfigXML = []byte(`<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>1</Days></DefaultRetention></Rule></ObjectLockConfiguration>`)
|
||||
meta.VersioningConfigXML = enabledBucketVersioningConfig
|
||||
if err = meta.parseAllConfigs(t.Context(), obj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(bucket, meta)
|
||||
core := batchLockRemote(t, "retention content")
|
||||
sub := batchRootSubmitter(t)
|
||||
job := BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: "remote-bucket"},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: bucket},
|
||||
}
|
||||
pull := func(key string, meta map[string]string) (ObjectInfo, error) {
|
||||
vid := mustGetUUID()
|
||||
err := job.ReplicateFromSource(t.Context(), obj, core, ObjectInfo{Name: key, VersionID: vid, ModTime: UTCNow().Add(-time.Hour), ETag: "batchetag", UserDefined: meta}, false, sub)
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
return obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: vid})
|
||||
}
|
||||
oi, err := pull("retention/default", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ret := objectlock.GetObjectRetentionMeta(oi.UserDefined)
|
||||
if ret.Mode != objectlock.RetGovernance || !ret.RetainUntilDate.After(UTCNow().Add(23*time.Hour)) {
|
||||
t.Errorf("%s: default retention not applied with the destination's time: %+v", instance, ret)
|
||||
}
|
||||
until := UTCNow().Add(48 * time.Hour)
|
||||
oi, err = pull("retention/copied", map[string]string{
|
||||
xhttp.AmzObjectLockMode: "COMPLIANCE", xhttp.AmzObjectLockRetainUntilDate: amztime.ISO8601Format(until),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ret = objectlock.GetObjectRetentionMeta(oi.UserDefined); ret.Mode != objectlock.RetCompliance || !ret.RetainUntilDate.Equal(until.Truncate(time.Millisecond)) {
|
||||
t.Errorf("%s: copied retention: %+v", instance, ret)
|
||||
}
|
||||
if _, err = pull("retention/invalid", map[string]string{xhttp.AmzObjectLockMode: "FOREVER", xhttp.AmzObjectLockRetainUntilDate: amztime.ISO8601Format(until)}); err == nil {
|
||||
t.Errorf("%s: invalid copied retention was accepted", instance)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Key rotation does not rewrite a version under Object Lock.
|
||||
func TestBatchKeyRotationObjectLock(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, endpoints: []string{"PutObject", "GetObject"},
|
||||
objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
defer r5Capacity(obj.(*erasureServerPools))()
|
||||
enableBucketObjectLock(t, bucket)
|
||||
oldKMS := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("batch-lock-key")
|
||||
defer func() { GlobalKMS = oldKMS }()
|
||||
sub := batchRootSubmitter(t)
|
||||
job := BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: sses3}}
|
||||
for _, kind := range append([]string{"none"}, batchLockKinds...) {
|
||||
key := "rotate/" + kind
|
||||
headers := map[string]string{xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionAES}
|
||||
switch kind {
|
||||
case "COMPLIANCE", "GOVERNANCE":
|
||||
headers[xhttp.AmzObjectLockMode] = kind
|
||||
headers[xhttp.AmzObjectLockRetainUntilDate] = UTCNow().Add(24 * time.Hour).Format(time.RFC3339)
|
||||
case "LEGALHOLD":
|
||||
headers[xhttp.AmzObjectLockLegalHold] = "ON"
|
||||
}
|
||||
putCopyChecksumSource(t, router, cred, bucket, key, []byte("rotate "+kind), headers)
|
||||
before, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{})
|
||||
if err != nil || !crypto.S3.IsEncrypted(before.UserDefined) {
|
||||
t.Fatalf("%s: %s: encrypted control: %v", instance, key, err)
|
||||
}
|
||||
err = job.KeyRotate(t.Context(), obj, before, sub)
|
||||
after, gerr := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: before.VersionID})
|
||||
if gerr != nil {
|
||||
t.Fatal(gerr)
|
||||
}
|
||||
if kind == "none" {
|
||||
if err != nil || after.ModTime.Equal(before.ModTime) {
|
||||
t.Errorf("%s: control: unlocked version not rotated: %v", instance, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var locked ObjectLocked
|
||||
if !errors.As(err, &locked) || !after.ModTime.Equal(before.ModTime) {
|
||||
t.Errorf("%s: %s: rotation of a locked version returned %v; rewritten=%v", instance, key, err, !after.ModTime.Equal(before.ModTime))
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// A push reads locked versions as any authorized read does, and a submitter
|
||||
// that may not read a version does not send it.
|
||||
func TestBatchPushReads(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||
t: t, endpoints: []string{"PutObject"},
|
||||
objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
defer r5Capacity(obj.(*erasureServerPools))()
|
||||
enableBucketObjectLock(t, bucket)
|
||||
var puts []string
|
||||
target, err := minio.NewCore("target.invalid", &minio.Options{
|
||||
Region: "us-east-1", Creds: credentials.NewStaticV4("batch-access", "batch-secret", ""),
|
||||
Transport: batchLockTransport(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Body != nil {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
r.Body.Close()
|
||||
}
|
||||
if r.Method == http.MethodPut {
|
||||
puts = append(puts, r.URL.Path)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Etag": []string{"\"pushetag\""}}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := BatchJobReplicateV1{
|
||||
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: bucket},
|
||||
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: "remote-bucket"},
|
||||
}
|
||||
src := batchPutVersion(t, obj, bucket, "push/locked", batchLockMeta("COMPLIANCE"))
|
||||
if err := job.ReplicateToTarget(t.Context(), obj, target, src, false, batchRootSubmitter(t)); err != nil {
|
||||
t.Fatalf("%s: authorized push of a locked version: %v", instance, err)
|
||||
}
|
||||
if len(puts) != 1 {
|
||||
t.Fatalf("%s: authorized push sent %d writes", instance, len(puts))
|
||||
}
|
||||
|
||||
// A user that may list but not read.
|
||||
p, err := policy.ParseConfig(strings.NewReader(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["admin:StartBatchJob"]},{"Effect":"Allow","Action":["s3:ListBucket","s3:ListBucketVersions"],"Resource":["arn:aws:s3:::` + bucket + `"]}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = globalIAMSys.SetPolicy(t.Context(), "batch-list-only", *p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := auth.Credentials{AccessKey: "batch-list-user", SecretKey: "batch-list-user-secret"}
|
||||
if _, err = globalIAMSys.CreateUser(t.Context(), user.AccessKey, madmin.AddOrUpdateUserReq{SecretKey: user.SecretKey, Status: madmin.AccountEnabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = globalIAMSys.PolicyDBSet(t.Context(), user.AccessKey, "batch-list-only", regUser, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub, err := newBatchJobSubmitter(globalIAMSysUser(t, user.AccessKey))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = job.ReplicateToTarget(t.Context(), obj, target, src, false, sub)
|
||||
if !isBatchJobAuthzError(err) || len(puts) != 1 {
|
||||
t.Fatalf("%s: unauthorized push returned %v and sent %d writes", instance, err, len(puts)-1)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// globalIAMSysUser returns the stored credential of accessKey, as a request
|
||||
// authenticated with it has.
|
||||
func globalIAMSysUser(t *testing.T, accessKey string) auth.Credentials {
|
||||
t.Helper()
|
||||
u, ok := globalIAMSys.store.GetUser(accessKey)
|
||||
if !ok {
|
||||
t.Fatalf("no user %s", accessKey)
|
||||
}
|
||||
return u.Credentials
|
||||
}
|
||||
|
||||
// The cursor of a walk done out of order moves only past keys whose items
|
||||
// are all done, and counts each item once.
|
||||
func TestBatchProgressCursor(t *testing.T) {
|
||||
ri := &batchJobInfo{}
|
||||
p := newBatchProgress(ri, "bucket")
|
||||
items := []string{"a", "a", "b", "c", "c"}
|
||||
seqs := make([]uint64, len(items))
|
||||
for i := range items {
|
||||
seqs[i] = p.begin()
|
||||
}
|
||||
skip := batchCounts{versionsSkipped: 1}
|
||||
p.done(seqs[1], "a", skip)
|
||||
p.done(seqs[3], "c", skip)
|
||||
if ri.Object != "" || ri.VersionsSkippedLocked != 0 {
|
||||
t.Fatalf("cursor moved before the first item: %q %d", ri.Object, ri.VersionsSkippedLocked)
|
||||
}
|
||||
p.done(seqs[0], "a", skip)
|
||||
p.done(seqs[2], "b", skip)
|
||||
// a is done; b is done but c may still have items.
|
||||
if ri.Object != "b" && ri.Object != "a" {
|
||||
t.Fatalf("cursor %q", ri.Object)
|
||||
}
|
||||
if ri.Object == "a" && ri.VersionsSkippedLocked != 2 {
|
||||
t.Fatalf("counts %d at cursor a", ri.VersionsSkippedLocked)
|
||||
}
|
||||
// c's last item never finishes, as when a run stops: the cursor stays
|
||||
// before c and c is not counted.
|
||||
p.finish()
|
||||
if ri.Object != "b" || ri.VersionsSkippedLocked != 3 {
|
||||
t.Fatalf("interrupted: cursor %q counts %d, want b 3", ri.Object, ri.VersionsSkippedLocked)
|
||||
}
|
||||
p.done(seqs[4], "c", skip)
|
||||
p.finish()
|
||||
if ri.Object != "c" || ri.VersionsSkippedLocked != 5 {
|
||||
t.Fatalf("finished: cursor %q counts %d, want c 5", ri.Object, ri.VersionsSkippedLocked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
)
|
||||
|
||||
// batchCounts are outcomes of a batch job that a later run of the job would
|
||||
// see again, and so must be persisted together with the cursor of the walk.
|
||||
type batchCounts struct {
|
||||
objects int64
|
||||
objectsFailed int64
|
||||
deleteMarkersFailed int64
|
||||
versionsSkipped int64
|
||||
keysRefused int64
|
||||
}
|
||||
|
||||
func (c *batchCounts) add(o batchCounts) {
|
||||
c.objects += o.objects
|
||||
c.objectsFailed += o.objectsFailed
|
||||
c.deleteMarkersFailed += o.deleteMarkersFailed
|
||||
c.versionsSkipped += o.versionsSkipped
|
||||
c.keysRefused += o.keysRefused
|
||||
}
|
||||
|
||||
//msgp:ignore batchProgress batchProgressItem batchCounts
|
||||
|
||||
// batchProgress is the cursor of a walk whose items are done concurrently.
|
||||
//
|
||||
// The items of a walk are numbered in walk order. A key is done when every
|
||||
// item of it, and every item before it, is done; only then does the cursor
|
||||
// move past it and its counts become part of the job's counts. The cursor and
|
||||
// the counts are persisted together, so a job resumed after the cursor
|
||||
// neither skips an item that was not done nor counts an item twice.
|
||||
type batchProgress struct {
|
||||
mu sync.Mutex
|
||||
ri *batchJobInfo
|
||||
bucket string
|
||||
issued uint64
|
||||
next uint64
|
||||
pending map[uint64]batchProgressItem
|
||||
// key is the key of the item last committed, and held are the counts of
|
||||
// its items so far.
|
||||
key string
|
||||
held batchCounts
|
||||
}
|
||||
|
||||
type batchProgressItem struct {
|
||||
key string
|
||||
counts batchCounts
|
||||
}
|
||||
|
||||
func newBatchProgress(ri *batchJobInfo, bucket string) *batchProgress {
|
||||
return &batchProgress{ri: ri, bucket: bucket, pending: make(map[uint64]batchProgressItem)}
|
||||
}
|
||||
|
||||
// begin numbers the next item of the walk.
|
||||
func (p *batchProgress) begin() uint64 {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
seq := p.issued
|
||||
p.issued++
|
||||
return seq
|
||||
}
|
||||
|
||||
// done records item seq, of key, as done with counts.
|
||||
func (p *batchProgress) done(seq uint64, key string, counts batchCounts) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.pending[seq] = batchProgressItem{key: key, counts: counts}
|
||||
for {
|
||||
item, ok := p.pending[p.next]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(p.pending, p.next)
|
||||
p.next++
|
||||
if item.key != p.key {
|
||||
p.commitLocked()
|
||||
p.key = item.key
|
||||
}
|
||||
p.held.add(item.counts)
|
||||
}
|
||||
}
|
||||
|
||||
// finish commits the last key if every item of the walk is done. Call it
|
||||
// once no item is in progress.
|
||||
func (p *batchProgress) finish() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.next == p.issued {
|
||||
p.commitLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *batchProgress) commitLocked() {
|
||||
if p.key == "" {
|
||||
return
|
||||
}
|
||||
ri := p.ri
|
||||
ri.mu.Lock()
|
||||
ri.Bucket = p.bucket
|
||||
ri.Object = p.key
|
||||
ri.Objects += p.held.objects
|
||||
ri.ObjectsFailed += p.held.objectsFailed
|
||||
ri.DeleteMarkersFailed += p.held.deleteMarkersFailed
|
||||
ri.VersionsSkippedLocked += p.held.versionsSkipped
|
||||
ri.KeysRefused += p.held.keysRefused
|
||||
ri.mu.Unlock()
|
||||
p.held = batchCounts{}
|
||||
}
|
||||
|
||||
// countRemoved counts versions a job removed. A removed version is gone for
|
||||
// any later run, so it is counted at once, not at the cursor.
|
||||
func (ri *batchJobInfo) countRemoved(bucket string, objects, markers int64) {
|
||||
ri.mu.Lock()
|
||||
defer ri.mu.Unlock()
|
||||
ri.Bucket = bucket
|
||||
ri.Objects += objects
|
||||
ri.DeleteMarkers += markers
|
||||
}
|
||||
|
||||
// countSkipped counts a version a job left in place because Object Lock
|
||||
// protects it, for a job that is counted per run rather than at a cursor.
|
||||
func (ri *batchJobInfo) countSkipped() {
|
||||
ri.mu.Lock()
|
||||
defer ri.mu.Unlock()
|
||||
ri.VersionsSkippedLocked++
|
||||
}
|
||||
|
||||
// batchCanonicalPrefixes orders the prefixes of a walk and drops a prefix
|
||||
// that another one covers. The keys of the walk are then visited once each,
|
||||
// in one ascending order, which a single cursor can describe.
|
||||
func batchCanonicalPrefixes(prefixes []string) []string {
|
||||
if len(prefixes) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
sorted := slices.Clone(prefixes)
|
||||
slices.Sort(sorted)
|
||||
out := sorted[:0]
|
||||
for _, p := range sorted {
|
||||
if len(out) > 0 && strings.HasPrefix(p, out[len(out)-1]) {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// batchResumeAfter reports whether a resumed walk returned a key after the
|
||||
// cursor. The cursor key is done; the walk forwards conservatively and
|
||||
// returns it again. Keys compare as the walk orders them, by name, so a
|
||||
// directory object comes before the keys under it.
|
||||
func batchResumeAfter(cursor, name string) bool {
|
||||
return cursor == "" || name > cursor
|
||||
}
|
||||
|
||||
// batchJobStatus is the status response: madmin.BatchJobStatus with the
|
||||
// fields this server adds. The added fields are additive; a client that does
|
||||
// not know them ignores them.
|
||||
type batchJobStatus struct {
|
||||
LastMetric batchJobStatusMetric
|
||||
}
|
||||
|
||||
type batchJobStatusMetric struct {
|
||||
madmin.JobMetric
|
||||
VersionsSkippedLocked int64 `json:"versionsSkippedLocked,omitempty"`
|
||||
KeysRefused int64 `json:"keysRefused,omitempty"`
|
||||
FailureReason string `json:"failureReason,omitempty"`
|
||||
PausedReason string `json:"pausedReason,omitempty"`
|
||||
}
|
||||
|
||||
func (ri *batchJobInfo) statusMetric() batchJobStatusMetric {
|
||||
ri.mu.RLock()
|
||||
defer ri.mu.RUnlock()
|
||||
return batchJobStatusMetric{
|
||||
JobMetric: ri.metric(),
|
||||
VersionsSkippedLocked: ri.VersionsSkippedLocked,
|
||||
KeysRefused: ri.KeysRefused,
|
||||
FailureReason: ri.FailureReason,
|
||||
PausedReason: ri.PausedReason,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
// An unavailable dependency is not an authorization decision. It stops the
|
||||
// current attempt without turning an outage into either an allow or a denial.
|
||||
type batchJobUnavailableError struct {
|
||||
reason string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e batchJobUnavailableError) Error() string { return fmt.Sprintf("%s: %v", e.reason, e.err) }
|
||||
func (e batchJobUnavailableError) Unwrap() error { return e.err }
|
||||
func batchUnavailable(reason string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if isBatchJobUnavailable(err) {
|
||||
return err
|
||||
}
|
||||
return batchJobUnavailableError{reason: reason, err: err}
|
||||
}
|
||||
|
||||
func isBatchJobUnavailable(err error) bool { var e batchJobUnavailableError; return errors.As(err, &e) }
|
||||
|
||||
func isBatchJobStopped(err error) bool {
|
||||
return isBatchJobAuthzError(err) || isBatchJobUnavailable(err)
|
||||
}
|
||||
|
||||
func batchListingError(err error) error {
|
||||
if isErrBucketNotFound(err) {
|
||||
return err
|
||||
}
|
||||
return batchUnavailable("object listing unavailable", err)
|
||||
}
|
||||
|
||||
func (ri *batchJobInfo) pause(err error) {
|
||||
ri.mu.Lock()
|
||||
defer ri.mu.Unlock()
|
||||
// A concurrent explicit denial wins over a transient worker error.
|
||||
if ri.FailureReason != "" {
|
||||
return
|
||||
}
|
||||
ri.Complete, ri.Failed = false, false
|
||||
ri.PausedReason = err.Error()
|
||||
}
|
||||
|
||||
func (ri *batchJobInfo) savePaused(ctx context.Context, api ObjectLayer, job BatchJobRequest, err error) error {
|
||||
ri.pause(err)
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
if saveErr := ri.updateAfter(saveCtx, api, 0, job); saveErr != nil {
|
||||
return batchUnavailable("saving paused job", errors.Join(err, saveErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Authenticate the HTTP request normally, but keep dependency errors through
|
||||
// the authorization step so submission returns 503, not a permanent 403.
|
||||
func validateBatchJobAdminReq(ctx context.Context, w http.ResponseWriter, r *http.Request) (ObjectLayer, auth.Credentials) {
|
||||
api := newObjectLayerFn()
|
||||
if api == nil || globalNotificationSys == nil || !batchIAMReady() {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL)
|
||||
return nil, auth.Credentials{}
|
||||
}
|
||||
cred, owner, code := validateAdminSignature(ctx, r, "")
|
||||
if code != ErrNone {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(code), r.URL)
|
||||
return nil, cred
|
||||
}
|
||||
submitter, err := newBatchJobSubmitter(cred)
|
||||
if err == nil {
|
||||
var principal batchJobPrincipal
|
||||
principal, err = submitter.principal(ctx)
|
||||
if err == nil {
|
||||
err = principal.authorizeArgs(policy.Args{
|
||||
AccountName: cred.AccessKey, Groups: cred.Groups,
|
||||
Action: policy.Action(policy.StartBatchJobAction), ConditionValues: getConditionValues(r, "", cred), IsOwner: owner, Claims: principal.claims,
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
apiErr := errorCodes.ToAPIErrWithErr(ErrAccessDenied, err)
|
||||
if isBatchJobUnavailable(err) {
|
||||
apiErr = batchServiceUnavailable(err)
|
||||
}
|
||||
writeErrorResponseJSON(ctx, w, apiErr, r.URL)
|
||||
return nil, cred
|
||||
}
|
||||
return api, cred
|
||||
}
|
||||
|
||||
// One owner record spans queued, running and paused attempts. The request
|
||||
// pointer is the generation token. Canceled records fence stale requests until
|
||||
// shutdown. Terminal records fence discovery until the startup scan finishes,
|
||||
// including jobs submitted and terminated while the scan was unavailable.
|
||||
type batchJobOwner struct {
|
||||
req *BatchJobRequest
|
||||
cancel context.CancelFunc
|
||||
state string
|
||||
retries uint
|
||||
due time.Time
|
||||
}
|
||||
|
||||
func (j *BatchJobPool) beginJob(job *BatchJobRequest) bool {
|
||||
j.jmu.Lock()
|
||||
defer j.jmu.Unlock()
|
||||
o := j.jobOwners[job.ID]
|
||||
if o == nil || o.req != job || o.state != "queued" || job.ctx.Err() != nil || j.ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
o.state = "running"
|
||||
return true
|
||||
}
|
||||
|
||||
func (j *BatchJobPool) finishJob(job *BatchJobRequest, err error) {
|
||||
j.jmu.Lock()
|
||||
defer j.jmu.Unlock()
|
||||
o := j.jobOwners[job.ID]
|
||||
if o == nil || o.req != job || o.state != "running" {
|
||||
return
|
||||
}
|
||||
if isBatchJobUnavailable(err) && job.ctx.Err() == nil && j.ctx.Err() == nil {
|
||||
o.due = time.Now().Add(batchJobRetryDelay(o.retries))
|
||||
o.retries++
|
||||
o.state = "paused"
|
||||
return
|
||||
}
|
||||
o.cancel()
|
||||
if j.discoveryDone {
|
||||
delete(j.jobOwners, job.ID)
|
||||
} else {
|
||||
j.jobOwners[job.ID] = &batchJobOwner{state: "terminal"}
|
||||
}
|
||||
}
|
||||
|
||||
// After the only startup discovery has returned, no loaded manifest remains
|
||||
// to re-admit a terminal job. Release its marker, but keep queued, running,
|
||||
// paused and canceled ownership intact. New submissions use new job IDs.
|
||||
func (j *BatchJobPool) finishDiscovery() {
|
||||
j.jmu.Lock()
|
||||
defer j.jmu.Unlock()
|
||||
j.discoveryDone = true
|
||||
for id, owner := range j.jobOwners {
|
||||
if owner.state == "terminal" {
|
||||
delete(j.jobOwners, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func batchJobRetryDelay(retries uint) time.Duration {
|
||||
delay := 30 * time.Second * time.Duration(1<<min(retries, 4))
|
||||
return delay/2 + time.Duration(rand.Float64()*float64(delay))
|
||||
}
|
||||
|
||||
// retryDue holds the ownership lock across the nonblocking handoff. A fast
|
||||
// worker cannot replace a pause while the scheduler removes the old pause.
|
||||
func (j *BatchJobPool) retryDue(now time.Time) {
|
||||
j.jmu.Lock()
|
||||
defer j.jmu.Unlock()
|
||||
if j.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
for _, o := range j.jobOwners {
|
||||
if o.state != "paused" || now.Before(o.due) || o.req.ctx.Err() != nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case j.jobCh <- o.req:
|
||||
o.state = "queued"
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (j *BatchJobPool) retryLoop() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-j.ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
j.retryDue(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func batchServiceUnavailable(err error) APIError {
|
||||
return APIError{Code: "ServiceUnavailable", Description: err.Error(), HTTPStatusCode: http.StatusServiceUnavailable}
|
||||
}
|
||||
|
||||
// initStore precedes the first successful cache load. During that interval a
|
||||
// missing identity or policy is not a revocation decision, including for root.
|
||||
func batchIAMReady() bool {
|
||||
sys := globalIAMSys
|
||||
if sys == nil {
|
||||
return false
|
||||
}
|
||||
sys.Lock()
|
||||
defer sys.Unlock()
|
||||
if sys.store == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-sys.configLoaded:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
polplugin "github.com/minio/minio/internal/config/policy/plugin"
|
||||
xnet "github.com/pgsty/silo-pkg/v3/net"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
"github.com/tinylib/msgp/msgp"
|
||||
)
|
||||
|
||||
func waitBatchState(t *testing.T, pool *BatchJobPool, id, state string) {
|
||||
t.Helper()
|
||||
timeout := time.NewTimer(10 * time.Second)
|
||||
defer timeout.Stop()
|
||||
tick := time.NewTicker(time.Millisecond)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
pool.jmu.Lock()
|
||||
owner := pool.jobOwners[id]
|
||||
ready := owner == nil && state == ""
|
||||
if owner != nil {
|
||||
ready = owner.state == state
|
||||
}
|
||||
pool.jmu.Unlock()
|
||||
if ready {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-timeout.C:
|
||||
t.Fatalf("job did not reach %q", state)
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPluginPauseRecoversInProcess(t *testing.T) {
|
||||
for _, midrun := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "before-start", true: "during-delete"}[midrun], func(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
older := f.putVersions(t, "data/key")
|
||||
var unavailable atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Input policy.Args `json:"input"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
if unavailable.Load() && (!midrun || request.Input.Action == policy.DeleteObjectVersionAction) {
|
||||
w.WriteHeader(503)
|
||||
io.WriteString(w, `{"result":true}`)
|
||||
return
|
||||
}
|
||||
io.WriteString(w, `{"result":true}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
url, err := xnet.ParseHTTPURL(server.URL)
|
||||
mustIAM(t, err)
|
||||
previous := newGlobalAuthZPluginFn()
|
||||
setGlobalAuthZPlugin(polplugin.New(polplugin.Args{URL: url, CloseRespFn: func(r io.ReadCloser) { r.Close() }}))
|
||||
defer setGlobalAuthZPlugin(previous)
|
||||
rec, job := f.submit(t, globalActiveCred, f.expireYAML("data/"))
|
||||
if job == nil {
|
||||
t.Fatalf("submit %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
pool := globalBatchJobPool
|
||||
ctx, cancel := context.WithCancel(f.ctx)
|
||||
pool.ctx = ctx
|
||||
// Re-admit the fixture's consumed request with a context owned by this worker.
|
||||
pool.jmu.Lock()
|
||||
pool.jobOwners[job.ID].cancel()
|
||||
delete(pool.jobOwners, job.ID)
|
||||
pool.jmu.Unlock()
|
||||
mustIAM(t, pool.queueJob(job))
|
||||
unavailable.Store(true)
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); pool.AddWorker() }()
|
||||
defer func() { cancel(); <-done }()
|
||||
waitBatchState(t, pool, job.ID, "paused")
|
||||
ri := f.jobInfo(t, job)
|
||||
if ri.Failed || ri.Complete || ri.FailureReason != "" || ri.PausedReason == "" || ri.Object != "" {
|
||||
t.Fatalf("paused report: %+v", ri)
|
||||
}
|
||||
if !f.versionExists(t, "data/key", older) {
|
||||
t.Fatal("outage authorized a deletion")
|
||||
}
|
||||
unavailable.Store(false)
|
||||
pool.retryDue(time.Now().Add(time.Hour))
|
||||
waitBatchState(t, pool, job.ID, "terminal")
|
||||
ri = f.jobInfo(t, job)
|
||||
if !ri.Complete || ri.Failed || ri.PausedReason != "" || ri.Objects != 1 || f.versionExists(t, "data/key", older) {
|
||||
t.Fatalf("resumed report: %+v", ri)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSubmissionUnavailableIs503(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(503); io.WriteString(w, `{"result":true}`) }))
|
||||
defer server.Close()
|
||||
url, err := xnet.ParseHTTPURL(server.URL)
|
||||
mustIAM(t, err)
|
||||
previous := newGlobalAuthZPluginFn()
|
||||
setGlobalAuthZPlugin(polplugin.New(polplugin.Args{URL: url, CloseRespFn: func(r io.ReadCloser) { r.Close() }}))
|
||||
defer setGlobalAuthZPlugin(previous)
|
||||
rec, job := f.submit(t, globalActiveCred, f.expireYAML("data/"))
|
||||
if rec.Code != 503 || job != nil {
|
||||
t.Fatalf("outage submission: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPoolOwnershipAndCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
pool := &BatchJobPool{ctx: ctx, jobCh: make(chan *BatchJobRequest, 1)}
|
||||
first, second := &BatchJobRequest{ID: "first"}, &BatchJobRequest{ID: "second"}
|
||||
mustIAM(t, pool.queueJob(first))
|
||||
mustIAM(t, pool.queueJob(second))
|
||||
if pool.jobOwners[second.ID].state != "paused" {
|
||||
t.Fatal("full queue lost pending owner")
|
||||
}
|
||||
original := second.ctx
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
if err := pool.queueJob(&BatchJobRequest{ID: "second"}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if second.ctx != original || len(pool.jobCh) != 1 {
|
||||
t.Fatal("duplicate changed ownership")
|
||||
}
|
||||
if !pool.beginJob(<-pool.jobCh) {
|
||||
t.Fatal("first not owned")
|
||||
}
|
||||
pool.finishJob(first, nil)
|
||||
mustIAM(t, pool.queueJob(&BatchJobRequest{ID: first.ID}))
|
||||
if len(pool.jobCh) != 0 {
|
||||
t.Fatal("late discovery re-admitted a terminal job")
|
||||
}
|
||||
pool.retryDue(time.Now())
|
||||
next := <-pool.jobCh
|
||||
if next != second || !pool.beginJob(next) {
|
||||
t.Fatal("pending handoff lost ownership")
|
||||
}
|
||||
for range 2 {
|
||||
pool.finishJob(second, batchUnavailable("test", errors.New("outage")))
|
||||
pool.retryDue(time.Now().Add(time.Hour))
|
||||
if !pool.beginJob(<-pool.jobCh) {
|
||||
t.Fatal("retry lost owner")
|
||||
}
|
||||
}
|
||||
if pool.jobOwners[second.ID].retries != 2 {
|
||||
t.Fatal("retry count reset during handoff")
|
||||
}
|
||||
mustIAM(t, pool.canceler(second.ID, true))
|
||||
mustIAM(t, pool.canceler(second.ID, true))
|
||||
pool.finishJob(second, batchUnavailable("late", errors.New("outage")))
|
||||
pool.retryDue(time.Now().Add(time.Hour))
|
||||
if len(pool.jobCh) != 0 || second.ctx.Err() == nil {
|
||||
t.Fatal("cancel lost to retry")
|
||||
}
|
||||
if err := pool.queueJob(&BatchJobRequest{ID: second.ID}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("stale admission: %v", err)
|
||||
}
|
||||
// A cancellation before this node admitted a manifest is a tombstone too.
|
||||
mustIAM(t, pool.canceler("not-loaded", true))
|
||||
mustIAM(t, pool.canceler("not-loaded", true))
|
||||
if err := pool.queueJob(&BatchJobRequest{ID: "not-loaded"}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("stale resume: %v", err)
|
||||
}
|
||||
cancel()
|
||||
if err := pool.queueJob(&BatchJobRequest{ID: "shutdown"}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("shutdown admission: %v", err)
|
||||
}
|
||||
pool.retryDue(time.Now().Add(time.Hour)) // channel remains open; no send/close race.
|
||||
}
|
||||
|
||||
// A cancellation of a job this node does not own leaves a record only while
|
||||
// startup discovery may still admit its manifest. Once discovery has
|
||||
// finished, canceling unknown IDs must not grow the ownership table.
|
||||
func TestBatchCancelUnknownJobAfterDiscovery(t *testing.T) {
|
||||
pool := &BatchJobPool{ctx: t.Context(), jobCh: make(chan *BatchJobRequest, 1)}
|
||||
// Before discovery finished, the record fences the manifest it may admit.
|
||||
mustIAM(t, pool.canceler("expire-before-discovery", true))
|
||||
if err := pool.queueJob(&BatchJobRequest{ID: "expire-before-discovery"}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("admission of a job canceled during discovery: %v", err)
|
||||
}
|
||||
pool.finishDiscovery()
|
||||
for i := range 1000 {
|
||||
mustIAM(t, pool.canceler(fmt.Sprintf("expire-unknown-%d", i), true))
|
||||
}
|
||||
pool.jmu.Lock()
|
||||
owners := len(pool.jobOwners)
|
||||
pool.jmu.Unlock()
|
||||
if owners != 1 {
|
||||
t.Fatalf("%d owner records after canceling unknown jobs, want only the record from discovery", owners)
|
||||
}
|
||||
// An owned job is still canceled and fenced.
|
||||
job := &BatchJobRequest{ID: "expire-owned"}
|
||||
mustIAM(t, pool.queueJob(job))
|
||||
mustIAM(t, pool.canceler(job.ID, true))
|
||||
if job.ctx.Err() == nil {
|
||||
t.Fatal("cancellation did not stop the owned job")
|
||||
}
|
||||
if err := pool.queueJob(&BatchJobRequest{ID: job.ID}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("stale admission of a canceled job: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type batchPolicyOutage struct {
|
||||
IAMStorageAPI
|
||||
failed string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *batchPolicyOutage) loadPolicyDoc(ctx context.Context, name string, out map[string]PolicyDoc) error {
|
||||
if name == s.failed {
|
||||
return s.err
|
||||
}
|
||||
return s.IAMStorageAPI.loadPolicyDoc(ctx, name, out)
|
||||
}
|
||||
|
||||
func TestBatchPolicySnapshotKeepsUnavailableDeny(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "retry-allow", batchAuthzFullAccess)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "retry-deny", `{"Effect":"Deny","Action":["s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"]}`)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "retry-user", "retry-allow,retry-deny")
|
||||
sub, err := newBatchJobSubmitter(cred)
|
||||
mustIAM(t, err)
|
||||
store := f.sys.store
|
||||
original := store.IAMStorageAPI
|
||||
defer func() { store.IAMStorageAPI = original }()
|
||||
cache := store.lock()
|
||||
delete(cache.iamPolicyDocsMap, "retry-deny")
|
||||
store.unlock()
|
||||
store.IAMStorageAPI = &batchPolicyOutage{IAMStorageAPI: original, failed: "retry-deny", err: errErasureReadQuorum}
|
||||
request := batchAuthzRequest{action: policy.DeleteObjectVersionAction, bucket: f.bucket, object: "key", versionID: mustGetUUID()}
|
||||
if err := sub.authorize(f.ctx, request); !isBatchJobUnavailable(err) {
|
||||
t.Fatalf("missing Deny must pause, got %v", err)
|
||||
}
|
||||
store.IAMStorageAPI = original
|
||||
if err := sub.authorize(f.ctx, request); !isBatchJobAuthzError(err) {
|
||||
t.Fatalf("recovered Deny was not enforced: %v", err)
|
||||
}
|
||||
// Root also waits for IAM readiness; startup must not bypass the pause gate.
|
||||
root, err := newBatchJobSubmitter(globalActiveCred)
|
||||
mustIAM(t, err)
|
||||
f.sys.Lock()
|
||||
f.sys.store = nil
|
||||
f.sys.Unlock()
|
||||
_, err = root.principal(f.ctx)
|
||||
f.sys.Lock()
|
||||
f.sys.store = store
|
||||
f.sys.Unlock()
|
||||
if !isBatchJobUnavailable(err) {
|
||||
t.Fatalf("root before IAM readiness: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPausedReportCompatibility(t *testing.T) {
|
||||
before := &batchJobInfo{JobID: "paused", Object: "cursor", PausedReason: "IAM unavailable"}
|
||||
data, err := before.MarshalMsg(nil)
|
||||
mustIAM(t, err)
|
||||
var after batchJobInfo
|
||||
_, err = after.UnmarshalMsg(data)
|
||||
mustIAM(t, err)
|
||||
if after.PausedReason != before.PausedReason || after.clone().PausedReason != before.PausedReason || after.statusMetric().PausedReason != before.PausedReason {
|
||||
t.Fatal("paused reason lost")
|
||||
}
|
||||
old := msgp.AppendMapHeader(nil, 2)
|
||||
old = msgp.AppendString(old, "jid")
|
||||
old = msgp.AppendString(old, "legacy")
|
||||
old = msgp.AppendString(old, "cmp")
|
||||
old = msgp.AppendBool(old, true)
|
||||
var legacy batchJobInfo
|
||||
_, err = legacy.UnmarshalMsg(old)
|
||||
mustIAM(t, err)
|
||||
if legacy.PausedReason != "" || !legacy.Complete {
|
||||
t.Fatal("old report decode changed")
|
||||
}
|
||||
after.refuse(errBatchJobInvalid)
|
||||
if after.PausedReason != "" || !after.Failed {
|
||||
t.Fatal("terminal denial retained pause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPersistenceFencesOlderExecutors(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
rec, job := f.submit(t, globalActiveCred, f.expireYAML("data/"))
|
||||
if job == nil {
|
||||
t.Fatalf("submit: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
raw, err := readConfig(f.ctx, f.obj, getJobPath(*job))
|
||||
mustIAM(t, err)
|
||||
if !bytes.HasPrefix(raw, []byte(batchJobEnvelope)) {
|
||||
t.Fatal("new manifest has no executor version fence")
|
||||
}
|
||||
var oldDecoder BatchJobRequest
|
||||
if _, err := oldDecoder.UnmarshalMsg(raw); err == nil {
|
||||
t.Fatal("old bare-msgpack executor could decode new manifest")
|
||||
}
|
||||
var loaded BatchJobRequest
|
||||
mustIAM(t, loaded.load(f.ctx, f.obj, getJobPath(*job)))
|
||||
if loaded.Submitter == nil {
|
||||
t.Fatal("new executor lost submitter")
|
||||
}
|
||||
ri := &batchJobInfo{JobID: job.ID, JobType: string(job.Type()), StartTime: job.Started}
|
||||
outage := batchUnavailable("test", errErasureReadQuorum)
|
||||
if err := ri.savePaused(f.ctx, f.obj, *job, outage); !errors.Is(err, outage) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reportPath, err := job.getJobReportPath()
|
||||
mustIAM(t, err)
|
||||
report, err := readConfig(f.ctx, f.obj, reportPath)
|
||||
mustIAM(t, err)
|
||||
if binary.LittleEndian.Uint16(report[2:4]) == batchExpireVersionV1 {
|
||||
t.Fatal("old executor would accept paused report")
|
||||
}
|
||||
mustIAM(t, ri.load(f.ctx, f.obj, *job))
|
||||
if ri.PausedReason == "" || ri.Failed {
|
||||
t.Fatal("restart lost pause")
|
||||
}
|
||||
// A stale resume loaded the manifest before cancellation.
|
||||
mustIAM(t, globalBatchJobPool.canceler(job.ID, true))
|
||||
mustIAM(t, job.delete(f.ctx, f.obj))
|
||||
if err := globalBatchJobPool.queueJob(&loaded); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled job re-admitted: %v", err)
|
||||
}
|
||||
if err := loaded.load(f.ctx, f.obj, getJobPath(*job)); !errors.Is(err, errNoSuchJob) {
|
||||
t.Fatalf("canceled manifest recreated: %v", err)
|
||||
}
|
||||
}
|
||||
+239
-54
@@ -20,6 +20,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math/rand"
|
||||
@@ -35,6 +36,7 @@ import (
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
"github.com/pgsty/silo-pkg/v3/env"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
"github.com/pgsty/silo-pkg/v3/workers"
|
||||
)
|
||||
|
||||
@@ -161,8 +163,68 @@ func (r BatchJobKeyRotateV1) Notify(ctx context.Context, ri *batchJobInfo) error
|
||||
return notifyEndpoint(ctx, ri, r.Flags.Notify.Endpoint, r.Flags.Notify.Token)
|
||||
}
|
||||
|
||||
// KeyRotate rotates encryption key of an object
|
||||
func (r *BatchJobKeyRotateV1) KeyRotate(ctx context.Context, api ObjectLayer, objInfo ObjectInfo) error {
|
||||
// batchAuthoritativeVersion returns the version obj as all its copies
|
||||
// describe it. With several pools a version can have copies in more than one
|
||||
// pool, and a read returns one of them; the merged copy carries the newest
|
||||
// Object Lock and tag state. The caller holds the object's write lock.
|
||||
func batchAuthoritativeVersion(ctx context.Context, api ObjectLayer, bucket, object string, obj ObjectInfo) (ObjectInfo, error) {
|
||||
z, ok := api.(*erasureServerPools)
|
||||
if !ok || z.SinglePool() {
|
||||
return obj, nil
|
||||
}
|
||||
versionID := obj.VersionID
|
||||
if versionID == "" {
|
||||
versionID = nullVersionID
|
||||
}
|
||||
return z.replicaObjectInfo(ctx, bucket, encodeDirObject(object), ObjectOptions{VersionID: versionID, NoLock: true, NoAuditLog: true})
|
||||
}
|
||||
|
||||
// batchAuthoritativeRead is batchAuthoritativeVersion for a version that has
|
||||
// been opened for reading. It takes the object's read lock itself, since an
|
||||
// opened reader may hold none.
|
||||
func batchAuthoritativeRead(ctx context.Context, api ObjectLayer, bucket, object string, obj ObjectInfo) (ObjectInfo, error) {
|
||||
z, ok := api.(*erasureServerPools)
|
||||
if !ok || z.SinglePool() {
|
||||
return obj, nil
|
||||
}
|
||||
lk := z.NewNSLock(bucket, encodeDirObject(object))
|
||||
lkctx, err := lk.GetRLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
defer lk.RUnlock(lkctx)
|
||||
return batchAuthoritativeVersion(lkctx.Context(), api, bucket, object, obj)
|
||||
}
|
||||
|
||||
// rotateAuthzRequests are the actions of rotating one version: it is read and
|
||||
// written again in place with the new key.
|
||||
func (r *BatchJobKeyRotateV1) rotateAuthzRequests(oi ObjectInfo) []batchAuthzRequest {
|
||||
versionID := oi.VersionID
|
||||
if versionID == "" {
|
||||
versionID = nullVersionID
|
||||
}
|
||||
// The encryption the rewrite leaves: rotation keeps the kind of the
|
||||
// version (rotateKey), and SSE-KMS takes the requested key.
|
||||
values := map[string][]string{}
|
||||
switch {
|
||||
case crypto.S3KMS.IsEncrypted(oi.UserDefined):
|
||||
values[strings.ToLower(xhttp.AmzServerSideEncryption)] = []string{xhttp.AmzEncryptionKMS}
|
||||
if r.Encryption.Type == ssekms && r.Encryption.Key != "" {
|
||||
values[strings.ToLower(xhttp.AmzServerSideEncryptionKmsID)] = []string{r.Encryption.Key}
|
||||
}
|
||||
case crypto.S3.IsEncrypted(oi.UserDefined):
|
||||
values[strings.ToLower(xhttp.AmzServerSideEncryption)] = []string{xhttp.AmzEncryptionAES}
|
||||
}
|
||||
return []batchAuthzRequest{
|
||||
{action: policy.GetObjectAction, bucket: r.Bucket, object: oi.Name, versionID: versionID, tags: oi.UserTags},
|
||||
{action: policy.GetObjectVersionAction, bucket: r.Bucket, object: oi.Name, versionID: versionID, tags: oi.UserTags},
|
||||
{action: policy.PutObjectAction, bucket: r.Bucket, object: oi.Name, versionID: versionID, tags: oi.UserTags, values: values},
|
||||
}
|
||||
}
|
||||
|
||||
// KeyRotate rotates encryption key of an object. A version that Object Lock
|
||||
// protects is not rewritten: it returns ObjectLocked.
|
||||
func (r *BatchJobKeyRotateV1) KeyRotate(ctx context.Context, api ObjectLayer, objInfo ObjectInfo, sub *batchJobSubmitter) error {
|
||||
srcBucket := r.Bucket
|
||||
srcObject := objInfo.Name
|
||||
|
||||
@@ -180,7 +242,8 @@ func (r *BatchJobKeyRotateV1) KeyRotate(ctx context.Context, api ObjectLayer, ob
|
||||
versioned := globalBucketVersioningSys.PrefixEnabled(srcBucket, srcObject)
|
||||
versionSuspended := globalBucketVersioningSys.PrefixSuspended(srcBucket, srcObject)
|
||||
|
||||
lock := api.NewNSLock(r.Bucket, objInfo.Name)
|
||||
// Lock the name writers lock: the stored name of a directory object.
|
||||
lock := api.NewObjectNSLock(r.Bucket, encodeDirObject(objInfo.Name))
|
||||
lkctx, err := lock.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -194,10 +257,32 @@ func (r *BatchJobKeyRotateV1) KeyRotate(ctx context.Context, api ObjectLayer, ob
|
||||
VersionSuspended: versionSuspended,
|
||||
NoLock: true,
|
||||
}
|
||||
obj, err := api.GetObjectInfo(ctx, r.Bucket, objInfo.Name, opts)
|
||||
obj, pool, err := batchRotateRead(ctx, api, r.Bucket, objInfo.Name, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Decide on the version as it is under the lock that the rewrite holds,
|
||||
// before any key is requested, with the metadata of all its copies.
|
||||
logical, err := batchAuthoritativeVersion(ctx, api, r.Bucket, objInfo.Name, obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enforceRetentionForDeletion(ctx, logical) {
|
||||
return ObjectLocked{Bucket: r.Bucket, Object: obj.Name, VersionID: obj.VersionID}
|
||||
}
|
||||
p, err := sub.principal(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Authorize the copy that is read and rewritten, and the version as all
|
||||
// its copies describe it.
|
||||
for _, v := range []ObjectInfo{obj, logical} {
|
||||
for _, req := range r.rotateAuthzRequests(v) {
|
||||
if err := p.authorize(req); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
oi := obj.Clone()
|
||||
var (
|
||||
newKeyID string
|
||||
@@ -225,16 +310,61 @@ func (r *BatchJobKeyRotateV1) KeyRotate(ctx context.Context, api ObjectLayer, ob
|
||||
oi.metadataOnly = true
|
||||
oi.keyRotation = true
|
||||
maps.Copy(oi.UserDefined, encMetadata)
|
||||
if _, err := api.CopyObject(ctx, r.Bucket, oi.Name, r.Bucket, oi.Name, oi, ObjectOptions{
|
||||
VersionID: oi.VersionID,
|
||||
}, ObjectOptions{
|
||||
VersionID: oi.VersionID,
|
||||
NoLock: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
// The rewrite replaces the stored metadata with UserDefined, which a
|
||||
// read returns without the tags and the expiry: keep them.
|
||||
if oi.UserTags != "" {
|
||||
oi.UserDefined[xhttp.AmzObjectTagging] = oi.UserTags
|
||||
}
|
||||
if !oi.Expires.IsZero() {
|
||||
oi.UserDefined["expires"] = oi.ExpiresStr()
|
||||
}
|
||||
srcOpts := ObjectOptions{VersionID: oi.VersionID}
|
||||
dstOpts := ObjectOptions{VersionID: oi.VersionID, NoLock: true}
|
||||
if z, ok := api.(*erasureServerPools); ok && pool >= 0 {
|
||||
// Rewrite the copy that was read and authorized. The pools layer
|
||||
// would route the rewrite by the latest version of the key, which
|
||||
// can be in a pool that holds another write of this version ID.
|
||||
//
|
||||
// Check the pool's state again where the pools layer checks it,
|
||||
// right before the write: a move can have started since the read.
|
||||
if z.IsSuspended(pool) || z.IsPoolRebalancing(pool) {
|
||||
return errBatchRotateMoving
|
||||
}
|
||||
dstOpts.replicaObjectInfo = z.replicaObjectInfo
|
||||
name := encodeDirObject(oi.Name)
|
||||
_, err = z.serverPools[pool].CopyObject(ctx, r.Bucket, name, r.Bucket, name, oi, srcOpts, dstOpts)
|
||||
} else {
|
||||
_, err = api.CopyObject(ctx, r.Bucket, oi.Name, r.Bucket, oi.Name, oi, srcOpts, dstOpts)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
// errBatchRotateMoving: the version is read from a pool that a decommission
|
||||
// or a rebalance is moving.
|
||||
var errBatchRotateMoving = errors.New("the version is being moved to another pool; rotate it once the move is done")
|
||||
|
||||
// batchRotateRead reads the version that a rotation rewrites, as
|
||||
// GetObjectInfo does. With several pools it also returns the pool of the
|
||||
// copy it read, otherwise -1. The caller holds the object lock.
|
||||
//
|
||||
// A copy in a pool that a decommission or a rebalance is moving is the
|
||||
// source of that move, which must not change under it, and rewriting a copy
|
||||
// in another pool would change which copy the version's metadata comes
|
||||
// from. Such a version is left to a later run (errBatchRotateMoving).
|
||||
func batchRotateRead(ctx context.Context, api ObjectLayer, bucket, object string, opts ObjectOptions) (ObjectInfo, int, error) {
|
||||
z, ok := api.(*erasureServerPools)
|
||||
if !ok || z.SinglePool() {
|
||||
oi, err := api.GetObjectInfo(ctx, bucket, object, opts)
|
||||
return oi, -1, err
|
||||
}
|
||||
if err := checkGetObjArgs(ctx, bucket, object); err != nil {
|
||||
return ObjectInfo{}, -1, err
|
||||
}
|
||||
oi, idx, err := z.getLatestObjectInfoWithIdx(ctx, bucket, object, opts)
|
||||
if err == nil && (z.IsSuspended(idx) || z.IsPoolRebalancing(idx)) {
|
||||
return ObjectInfo{}, -1, errBatchRotateMoving
|
||||
}
|
||||
return oi, idx, err
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -255,11 +385,14 @@ func (r *BatchJobKeyRotateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
StartTime: job.Started,
|
||||
}
|
||||
if err := ri.loadOrInit(ctx, api, job); err != nil {
|
||||
return err
|
||||
return batchUnavailable("loading job progress", err)
|
||||
}
|
||||
if ri.Complete {
|
||||
return nil
|
||||
}
|
||||
if done, err := ri.startAuthorized(ctx, api, job); done || err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
lastObject := ri.Object
|
||||
@@ -273,8 +406,6 @@ func (r *BatchJobKeyRotateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
delay = batchKeyRotateJobDefaultRetryDelay
|
||||
}
|
||||
|
||||
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
selectObj := func(info FileInfo) (ok bool) {
|
||||
if r.Flags.Filter.OlderThan > 0 && time.Since(info.ModTime) < r.Flags.Filter.OlderThan {
|
||||
// skip all objects that are newer than specified older duration
|
||||
@@ -355,65 +486,103 @@ func (r *BatchJobKeyRotateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
ctx, cancelCause := context.WithCancelCause(ctx)
|
||||
defer cancelCause(nil)
|
||||
// refuse ends the job for its submitter: record why before stopping.
|
||||
refuse := func(err error) {
|
||||
if isBatchJobUnavailable(err) {
|
||||
ri.pause(err)
|
||||
} else {
|
||||
ri.refuse(err)
|
||||
}
|
||||
cancelCause(err)
|
||||
}
|
||||
progress := newBatchProgress(ri, r.Bucket)
|
||||
|
||||
results := make(chan itemOrErr[ObjectInfo], 100)
|
||||
if err := api.Walk(ctx, r.Bucket, r.Prefix, results, WalkOptions{
|
||||
Marker: lastObject,
|
||||
Filter: selectObj,
|
||||
}); err != nil {
|
||||
cancel()
|
||||
// Do not need to retry if we can't list objects on source.
|
||||
return err
|
||||
cause := batchListingError(err)
|
||||
if isBatchJobUnavailable(cause) {
|
||||
return ri.savePaused(ctx, api, job, cause)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
failed := false
|
||||
for res := range results {
|
||||
if context.Cause(ctx) != nil {
|
||||
// Refused or canceled: schedule nothing more.
|
||||
continue
|
||||
}
|
||||
if res.Err != nil {
|
||||
failed = true
|
||||
cancelCause(batchListingError(res.Err))
|
||||
batchLogIf(ctx, res.Err)
|
||||
break
|
||||
continue
|
||||
}
|
||||
result := res.Item
|
||||
if !batchResumeAfter(lastObject, result.Name) {
|
||||
// Completed by the run that set the cursor.
|
||||
continue
|
||||
}
|
||||
sseKMS := crypto.S3KMS.IsEncrypted(result.UserDefined)
|
||||
sseS3 := crypto.S3.IsEncrypted(result.UserDefined)
|
||||
if !sseKMS && !sseS3 { // neither sse-s3 nor sse-kms disallowed
|
||||
continue
|
||||
}
|
||||
seq := progress.begin()
|
||||
wk.Take()
|
||||
go func() {
|
||||
defer wk.Give()
|
||||
var counts batchCounts
|
||||
for attempts := 1; attempts <= retryAttempts; attempts++ {
|
||||
stopFn := globalBatchJobsMetrics.trace(batchJobMetricKeyRotation, job.ID, attempts)
|
||||
success := true
|
||||
if err := r.KeyRotate(ctx, api, result); err != nil {
|
||||
stopFn(result, err)
|
||||
batchLogIf(ctx, err)
|
||||
success = false
|
||||
if attempts >= retryAttempts {
|
||||
auditOptions := AuditLogOptions{
|
||||
Event: "KeyRotate",
|
||||
APIName: "StartBatchJob",
|
||||
Bucket: result.Bucket,
|
||||
Object: result.Name,
|
||||
VersionID: result.VersionID,
|
||||
Error: err.Error(),
|
||||
}
|
||||
auditLogInternal(ctx, auditOptions)
|
||||
}
|
||||
} else {
|
||||
stopFn(result, nil)
|
||||
err := r.KeyRotate(ctx, api, result, job.Submitter)
|
||||
if errors.Is(err, errBatchRotateMoving) {
|
||||
err = batchUnavailable("object is being moved", err)
|
||||
}
|
||||
ri.trackCurrentBucketObject(r.Bucket, result, success, attempts)
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
// persist in-memory state to disk after every 10secs.
|
||||
batchLogIf(ctx, ri.updateAfter(ctx, api, 10*time.Second, job))
|
||||
if success {
|
||||
stopFn(result, err)
|
||||
if err == nil {
|
||||
counts.objects = 1
|
||||
break
|
||||
}
|
||||
if isBatchJobStopped(err) {
|
||||
refuse(err)
|
||||
return
|
||||
}
|
||||
if isErrObjectLocked(err) {
|
||||
// Left as it is, not retried and not a failure.
|
||||
counts.versionsSkipped = 1
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
// Not done; a later run rotates it.
|
||||
return
|
||||
}
|
||||
batchLogIf(ctx, err)
|
||||
if attempts >= retryAttempts {
|
||||
auditOptions := AuditLogOptions{
|
||||
Event: "KeyRotate",
|
||||
APIName: "StartBatchJob",
|
||||
Bucket: result.Bucket,
|
||||
Object: result.Name,
|
||||
VersionID: result.VersionID,
|
||||
Error: err.Error(),
|
||||
}
|
||||
auditLogInternal(ctx, auditOptions)
|
||||
counts.objectsFailed = 1
|
||||
break
|
||||
}
|
||||
if delay > 0 {
|
||||
time.Sleep(delay + time.Duration(rnd.Float64()*float64(delay)))
|
||||
time.Sleep(delay + time.Duration(rand.Float64()*float64(delay)))
|
||||
}
|
||||
}
|
||||
progress.done(seq, result.Name, counts)
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
// persist in-memory state to disk after every 10secs.
|
||||
batchLogIf(ctx, ri.updateAfter(ctx, api, 10*time.Second, job))
|
||||
|
||||
if wait := globalBatchConfig.KeyRotationWait(); wait > 0 {
|
||||
time.Sleep(wait)
|
||||
@@ -421,18 +590,34 @@ func (r *BatchJobKeyRotateV1) Start(ctx context.Context, api ObjectLayer, job Ba
|
||||
}()
|
||||
}
|
||||
wk.Wait()
|
||||
|
||||
ri.Complete = !failed && ri.ObjectsFailed == 0
|
||||
ri.Failed = failed || ri.ObjectsFailed > 0
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
// persist in-memory state to disk.
|
||||
batchLogIf(ctx, ri.updateAfter(ctx, api, 0, job))
|
||||
|
||||
if err := r.Notify(ctx, ri); err != nil {
|
||||
batchLogIf(ctx, fmt.Errorf("unable to notify %v", err))
|
||||
cause := context.Cause(ctx)
|
||||
if !failed && cause == nil {
|
||||
// The walk ended: its last key is done.
|
||||
progress.finish()
|
||||
}
|
||||
if cause != nil && !ri.refused() {
|
||||
if isBatchJobUnavailable(cause) {
|
||||
return ri.savePaused(ctx, api, job, cause)
|
||||
}
|
||||
// Stopped before the end: keep the work after the cursor pending.
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
batchLogIf(ctx, ri.updateAfter(GlobalContext, api, 0, job))
|
||||
return cause
|
||||
}
|
||||
|
||||
cancel()
|
||||
if !ri.refused() {
|
||||
ri.mu.Lock()
|
||||
ri.Complete = !failed && ri.ObjectsFailed == 0
|
||||
ri.Failed = failed || ri.ObjectsFailed > 0
|
||||
ri.mu.Unlock()
|
||||
}
|
||||
globalBatchJobsMetrics.save(job.ID, ri)
|
||||
// persist in-memory state to disk.
|
||||
batchLogIf(ctx, ri.updateAfter(GlobalContext, api, 0, job))
|
||||
|
||||
if err := r.Notify(GlobalContext, ri); err != nil {
|
||||
batchLogIf(ctx, fmt.Errorf("unable to notify %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// batchAuthzFixture runs batch jobs submitted through the admin API, one job
|
||||
// at a time and in the test's goroutine.
|
||||
type batchAuthzFixture struct {
|
||||
ctx context.Context
|
||||
sys *IAMSys
|
||||
obj ObjectLayer
|
||||
bucket string
|
||||
}
|
||||
|
||||
func newBatchAuthzFixture(t *testing.T) *batchAuthzFixture {
|
||||
t.Helper()
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
obj := newObjectLayerFn()
|
||||
batchTestJobPool(ctx, t, obj)
|
||||
f := &batchAuthzFixture{ctx: ctx, sys: sys, obj: obj, bucket: "batch-authz"}
|
||||
mustIAM(t, obj.MakeBucket(ctx, f.bucket, MakeBucketOptions{VersioningEnabled: true}))
|
||||
_, err := globalBucketMetadataSys.Update(ctx, f.bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
|
||||
mustIAM(t, err)
|
||||
return f
|
||||
}
|
||||
|
||||
// batchTestJobPool queues submitted jobs without running them.
|
||||
func batchTestJobPool(ctx context.Context, t *testing.T, obj ObjectLayer) {
|
||||
t.Helper()
|
||||
prevPool := globalBatchJobPool
|
||||
globalBatchJobPool = &BatchJobPool{
|
||||
ctx: ctx,
|
||||
objLayer: obj,
|
||||
jobCh: make(chan *BatchJobRequest, 10),
|
||||
jobOwners: make(map[string]*batchJobOwner),
|
||||
}
|
||||
globalBatchJobsMetrics.Lock()
|
||||
globalBatchJobsMetrics.metrics = make(map[string]*batchJobInfo)
|
||||
globalBatchJobsMetrics.Unlock()
|
||||
t.Cleanup(func() { globalBatchJobPool = prevPool })
|
||||
}
|
||||
|
||||
// putVersions writes two versions of key and returns the older one.
|
||||
func (f *batchAuthzFixture) putVersions(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
var older string
|
||||
for i := range 2 {
|
||||
data := []byte(fmt.Sprintf("version %d", i))
|
||||
oi, err := f.obj.PutObject(f.ctx, f.bucket, key, mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{Versioned: true})
|
||||
mustIAM(t, err)
|
||||
if i == 0 {
|
||||
older = oi.VersionID
|
||||
}
|
||||
}
|
||||
return older
|
||||
}
|
||||
|
||||
func (f *batchAuthzFixture) versionExists(t *testing.T, key, versionID string) bool {
|
||||
t.Helper()
|
||||
_, err := f.obj.GetObjectInfo(f.ctx, f.bucket, key, ObjectOptions{VersionID: versionID})
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if isErrVersionNotFound(err) || isErrObjectNotFound(err) {
|
||||
return false
|
||||
}
|
||||
t.Fatal(err)
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *batchAuthzFixture) expireYAML(prefix string) string {
|
||||
return fmt.Sprintf(`expire:
|
||||
apiVersion: v1
|
||||
bucket: %s
|
||||
prefix: %q
|
||||
rules:
|
||||
- type: object
|
||||
purge:
|
||||
retainVersions: 1
|
||||
`, f.bucket, prefix)
|
||||
}
|
||||
|
||||
// submit sends a job definition to StartBatchJob as cred. An accepted job is
|
||||
// returned from the queue, not run.
|
||||
func (f *batchAuthzFixture) submit(t *testing.T, cred auth.Credentials, job string) (*httptest.ResponseRecorder, *BatchJobRequest) {
|
||||
t.Helper()
|
||||
headers := map[string]string{}
|
||||
if cred.IsTemp() && !cred.IsServiceAccount() {
|
||||
headers["X-Amz-Security-Token"] = cred.SessionToken
|
||||
}
|
||||
body := []byte(job)
|
||||
req, err := newTestSignedRequestV4(http.MethodPost, "http://localhost/minio/admin/v3/start-job", int64(len(body)), bytes.NewReader(body), cred.AccessKey, cred.SecretKey, headers)
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, req.ParseForm())
|
||||
rec := httptest.NewRecorder()
|
||||
req = req.WithContext(newContext(req, rec, "StartBatchJob"))
|
||||
adminAPIHandlers{}.StartBatchJob(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
return rec, nil
|
||||
}
|
||||
select {
|
||||
case queued := <-globalBatchJobPool.jobCh:
|
||||
return rec, queued
|
||||
default:
|
||||
t.Fatal("accepted job was not queued")
|
||||
return rec, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (f *batchAuthzFixture) run(t *testing.T, job *BatchJobRequest) {
|
||||
t.Helper()
|
||||
ctx := job.ctx
|
||||
if ctx == nil {
|
||||
ctx = f.ctx
|
||||
}
|
||||
switch {
|
||||
case job.Expire != nil:
|
||||
if err := job.Expire.Start(ctx, f.obj, *job); err != nil {
|
||||
t.Logf("start: %v", err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected job type %s", job.Type())
|
||||
}
|
||||
ri := &batchJobInfo{JobID: job.ID, JobType: string(job.Type())}
|
||||
if err := ri.load(f.ctx, f.obj, *job); err == nil {
|
||||
t.Logf("job %s: %+v", job.ID, ri.clone())
|
||||
}
|
||||
}
|
||||
|
||||
func (f *batchAuthzFixture) jobInfo(t *testing.T, job *BatchJobRequest) *batchJobInfo {
|
||||
t.Helper()
|
||||
ri := &batchJobInfo{JobID: job.ID, JobType: string(job.Type())}
|
||||
mustIAM(t, ri.load(f.ctx, f.obj, *job))
|
||||
return ri
|
||||
}
|
||||
|
||||
func batchAuthzPolicy(ctx context.Context, t *testing.T, sys *IAMSys, name, statements string) {
|
||||
t.Helper()
|
||||
p, err := policy.ParseConfig(strings.NewReader(`{"Version":"2012-10-17","Statement":[` + statements + `]}`))
|
||||
mustIAM(t, err)
|
||||
_, err = sys.SetPolicy(ctx, name, *p)
|
||||
mustIAM(t, err)
|
||||
}
|
||||
|
||||
const batchAuthzFullAccess = `{"Effect":"Allow","Action":["admin:StartBatchJob"]},
|
||||
{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::batch-authz","arn:aws:s3:::batch-authz/*"]}`
|
||||
|
||||
// A credential that may start batch jobs but has no access to the bucket
|
||||
// cannot delete through a job.
|
||||
func TestBatchJobSubmitterWithoutDataAccess(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-only", `{"Effect":"Allow","Action":["admin:StartBatchJob","admin:ListBatchJobs"]}`)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "batch-only-user", "batch-only")
|
||||
older := f.putVersions(t, "data/a")
|
||||
rec, job := f.submit(t, cred, f.expireYAML(""))
|
||||
if job != nil {
|
||||
f.run(t, job)
|
||||
}
|
||||
if !f.versionExists(t, "data/a", older) {
|
||||
t.Fatal("a job submitted without data access deleted a version")
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("submission status %d, want 403: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A job acts only while the credential that submitted it is the same valid
|
||||
// credential.
|
||||
func TestBatchJobSubmitterChangedBeforeStart(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
change func(t *testing.T, f *batchAuthzFixture, cred auth.Credentials)
|
||||
}{
|
||||
{"unchanged", nil},
|
||||
{"deleted", func(t *testing.T, f *batchAuthzFixture, cred auth.Credentials) {
|
||||
mustIAM(t, f.sys.DeleteUser(f.ctx, cred.AccessKey, false))
|
||||
}},
|
||||
{"disabled", func(t *testing.T, f *batchAuthzFixture, cred auth.Credentials) {
|
||||
_, err := f.sys.SetUserStatus(f.ctx, cred.AccessKey, madmin.AccountDisabled)
|
||||
mustIAM(t, err)
|
||||
}},
|
||||
{"secret-changed", func(t *testing.T, f *batchAuthzFixture, cred auth.Credentials) {
|
||||
mustIAM(t, f.sys.SetUserSecretKey(f.ctx, cred.AccessKey, "batch-authz-changed-secret"))
|
||||
}},
|
||||
{"access-key-reused", func(t *testing.T, f *batchAuthzFixture, cred auth.Credentials) {
|
||||
mustIAM(t, f.sys.DeleteUser(f.ctx, cred.AccessKey, false))
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
iamAdminUser(f.ctx, t, f.sys, cred.AccessKey, "batch-full")
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "batch-user", "batch-full")
|
||||
older := f.putVersions(t, "data/a")
|
||||
rec, job := f.submit(t, cred, f.expireYAML(""))
|
||||
if job == nil {
|
||||
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if tc.change != nil {
|
||||
tc.change(t, f, cred)
|
||||
}
|
||||
f.run(t, job)
|
||||
exists := f.versionExists(t, "data/a", older)
|
||||
if tc.change == nil && exists {
|
||||
t.Fatal("control: an authorized job did not expire the older version")
|
||||
}
|
||||
if tc.change != nil && !exists {
|
||||
t.Fatal("a job ran as a credential that changed after submission")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An expired temporary credential does not keep its job running.
|
||||
func TestBatchJobSubmitterExpired(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
|
||||
parent := iamAdminUser(f.ctx, t, f.sys, "batch-parent", "batch-full")
|
||||
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{parentClaim: parent.AccessKey, "exp": UTCNow().Add(2 * time.Second).Unix()}, globalActiveCred.SecretKey)
|
||||
mustIAM(t, err)
|
||||
cred.ParentUser = parent.AccessKey
|
||||
_, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "")
|
||||
mustIAM(t, err)
|
||||
older := f.putVersions(t, "data/a")
|
||||
rec, job := f.submit(t, cred, f.expireYAML(""))
|
||||
if job == nil {
|
||||
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
time.Sleep(time.Until(cred.Expiration) + time.Second)
|
||||
f.run(t, job)
|
||||
if !f.versionExists(t, "data/a", older) {
|
||||
t.Fatal("a job ran as an expired credential")
|
||||
}
|
||||
}
|
||||
|
||||
// A session policy limits the job as it limits requests, also when an
|
||||
// authorization plugin decides the rest.
|
||||
func TestBatchJobSessionPolicyDeny(t *testing.T) {
|
||||
session := `{"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":["admin:StartBatchJob"]},
|
||||
{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::batch-authz","arn:aws:s3:::batch-authz/*"]},
|
||||
{"Effect":"Deny","Action":["s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"]}]}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
plugin bool
|
||||
}{{"local", false}, {"plugin-allows", true}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
|
||||
parent := iamAdminUser(f.ctx, t, f.sys, "batch-parent", "batch-full")
|
||||
cred := iamAdminServiceAccount(f.ctx, t, f.sys, parent.AccessKey, "batch-restricted", session)
|
||||
if tc.plugin {
|
||||
iamAdminPlugin(t, true)
|
||||
}
|
||||
older := f.putVersions(t, "data/a")
|
||||
rec, job := f.submit(t, cred, f.expireYAML(""))
|
||||
if job != nil {
|
||||
f.run(t, job)
|
||||
} else {
|
||||
t.Logf("submission status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !f.versionExists(t, "data/a", older) {
|
||||
t.Fatal("a job deleted a version its session policy denies")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A temporary credential's session policy limits its job too.
|
||||
func TestBatchJobTemporarySessionPolicyDeny(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
|
||||
parent := iamAdminUser(f.ctx, t, f.sys, "batch-parent", "batch-full")
|
||||
session := `{"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":["admin:StartBatchJob"]},
|
||||
{"Effect":"Allow","Action":["s3:ListBucket","s3:ListBucketVersions","s3:GetObject","s3:GetObjectVersion","s3:DeleteObject"],"Resource":["arn:aws:s3:::batch-authz","arn:aws:s3:::batch-authz/*"]}]}`
|
||||
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{
|
||||
parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix(),
|
||||
policy.SessionPolicyName: base64.StdEncoding.EncodeToString([]byte(session)),
|
||||
}, globalActiveCred.SecretKey)
|
||||
mustIAM(t, err)
|
||||
cred.ParentUser = parent.AccessKey
|
||||
_, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "")
|
||||
mustIAM(t, err)
|
||||
older := f.putVersions(t, "data/a")
|
||||
rec, job := f.submit(t, cred, f.expireYAML(""))
|
||||
if job != nil {
|
||||
f.run(t, job)
|
||||
} else {
|
||||
t.Logf("submission status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !f.versionExists(t, "data/a", older) {
|
||||
t.Fatal("a job deleted a version its session policy does not allow")
|
||||
}
|
||||
}
|
||||
|
||||
// A Deny that depends on request context the job does not have refuses the
|
||||
// action instead of being skipped.
|
||||
func TestBatchJobAbsentContextDeny(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-context", batchAuthzFullAccess+`,
|
||||
{"Effect":"Deny","Action":["s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}`)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "batch-user", "batch-context")
|
||||
older := f.putVersions(t, "data/a")
|
||||
rec, job := f.submit(t, cred, f.expireYAML(""))
|
||||
if job != nil {
|
||||
f.run(t, job)
|
||||
} else {
|
||||
t.Logf("submission status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !f.versionExists(t, "data/a", older) {
|
||||
t.Fatal("a job skipped a Deny that depends on request context")
|
||||
}
|
||||
}
|
||||
|
||||
// Each object is authorized: a Deny under an allowed prefix keeps its objects.
|
||||
func TestBatchJobPerObjectDeny(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-keep", batchAuthzFullAccess+`,
|
||||
{"Effect":"Deny","Action":["s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/data/keep*"]}`)
|
||||
cred := iamAdminUser(f.ctx, t, f.sys, "batch-user", "batch-keep")
|
||||
kept := f.putVersions(t, "data/keep")
|
||||
rec, job := f.submit(t, cred, f.expireYAML("data/"))
|
||||
if job == nil {
|
||||
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
f.run(t, job)
|
||||
if !f.versionExists(t, "data/keep", kept) {
|
||||
t.Fatal("a job deleted a version that a per-object Deny protects")
|
||||
}
|
||||
if ri := f.jobInfo(t, job); !ri.Failed || ri.Complete {
|
||||
t.Fatalf("refused job: complete=%v failed=%v", ri.Complete, ri.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
// A job queued without its submitter, as an earlier release stored it, fails
|
||||
// and stays failed across another start.
|
||||
func TestBatchJobWithoutSubmitter(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
older := f.putVersions(t, "data/a")
|
||||
job := &BatchJobRequest{}
|
||||
mustIAM(t, yaml.Unmarshal([]byte(f.expireYAML("")), job))
|
||||
job.ID = "expire-legacy" + getKeySeparator() + "0"
|
||||
job.User = globalActiveCred.AccessKey
|
||||
job.Started = time.Now()
|
||||
mustIAM(t, job.save(f.ctx, f.obj))
|
||||
for range 2 {
|
||||
loaded := &BatchJobRequest{}
|
||||
mustIAM(t, loaded.load(f.ctx, f.obj, getJobPath(*job)))
|
||||
f.run(t, loaded)
|
||||
if !f.versionExists(t, "data/a", older) {
|
||||
t.Fatal("a job without a recorded submitter deleted a version")
|
||||
}
|
||||
if ri := f.jobInfo(t, job); !ri.Failed || ri.Complete {
|
||||
t.Fatalf("job without submitter: complete=%v failed=%v", ri.Complete, ri.Failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Root and an authorized user can still run jobs.
|
||||
func TestBatchJobAuthorizedControls(t *testing.T) {
|
||||
for _, root := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("root=%v", root), func(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
cred := globalActiveCred
|
||||
if !root {
|
||||
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
|
||||
cred = iamAdminUser(f.ctx, t, f.sys, "batch-user", "batch-full")
|
||||
}
|
||||
older := f.putVersions(t, "data/a")
|
||||
rec, job := f.submit(t, cred, f.expireYAML(""))
|
||||
if job == nil {
|
||||
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
f.run(t, job)
|
||||
if f.versionExists(t, "data/a", older) {
|
||||
t.Fatal("an authorized job did not expire the older version")
|
||||
}
|
||||
if ri := f.jobInfo(t, job); !ri.Complete || ri.Failed {
|
||||
t.Fatalf("authorized job: complete=%v failed=%v", ri.Complete, ri.Failed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The describe output has no secrets of the job.
|
||||
func TestBatchJobDescribeRedactsSecrets(t *testing.T) {
|
||||
f := newBatchAuthzFixture(t)
|
||||
secrets := []string{"describe-source-secret", "describe-source-token", "describe-target-secret", "describe-target-token", "describe-notify-token"}
|
||||
for _, job := range []BatchJobRequest{
|
||||
{ID: "replicate-describe" + getKeySeparator() + "0", Replicate: &BatchJobReplicateV1{
|
||||
APIVersion: batchReplJobAPIVersion,
|
||||
Source: BatchJobReplicateSource{Bucket: "remote", Endpoint: "https://remote.invalid", Creds: BatchJobReplicateCredentials{
|
||||
AccessKey: "describe-source-access", SecretKey: secrets[0], SessionToken: secrets[1],
|
||||
}},
|
||||
Target: BatchJobReplicateTarget{Bucket: f.bucket, Creds: BatchJobReplicateCredentials{
|
||||
AccessKey: "describe-target-access", SecretKey: secrets[2], SessionToken: secrets[3],
|
||||
}},
|
||||
Flags: BatchJobReplicateFlags{Notify: BatchJobNotification{Endpoint: "https://notify.invalid", Token: secrets[4]}},
|
||||
}},
|
||||
{ID: "expire-describe" + getKeySeparator() + "0", Expire: &BatchJobExpire{
|
||||
APIVersion: batchExpireAPIVersion, Bucket: f.bucket,
|
||||
NotificationCfg: BatchJobNotification{Endpoint: "https://notify.invalid", Token: secrets[4]},
|
||||
}},
|
||||
{ID: "keyrotate-describe" + getKeySeparator() + "0", KeyRotate: &BatchJobKeyRotateV1{
|
||||
APIVersion: batchKeyRotateAPIVersion, Bucket: f.bucket,
|
||||
Flags: BatchJobKeyRotateFlags{Notify: BatchJobNotification{Endpoint: "https://notify.invalid", Token: secrets[4]}},
|
||||
}},
|
||||
} {
|
||||
data, err := job.MarshalMsg(nil)
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, saveConfig(f.ctx, f.obj, getJobPath(job), data))
|
||||
cred := globalActiveCred
|
||||
req, err := newTestSignedRequestV4(http.MethodGet, "http://localhost/minio/admin/v3/describe-job?jobId="+job.ID, 0, nil, cred.AccessKey, cred.SecretKey, nil)
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, req.ParseForm())
|
||||
rec := httptest.NewRecorder()
|
||||
req = req.WithContext(newContext(req, rec, "DescribeBatchJob"))
|
||||
adminAPIHandlers{}.DescribeBatchJob(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: describe status %d: %s", job.ID, rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, s := range secrets {
|
||||
if strings.Contains(rec.Body.String(), s) {
|
||||
t.Errorf("%s: describe output contains %s", job.ID, s)
|
||||
}
|
||||
}
|
||||
if job.Replicate != nil && !strings.Contains(rec.Body.String(), "describe-source-access") {
|
||||
t.Errorf("%s: describe output lost the access key", job.ID)
|
||||
}
|
||||
// The stored job keeps what a run needs.
|
||||
stored := &BatchJobRequest{}
|
||||
mustIAM(t, stored.load(f.ctx, f.obj, getJobPath(job)))
|
||||
if stored.Replicate != nil && stored.Replicate.Source.Creds.SecretKey != secrets[0] {
|
||||
t.Errorf("%s: describe changed the stored job", job.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,14 +511,13 @@ func testSiteReplicationStatusDetectsCorsTimestampMismatch(obj ObjectLayer, _ st
|
||||
}))
|
||||
defer remote.Close()
|
||||
|
||||
serviceCred, err := auth.CreateCredentials("cors-status-service", "cors-status-service-secret-key")
|
||||
serviceCred, _, err := globalIAMSys.NewServiceAccount(ctx, credentials.AccessKey, nil, newServiceAccountOpts{
|
||||
accessKey: "cors-status-service",
|
||||
secretKey: "cors-status-service-secret-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serviceCred.ParentUser = credentials.AccessKey
|
||||
if _, err = globalIAMSys.store.AddServiceAccount(ctx, serviceCred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer globalIAMSys.DeleteServiceAccount(ctx, serviceCred.AccessKey, false)
|
||||
|
||||
globalSiteReplicationSys.Lock()
|
||||
@@ -585,14 +584,13 @@ func testSiteReplicationStatusCountsCorsPerSite(obj ObjectLayer, _ string, bucke
|
||||
}))
|
||||
defer remote.Close()
|
||||
|
||||
serviceCred, err := auth.CreateCredentials("cors-count-service", "cors-count-service-secret-key")
|
||||
serviceCred, _, err := globalIAMSys.NewServiceAccount(ctx, credentials.AccessKey, nil, newServiceAccountOpts{
|
||||
accessKey: "cors-count-service",
|
||||
secretKey: "cors-count-service-secret-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serviceCred.ParentUser = credentials.AccessKey
|
||||
if _, err = globalIAMSys.store.AddServiceAccount(ctx, serviceCred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer globalIAMSys.DeleteServiceAccount(ctx, serviceCred.AccessKey, false)
|
||||
|
||||
globalSiteReplicationSys.Lock()
|
||||
@@ -1190,14 +1188,13 @@ func testCorsReplicationDispatchStatusHealReload(obj ObjectLayer, _ string, buck
|
||||
}))
|
||||
defer remote.Close()
|
||||
|
||||
serviceCred, err := auth.CreateCredentials("cors-integration-svc", "cors-integration-service-secret")
|
||||
serviceCred, _, err := globalIAMSys.NewServiceAccount(ctx, credentials.AccessKey, nil, newServiceAccountOpts{
|
||||
accessKey: "cors-integration-svc",
|
||||
secretKey: "cors-integration-service-secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serviceCred.ParentUser = credentials.AccessKey
|
||||
if _, err = globalIAMSys.store.AddServiceAccount(ctx, serviceCred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer globalIAMSys.DeleteServiceAccount(ctx, serviceCred.AccessKey, false)
|
||||
|
||||
globalSiteReplicationSys.Lock()
|
||||
|
||||
@@ -1273,6 +1273,12 @@ func (api objectAPIHandlers) PostPolicyBucketHandler(w http.ResponseWriter, r *h
|
||||
writeErrorResponseHeadersOnly(w, toAPIError(ctx, err))
|
||||
return
|
||||
}
|
||||
if isDirObject(object) {
|
||||
if _, _, _, s3Err := checkPutObjectLockAllowed(ctx, r, bucket, object, objectAPI.GetObjectInfo, ErrNone, ErrNone, false); s3Err != ErrNone {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
opts.WantChecksum = checksum
|
||||
|
||||
fanOutOpts := fanOutOptions{Checksum: checksum}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/event"
|
||||
)
|
||||
|
||||
// An ILM expiry attempt that ends without removing what it addressed is
|
||||
// incomplete: the rule no longer applied when it was checked again under the
|
||||
// object lock, Object Lock protects the version, the history needed to decide
|
||||
// could not be read, or another error. It is counted by reason, traced with
|
||||
// the reason, and, unless it was refused, audited and logged. It never sends
|
||||
// a removal notification: what it addressed is still there.
|
||||
//
|
||||
// An expiry of all versions of a key can also be refused because a version
|
||||
// is in a remote tier, or because the latest version changed since a batch
|
||||
// matched it, and it can stop at its budget after it removed some versions.
|
||||
const (
|
||||
ilmIncompleteNotApplicable = "not_applicable"
|
||||
ilmIncompleteLocked = "locked"
|
||||
ilmIncompleteHistoryUnavailable = "history_unavailable"
|
||||
ilmIncompleteError = "error"
|
||||
ilmIncompleteTierUnsupported = "tier_unsupported"
|
||||
ilmIncompleteBudget = "budget"
|
||||
ilmIncompleteChanged = "changed"
|
||||
// ilmIncompleteTierSkipped counts the scanner's observations of a key
|
||||
// whose expiry of all versions it did not queue, because a version it
|
||||
// holds is in a remote tier and the purge would refuse it. Each drive's
|
||||
// scanner of the key observes it, once per cycle.
|
||||
ilmIncompleteTierSkipped = "tier_unsupported_skipped"
|
||||
)
|
||||
|
||||
// ilmIncompleteReasons are the reasons expiry_incomplete_total counts, a
|
||||
// bounded set of label values.
|
||||
var ilmIncompleteReasons = []string{
|
||||
ilmIncompleteNotApplicable,
|
||||
ilmIncompleteLocked,
|
||||
ilmIncompleteHistoryUnavailable,
|
||||
ilmIncompleteError,
|
||||
ilmIncompleteTierUnsupported,
|
||||
ilmIncompleteBudget,
|
||||
ilmIncompleteChanged,
|
||||
ilmIncompleteTierSkipped,
|
||||
}
|
||||
|
||||
// ilmIncompleteStats counts incomplete ILM expiries by reason.
|
||||
type ilmIncompleteStats struct {
|
||||
counts map[string]*atomic.Int64
|
||||
}
|
||||
|
||||
func newILMIncompleteStats(reasons ...string) *ilmIncompleteStats {
|
||||
s := &ilmIncompleteStats{counts: make(map[string]*atomic.Int64, len(reasons))}
|
||||
for _, r := range reasons {
|
||||
s.counts[r] = &atomic.Int64{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ilmIncompleteStats) add(reason string) {
|
||||
if c, ok := s.counts[reason]; ok {
|
||||
c.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// snapshot returns the counts by reason.
|
||||
func (s *ilmIncompleteStats) snapshot() map[string]int64 {
|
||||
out := make(map[string]int64, len(s.counts))
|
||||
for r, c := range s.counts {
|
||||
out[r] = c.Load()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var globalILMIncomplete = newILMIncompleteStats(ilmIncompleteReasons...)
|
||||
|
||||
// ilmExpiryNotDone classifies the error of an ILM expiry that did not remove
|
||||
// what it addressed. A version that is already gone is not counted.
|
||||
func ilmExpiryNotDone(err error) (reason string, counted bool) {
|
||||
switch {
|
||||
case err == nil || isErrObjectNotFound(err) || isErrVersionNotFound(err):
|
||||
return "", false
|
||||
case errors.Is(err, errDeleteAllTiered):
|
||||
return ilmIncompleteTierUnsupported, true
|
||||
case errors.Is(err, errDeleteAllChanged):
|
||||
return ilmIncompleteChanged, true
|
||||
case errors.Is(err, errDeleteAllBudget):
|
||||
return ilmIncompleteBudget, true
|
||||
case errors.Is(err, errLifecycleNotApplicable):
|
||||
return ilmIncompleteNotApplicable, true
|
||||
}
|
||||
var locked ObjectLocked
|
||||
if errors.As(err, &locked) {
|
||||
return ilmIncompleteLocked, true
|
||||
}
|
||||
if isErrReadQuorum(err) || isErrWriteQuorum(err) {
|
||||
return ilmIncompleteHistoryUnavailable, true
|
||||
}
|
||||
return ilmIncompleteError, true
|
||||
}
|
||||
|
||||
// ilmExpiryRefused reports whether reason is a refusal, which changes
|
||||
// nothing, rather than a failure.
|
||||
func ilmExpiryRefused(reason string) bool {
|
||||
switch reason {
|
||||
case ilmIncompleteNotApplicable, ilmIncompleteLocked, ilmIncompleteTierUnsupported, ilmIncompleteChanged:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reportILMExpiryNotDone reports an ILM expiry of oi that ended with err. It
|
||||
// reports nothing and returns false when err says the version is already
|
||||
// gone; the caller reports that as it did before.
|
||||
func reportILMExpiryNotDone(ctx context.Context, oi ObjectInfo, lcEvent lifecycle.Event, src lcEventSrc, err error, logID string, traceFn func(event string, metadata map[string]string, err error)) bool {
|
||||
reason, counted := ilmExpiryNotDone(err)
|
||||
if !counted {
|
||||
return false
|
||||
}
|
||||
globalILMIncomplete.add(reason)
|
||||
if ilmExpiryRefused(reason) {
|
||||
traceFn(ILMExpiry, map[string]string{"result": "refused", "reason": reason, "version-id": oi.VersionID}, err)
|
||||
return true
|
||||
}
|
||||
traceFn(ILMExpiry, map[string]string{"result": "failed", "reason": reason, "version-id": oi.VersionID}, err)
|
||||
tags := newLifecycleAuditEvent(src, lcEvent).Tags()
|
||||
tags["version-id"] = oi.VersionID
|
||||
tags["ilm-incomplete-reason"] = reason
|
||||
auditLogInternal(ctx, AuditLogOptions{
|
||||
Event: ILMExpiry,
|
||||
APIName: "ILMExpiry",
|
||||
Bucket: oi.Bucket,
|
||||
Object: oi.Name,
|
||||
VersionID: oi.VersionID,
|
||||
Error: err.Error(),
|
||||
Tags: tags,
|
||||
})
|
||||
ilmLogOnceIf(ctx, fmt.Errorf("ILM expiry of %s/%s (%s) not done: %w", oi.Bucket, oi.Name, oi.VersionID, err), logID)
|
||||
return true
|
||||
}
|
||||
|
||||
// ilmExpiryEventName is the notification of an expiry that completed. An
|
||||
// expiry that addressed no version ID (Expiration of a latest version in a
|
||||
// versioned or suspended bucket) and whose result is a delete marker created
|
||||
// that marker, also when the marker takes the null version ID of the version
|
||||
// it replaces. Any other expiry removed the version it addressed, also when
|
||||
// that version was itself a delete marker.
|
||||
func ilmExpiryEventName(requestedVersionID string, result ObjectInfo) event.Name {
|
||||
if requestedVersionID == "" && result.DeleteMarker {
|
||||
return event.ObjectRemovedDeleteMarkerCreated
|
||||
}
|
||||
return event.ObjectRemovedDelete
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
loghttp "github.com/minio/minio/internal/logger/target/http"
|
||||
"github.com/minio/minio/internal/pubsub"
|
||||
xnet "github.com/pgsty/silo-pkg/v3/net"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
func TestILMExpiryNotDoneClassification(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
err error
|
||||
reason string
|
||||
counted bool
|
||||
}{
|
||||
{nil, "", false},
|
||||
{ObjectNotFound{}, "", false},
|
||||
{VersionNotFound{}, "", false},
|
||||
{errLifecycleNotApplicable, ilmIncompleteNotApplicable, true},
|
||||
{fmt.Errorf("wrapped: %w", errLifecycleNotApplicable), ilmIncompleteNotApplicable, true},
|
||||
{ObjectLocked{}, ilmIncompleteLocked, true},
|
||||
{InsufficientWriteQuorum{}, ilmIncompleteHistoryUnavailable, true},
|
||||
{InsufficientReadQuorum{}, ilmIncompleteHistoryUnavailable, true},
|
||||
{errFaultyDisk, ilmIncompleteError, true},
|
||||
} {
|
||||
reason, counted := ilmExpiryNotDone(tc.err)
|
||||
if reason != tc.reason || counted != tc.counted {
|
||||
t.Errorf("%v: %q %v, want %q %v", tc.err, reason, counted, tc.reason, tc.counted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fixedDeleteResults is an object layer whose bulk deletes return errs.
|
||||
type fixedDeleteResults struct {
|
||||
ObjectLayer
|
||||
errs []error
|
||||
}
|
||||
|
||||
func (o fixedDeleteResults) DeleteObjects(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions) ([]DeletedObject, []error) {
|
||||
deleted := make([]DeletedObject, len(objects))
|
||||
for i, obj := range objects {
|
||||
deleted[i] = DeletedObject{ObjectName: obj.ObjectName, VersionID: obj.VersionID}
|
||||
}
|
||||
return deleted, slices.Clone(o.errs)
|
||||
}
|
||||
|
||||
// ilmTestAudit captures the ILMExpiry audit entries of bucket.
|
||||
func ilmTestAudit(t *testing.T, bucket string) <-chan map[string]any {
|
||||
t.Helper()
|
||||
if len(logger.AuditTargets()) != 0 {
|
||||
t.Fatal("unexpected pre-existing test audit targets")
|
||||
}
|
||||
entries := make(chan map[string]any, 16)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var entry struct {
|
||||
Error string `json:"error"`
|
||||
Tags map[string]any `json:"tags"`
|
||||
API struct {
|
||||
Name, Bucket string
|
||||
Objects []struct {
|
||||
ObjectName string `json:"objectName"`
|
||||
} `json:"objects"`
|
||||
}
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&entry); err == nil && entry.API.Name == "ILMExpiry" && entry.API.Bucket == bucket {
|
||||
tags := entry.Tags
|
||||
if tags == nil {
|
||||
tags = map[string]any{}
|
||||
}
|
||||
tags["error"] = entry.Error
|
||||
if len(entry.API.Objects) > 0 {
|
||||
tags["object"] = entry.API.Objects[0].ObjectName
|
||||
}
|
||||
entries <- tags
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
endpoint, err := xnet.ParseHTTPURL(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if errs := logger.UpdateAuditWebhooks(ctx, map[string]loghttp.Config{"ilm": {Enabled: true, Name: "ilm", Endpoint: endpoint, BatchSize: 1, QueueSize: 128, MaxRetry: 1, RetryIntvl: time.Millisecond, HTTPTimeout: time.Second}}); len(errs) > 0 {
|
||||
t.Fatal(errs)
|
||||
}
|
||||
targets := logger.AuditTargets()
|
||||
t.Cleanup(func() {
|
||||
logger.UpdateAuditWebhooks(ctx, nil)
|
||||
for _, target := range targets {
|
||||
target.Cancel()
|
||||
}
|
||||
server.Close()
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
// A refused or failed noncurrent purge is counted and traced with its reason,
|
||||
// a failure is audited, and only the version that was removed is announced.
|
||||
func TestDeleteObjectVersionsReportsRefusalAndFailure(t *testing.T) {
|
||||
s := newSplitILM(t, versioning.Enabled, splitILMRules)
|
||||
ctx := t.Context()
|
||||
audits := ilmTestAudit(t, s.bucket)
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
events := make(chan event.Event, 8)
|
||||
if err := globalHTTPListen.Subscribe(pubsub.MaskFromMaskable(event.ObjectRemovedAll), events, done, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
traces := make(chan madmin.TraceInfo, 8)
|
||||
if err := globalTrace.Subscribe(madmin.TraceILM, traces, done, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := globalILMIncomplete.snapshot()
|
||||
|
||||
layer := fixedDeleteResults{ObjectLayer: s.z, errs: []error{errLifecycleNotApplicable, InsufficientWriteQuorum{}, nil}}
|
||||
toDel := []ObjectToDelete{
|
||||
{ObjectV: ObjectV{ObjectName: "ncv-a", VersionID: "11111111-1111-1111-1111-111111111111"}},
|
||||
{ObjectV: ObjectV{ObjectName: "ncv-b", VersionID: "22222222-2222-2222-2222-222222222222"}},
|
||||
{ObjectV: ObjectV{ObjectName: "ncv-c", VersionID: "33333333-3333-3333-3333-333333333333"}},
|
||||
}
|
||||
evs := []lifecycle.Event{{Action: lifecycle.DeleteVersionAction}, {Action: lifecycle.DeleteVersionAction}, {Action: lifecycle.DeleteVersionAction}}
|
||||
deleteObjectVersions(ctx, layer, s.bucket, toDel, evs)
|
||||
|
||||
after := globalILMIncomplete.snapshot()
|
||||
if d := after[ilmIncompleteNotApplicable] - before[ilmIncompleteNotApplicable]; d != 1 {
|
||||
t.Errorf("not_applicable moved by %d, want 1", d)
|
||||
}
|
||||
if d := after[ilmIncompleteHistoryUnavailable] - before[ilmIncompleteHistoryUnavailable]; d != 1 {
|
||||
t.Errorf("history_unavailable moved by %d, want 1", d)
|
||||
}
|
||||
var removed []string
|
||||
results := map[string]string{}
|
||||
for drained := false; !drained; {
|
||||
select {
|
||||
case ev := <-events:
|
||||
removed = append(removed, ev.S3.Object.Key)
|
||||
case tr := <-traces:
|
||||
if r := tr.Custom["result"]; r != "" {
|
||||
results[tr.Custom["version-id"]] = r
|
||||
if tr.Error == "" {
|
||||
t.Errorf("%s expiry of %s traced without its error", r, tr.Custom["version-id"])
|
||||
}
|
||||
}
|
||||
default:
|
||||
drained = true
|
||||
}
|
||||
}
|
||||
if !slices.Equal(removed, []string{"ncv-c"}) {
|
||||
t.Errorf("removal events for %v, want only ncv-c", removed)
|
||||
}
|
||||
if results[toDel[0].VersionID] != "refused" || results[toDel[1].VersionID] != "failed" {
|
||||
t.Errorf("traces %v, want refused and failed", results)
|
||||
}
|
||||
// Drain every audit entry of the bucket: until the removal of ncv-c,
|
||||
// audited after the refusal and the failure were reported, has arrived,
|
||||
// and then while the target empties its queue.
|
||||
var got []map[string]any
|
||||
deadline := time.After(10 * time.Second)
|
||||
for removal := false; !removal; {
|
||||
select {
|
||||
case tags := <-audits:
|
||||
got = append(got, tags)
|
||||
removal = removal || tags["object"] == "ncv-c"
|
||||
case <-deadline:
|
||||
t.Fatalf("audits %v: the removal of ncv-c was not audited", got)
|
||||
}
|
||||
}
|
||||
for idle := time.After(time.Second); idle != nil; {
|
||||
select {
|
||||
case tags := <-audits:
|
||||
got = append(got, tags)
|
||||
case <-idle:
|
||||
idle = nil
|
||||
}
|
||||
}
|
||||
perObject := map[any][]map[string]any{}
|
||||
for _, tags := range got {
|
||||
perObject[tags["object"]] = append(perObject[tags["object"]], tags)
|
||||
}
|
||||
if a := perObject["ncv-a"]; len(a) != 0 {
|
||||
t.Errorf("the refusal was audited: %v", a)
|
||||
}
|
||||
if b := perObject["ncv-b"]; len(b) != 1 || b[0]["ilm-incomplete-reason"] != ilmIncompleteHistoryUnavailable || b[0]["error"] == "" {
|
||||
t.Errorf("audits of the failure: %v, want one with its reason and error", b)
|
||||
}
|
||||
if c := perObject["ncv-c"]; len(c) != 1 || c[0]["ilm-incomplete-reason"] != nil {
|
||||
t.Errorf("audits of the removal: %v, want one", c)
|
||||
}
|
||||
}
|
||||
|
||||
// expiryEvent returns the removal events sent while run runs.
|
||||
func expiryEvents(t *testing.T, run func()) []event.Event {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
events := make(chan event.Event, 8)
|
||||
if err := globalHTTPListen.Subscribe(pubsub.MaskFromMaskable(event.ObjectRemovedAll), events, done, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run()
|
||||
var got []event.Event
|
||||
for {
|
||||
select {
|
||||
case ev := <-events:
|
||||
got = append(got, ev)
|
||||
default:
|
||||
return got
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The notification of a completed expiry names what its delete did: the
|
||||
// delete marker it created, or the version it removed.
|
||||
func TestExpiryEventsNameTheResult(t *testing.T) {
|
||||
type expiry struct {
|
||||
name string
|
||||
status versioning.State
|
||||
key string
|
||||
versions []splitVersion
|
||||
expire string // label of the version expired
|
||||
action lifecycle.Action
|
||||
wantEvent event.Name
|
||||
wantID func(s *splitILM, key string) []string // acceptable version IDs
|
||||
}
|
||||
marker := func(s *splitILM, key string) []string {
|
||||
oi, err := s.z.GetObjectInfo(s.t.Context(), s.bucket, key, ObjectOptions{})
|
||||
if !oi.DeleteMarker {
|
||||
s.t.Fatalf("latest of %s is not a marker: %+v %v", key, oi, err)
|
||||
}
|
||||
return []string{oi.VersionID}
|
||||
}
|
||||
label := func(l string) func(s *splitILM, key string) []string {
|
||||
return func(s *splitILM, key string) []string { return []string{s.ids[key+" "+l]} }
|
||||
}
|
||||
null := func(*splitILM, string) []string { return []string{"", nullVersionID} }
|
||||
cases := []expiry{
|
||||
{"latest-versioned", versioning.Enabled, "exp-ev-latest", []splitVersion{splitAt("v1", 5, 0)}, "v1", lifecycle.DeleteAction, event.ObjectRemovedDeleteMarkerCreated, marker},
|
||||
{"noncurrent", versioning.Enabled, "ncv-ev-noncurrent", []splitVersion{splitAt("v1", 5, 0), splitAt("v2", 3, 0)}, "v1", lifecycle.DeleteVersionAction, event.ObjectRemovedDelete, label("v1")},
|
||||
{"expired-marker", versioning.Enabled, "edm-ev-marker", []splitVersion{splitMarker("dm", 5, 0)}, "dm", lifecycle.DeleteVersionAction, event.ObjectRemovedDelete, label("dm")},
|
||||
{"latest-suspended-null", versioning.Suspended, "exp-ev-null", []splitVersion{splitNull("v1", 5, 0)}, "v1", lifecycle.DeleteAction, event.ObjectRemovedDeleteMarkerCreated, null},
|
||||
{"tiered-latest-versioned", versioning.Enabled, "exp-ev-tiered", []splitVersion{splitTiered("v1", 5, 0)}, "v1", lifecycle.DeleteAction, event.ObjectRemovedDeleteMarkerCreated, marker},
|
||||
{"tiered-noncurrent", versioning.Enabled, "ncv-ev-tiered", []splitVersion{splitTiered("v1", 5, 0), splitAt("v2", 3, 0)}, "v1", lifecycle.DeleteVersionAction, event.ObjectRemovedDelete, label("v1")},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := newSplitILM(t, tc.status, splitILMRules)
|
||||
installExpiryTestTier(t, map[string][]byte{"remote-v1": []byte("v1")})
|
||||
for _, v := range tc.versions {
|
||||
s.write(tc.key, v)
|
||||
}
|
||||
vid := s.ids[tc.key+" "+tc.expire]
|
||||
if vid == "" {
|
||||
vid = nullVersionID
|
||||
}
|
||||
oi, err := s.z.GetObjectInfo(t.Context(), s.bucket, tc.key, ObjectOptions{VersionID: vid})
|
||||
if err != nil && !isErrMethodNotAllowed(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oi.Bucket, oi.Name = s.bucket, tc.key
|
||||
lcEvent := lifecycle.Event{Action: tc.action}
|
||||
got := expiryEvents(t, func() {
|
||||
if oi.TransitionedObject.Status != "" {
|
||||
if !applyExpiryOnTransitionedObject(t.Context(), s.z, oi, lcEvent, lcEventSrc_Scanner) {
|
||||
t.Fatal("transitioned expiry did not apply")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !applyExpiryOnNonTransitionedObjects(t.Context(), s.z, oi, lcEvent, lcEventSrc_Scanner) {
|
||||
t.Fatal("expiry did not apply")
|
||||
}
|
||||
})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("events %+v, want one", got)
|
||||
}
|
||||
if got[0].EventName != tc.wantEvent {
|
||||
t.Errorf("event %s, want %s", got[0].EventName, tc.wantEvent)
|
||||
}
|
||||
if want := tc.wantID(s, tc.key); !slices.Contains(want, got[0].S3.Object.VersionID) {
|
||||
t.Errorf("event names version %q, want one of %v", got[0].S3.Object.VersionID, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// expiry_incomplete_total is exported as a counter with a reason label by
|
||||
// both metrics versions.
|
||||
func TestILMExpiryIncompleteMetrics(t *testing.T) {
|
||||
// Metrics v3 exports a counter once it is above zero.
|
||||
for _, r := range ilmIncompleteReasons {
|
||||
globalILMIncomplete.add(r)
|
||||
}
|
||||
// v3, through the collector the metrics handler registers.
|
||||
families, err := newMetricGroups(prometheus.NewRegistry()).mgGatherers[ilmCollectorPath].Gather()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := ilmCollectorPath.metricPrefix() + "_" + string(expiryIncompleteTotal)
|
||||
reasons := map[string]bool{}
|
||||
for _, family := range families {
|
||||
if family.GetName() != want {
|
||||
continue
|
||||
}
|
||||
for _, m := range family.Metric {
|
||||
if m.Counter == nil || m.GetCounter().GetValue() <= 0 {
|
||||
t.Errorf("v3 %s: %v, want a counter above zero", want, m)
|
||||
}
|
||||
for _, l := range m.Label {
|
||||
if l.GetName() == "reason" {
|
||||
reasons[l.GetValue()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, r := range ilmIncompleteReasons {
|
||||
if !reasons[r] {
|
||||
t.Errorf("v3: %s{reason=%q} not exported", want, r)
|
||||
}
|
||||
}
|
||||
reasons = map[string]bool{}
|
||||
for _, mv := range getILMNodeMetrics().Get() {
|
||||
if mv.Description.Name == expiryIncompleteTotal {
|
||||
if mv.Description.Type != counterMetric {
|
||||
t.Errorf("v2 type %v", mv.Description.Type)
|
||||
}
|
||||
reasons[mv.VariableLabels["reason"]] = true
|
||||
}
|
||||
}
|
||||
for _, r := range ilmIncompleteReasons {
|
||||
if !reasons[r] {
|
||||
t.Errorf("v2: reason %q not exported", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
)
|
||||
|
||||
// Read the actual metadata from every disk without object-layer read repair.
|
||||
func restoreBoundarySnapshot(t *testing.T, r *restoreTest) [][]byte {
|
||||
t.Helper()
|
||||
var result [][]byte
|
||||
for _, pool := range r.z.serverPools {
|
||||
for _, disk := range pool.getHashedSet(r.object).getDisks() {
|
||||
raw, err := disk.ReadXL(t.Context(), r.bucket, r.object, true)
|
||||
if err != nil && err != errFileNotFound {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = append(result, bytes.Clone(raw.Buf))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestExpireRestoredKeepsReplacementDeleteMarker(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
t.Run(fmt.Sprintf("pools=%d", pools), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Suspended)
|
||||
r.put("old", false, false)
|
||||
oi := r.put("null", true, true)
|
||||
r.restore(oi)
|
||||
stale, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(nullVersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.z.DeleteObject(t.Context(), r.bucket, r.object, r.opts("")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.fileInfo(nullVersionID).Deleted {
|
||||
t.Fatal("ordinary DELETE did not replace null with a delete marker")
|
||||
}
|
||||
before := restoreBoundarySnapshot(t, r)
|
||||
err = expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner)
|
||||
if !isErrVersionNotFound(err) {
|
||||
t.Errorf("stale cleanup should reject the replacement marker: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, restoreBoundarySnapshot(t, r)) {
|
||||
t.Error("stale cleanup changed stored versions or their data")
|
||||
}
|
||||
if _, _, err := r.read(""); !isErrObjectNotFound(err) {
|
||||
t.Errorf("stale cleanup exposed the old version: %v", err)
|
||||
}
|
||||
// An explicit user purge must still remove that same marker.
|
||||
if _, err := r.z.DeleteObject(t.Context(), r.bucket, r.object, r.opts(nullVersionID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if label, data, err := r.read(""); err != nil || label != "old" || !bytes.Equal(data, r.bodies["old"]) {
|
||||
t.Fatalf("ordinary marker purge: label=%s err=%v", label, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireRestoredMetadataReadKeepsMinorityVersion(t *testing.T) {
|
||||
for _, pools := range []int{1, 2} {
|
||||
for _, operation := range []string{"cleanup", "restore", "restore-cleanup", "lookup-cleanup", "lookup-restore", "get", "delete"} {
|
||||
t.Run(fmt.Sprintf("pools=%d/%s", pools, operation), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, pools, versioning.Enabled)
|
||||
oi := r.put("target", false, true)
|
||||
r.restore(oi)
|
||||
fi := r.fileInfo(oi.VersionID)
|
||||
// A readable latest version lets pools lookup finish before the
|
||||
// set reads the specifically addressed, incomplete old version.
|
||||
r.put("newer", false, false)
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
disks := er.getDisks()
|
||||
for _, disk := range disks[1:] {
|
||||
raw, err := disk.ReadXL(t.Context(), r.bucket, r.object, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var meta xlMetaV2
|
||||
if err := meta.Load(raw.Buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
del := FileInfo{VersionID: oi.VersionID}
|
||||
del.SetSkipTierFreeVersion()
|
||||
if _, err := meta.DeleteVersion(del); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf, err := meta.AppendTo(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Remove only this metadata entry; retain every real shard.
|
||||
if err := disk.WriteAll(t.Context(), r.bucket, pathJoin(r.object, xlStorageFormatFile), buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
before := restoreBoundarySnapshot(t, r)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
opts := r.opts(oi.VersionID)
|
||||
var err error
|
||||
switch operation {
|
||||
case "cleanup":
|
||||
err = expireTransitionedObject(ctx, r.z, &oi, lifecycle.Event{Action: lifecycle.DeleteRestoredVersionAction}, lcEventSrc_Scanner)
|
||||
case "restore":
|
||||
opts.Transition.RestoreRequest = &RestoreObjectRequest{Days: 1}
|
||||
err = r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, opts)
|
||||
case "restore-cleanup":
|
||||
// The initial read succeeded before remote download failed;
|
||||
// cleanup must not purge a now-incomplete target on reread.
|
||||
err = er.clearRestoreStatus(ctx, r.bucket, r.object, oi.VersionID, nil)
|
||||
case "lookup-cleanup", "lookup-restore":
|
||||
// Version-sensitive lookup also covers native restore routing.
|
||||
opts.MetadataChg = true
|
||||
if operation == "lookup-cleanup" {
|
||||
opts.Transition.ExpireRestored = true
|
||||
} else {
|
||||
opts.Transition.RestoreRequest = &RestoreObjectRequest{Days: 1}
|
||||
}
|
||||
_, _, err = r.z.getPoolInfoExistingWithOpts(ctx, r.bucket, r.object, opts)
|
||||
case "get":
|
||||
_, err = r.z.GetObjectInfo(ctx, r.bucket, r.object, opts)
|
||||
case "delete":
|
||||
_, err = r.z.DeleteObject(ctx, r.bucket, r.object, opts)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("%s timed out instead of returning its read error: %v", operation, err)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("incomplete target unexpectedly readable")
|
||||
}
|
||||
if operation != "get" && operation != "delete" {
|
||||
if !reflect.DeepEqual(before, restoreBoundarySnapshot(t, r)) {
|
||||
t.Error("failed restore read changed metadata, inline data or free versions")
|
||||
}
|
||||
if n := r.dataDirs(fi.DataDir); n != len(disks) {
|
||||
t.Errorf("failed restore read removed shards: %d/%d directories remain", n, len(disks))
|
||||
}
|
||||
return
|
||||
}
|
||||
// Ordinary GET/DELETE retain their existing dangling cleanup.
|
||||
raw, err := disks[0].ReadXL(t.Context(), r.bucket, r.object, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions, err := getFileInfoVersions(raw.Buf, r.bucket, r.object, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(versions.Versions) != 1 || len(versions.FreeVersions) != 1 || r.dataDirs(fi.DataDir) != len(disks)-1 {
|
||||
t.Fatal("ordinary dangling cleanup no longer removed the minority version")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeRestoreInitialMissingReadDoesNotWrite(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
r.put("live", false, false)
|
||||
before := restoreBoundarySnapshot(t, r)
|
||||
opts := r.opts(mustGetUUID())
|
||||
opts.Transition.RestoreRequest = &RestoreObjectRequest{Days: 1}
|
||||
if err := r.z.RestoreTransitionedObject(t.Context(), r.bucket, r.object, opts); !isErrVersionNotFound(err) {
|
||||
t.Fatalf("missing restore target: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, restoreBoundarySnapshot(t, r)) {
|
||||
t.Fatal("initial restore failure wrote metadata without a resolved version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireRestoredStorageRejectsOtherTypes(t *testing.T) {
|
||||
for _, kind := range []string{"null-marker", "uuid-marker", "legacy", "legacy-json"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
disk, _, err := newXLStorageTestSetup(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := t.Context()
|
||||
const bucket, object = "restore-types", "obj"
|
||||
if err := disk.MakeVol(ctx, bucket); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var meta xlMetaV2
|
||||
versionID, metaName, dataDir := nullVersionID, xlStorageFormatFile, ""
|
||||
var buf []byte
|
||||
switch kind {
|
||||
case "null-marker", "uuid-marker":
|
||||
if kind == "uuid-marker" {
|
||||
versionID = mustGetUUID()
|
||||
}
|
||||
err = meta.AddVersion(FileInfo{VersionID: versionID, Deleted: true, ModTime: time.Now().UTC()})
|
||||
case "legacy", "legacy-json":
|
||||
legacy := getSampleXLMeta(1)
|
||||
legacy.DataDir, dataDir = "legacy", "legacy"
|
||||
if kind == "legacy-json" {
|
||||
metaName = xlStorageFormatFileV1
|
||||
buf, err = json.Marshal(legacy)
|
||||
disk.storage.Lock()
|
||||
disk.storage.formatLegacy = true
|
||||
disk.storage.Unlock()
|
||||
} else {
|
||||
err = meta.AddLegacy(&legacy)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf == nil {
|
||||
buf, err = meta.AppendTo(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := disk.WriteAll(ctx, bucket, pathJoin(object, metaName), buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := []byte("sole legacy contents")
|
||||
dataPath := filepath.Join(disk.Endpoint().Path, bucket, object, dataDir, "part.1")
|
||||
if dataDir != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(dataPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dataPath, payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := disk.DeleteVersion(ctx, bucket, object, FileInfo{VersionID: versionID, ExpireRestored: true, TransitionStatus: lifecycle.TransitionComplete}, false, DeleteOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := disk.ReadAll(ctx, bucket, pathJoin(object, metaName))
|
||||
if err != nil || !bytes.Equal(buf, after) {
|
||||
t.Errorf("restore cleanup changed %s metadata: %v", kind, err)
|
||||
}
|
||||
if dataDir != "" {
|
||||
if data, err := os.ReadFile(dataPath); err != nil || !bytes.Equal(data, payload) {
|
||||
t.Errorf("restore cleanup removed legacy contents: %v", err)
|
||||
}
|
||||
}
|
||||
if err := disk.DeleteVersion(ctx, bucket, object, FileInfo{VersionID: versionID}, false, DeleteOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := disk.ReadAll(ctx, bucket, pathJoin(object, metaName)); err != errFileNotFound {
|
||||
t.Errorf("ordinary purge left %s: %v", kind, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
const restoreSafetyRules = `<LifecycleConfiguration><Rule><ID>tier</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter><Transition><Days>1000</Days><StorageClass>WARM</StorageClass></Transition></Rule></LifecycleConfiguration>`
|
||||
|
||||
// Reuse the original restore-copy fixture with a small, isolated real cluster.
|
||||
func newRestoreSafetyTest(t *testing.T, pools int, state versioning.State) *restoreTest {
|
||||
t.Helper()
|
||||
prevTier := globalTierConfigMgr
|
||||
t.Cleanup(func() { globalTierConfigMgr = prevTier })
|
||||
dirs, err := getRandomDisks(4 * pools)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { removeRoots(dirs) })
|
||||
var endpoints EndpointServerPools
|
||||
for i := range pools {
|
||||
endpoints = append(endpoints, mustGetPoolEndpoints(i, dirs[4*i:4*i+4]...)...)
|
||||
}
|
||||
obj, _, err := initObjectLayer(t.Context(), endpoints)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()) })
|
||||
globalTierConfigMgr = NewTierConfigMgr()
|
||||
globalTierConfigMgr.drivercache["WARM"] = &restoreTestTier{tierExpiryTestBackend: &tierExpiryTestBackend{objects: map[string][]byte{}}}
|
||||
r := &restoreTest{t: t, z: z, bucket: getRandomBucketName(), object: "obj", labels: map[string]string{}, bodies: map[string][]byte{}}
|
||||
if err := z.MakeBucket(t.Context(), r.bucket, MakeBucketOptions{VersioningEnabled: state != ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := globalBucketMetadataSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state == versioning.Suspended {
|
||||
meta.VersioningConfigXML = []byte(`<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>`)
|
||||
}
|
||||
meta.LifecycleConfigXML = []byte(restoreSafetyRules)
|
||||
if err := meta.Save(t.Context(), z); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(r.bucket, meta)
|
||||
return r
|
||||
}
|
||||
|
||||
type restoreCleanupFaultDisk struct {
|
||||
consistencyReadFaultDisk
|
||||
deletes *atomic.Int64
|
||||
}
|
||||
|
||||
func (d restoreCleanupFaultDisk) DeleteVersion(ctx context.Context, bucket, object string, fi FileInfo, force bool, opts DeleteOptions) error {
|
||||
if bucket == d.bucket && object == d.object {
|
||||
d.deletes.Add(1)
|
||||
}
|
||||
return d.StorageAPI.DeleteVersion(ctx, bucket, object, fi, force, opts)
|
||||
}
|
||||
|
||||
func TestExpireRestoredRequiresFreshMetadata(t *testing.T) {
|
||||
for _, restored := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("expire-restored=%v", restored), func(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, "")
|
||||
oi := r.put("v1", true, true)
|
||||
r.restore(oi)
|
||||
before := r.fileInfo(oi.VersionID)
|
||||
er := r.z.serverPools[0].getHashedSet(r.object)
|
||||
original := er.getDisks
|
||||
disks := original()
|
||||
var deletes atomic.Int64
|
||||
faulty := make([]StorageAPI, len(disks))
|
||||
for i, d := range disks {
|
||||
faulty[i] = restoreCleanupFaultDisk{consistencyReadFaultDisk{d, r.bucket, r.object}, &deletes}
|
||||
}
|
||||
er.getDisks = func() []StorageAPI { return faulty }
|
||||
// Restore the callback before any fixture reads and on a fatal assertion.
|
||||
defer func() { er.getDisks = original }()
|
||||
opts := ObjectOptions{VersionID: nullVersionID, Transition: TransitionOptions{ExpireRestored: restored}, Expiration: ExpirationOptions{Expire: restored}}
|
||||
_, err := er.DeleteObject(t.Context(), r.bucket, r.object, opts)
|
||||
er.getDisks = original
|
||||
if restored {
|
||||
var quorum InsufficientWriteQuorum
|
||||
if !errors.As(err, &quorum) {
|
||||
t.Errorf("cleanup without fresh metadata: got %v, want write-quorum error", err)
|
||||
}
|
||||
if n := deletes.Load(); n != 0 {
|
||||
t.Errorf("cleanup attempted %d blind disk deletes", n)
|
||||
}
|
||||
if after := r.fileInfo(oi.VersionID); !reflect.DeepEqual(before, after) {
|
||||
t.Error("failed metadata read changed the stored version")
|
||||
}
|
||||
if n := r.dataDirs(before.DataDir); n != len(disks) {
|
||||
t.Errorf("cleanup removed data: %d/%d directories remain", n, len(disks))
|
||||
}
|
||||
} else {
|
||||
// Ordinary unversioned DELETE retains the existing tryDel behavior.
|
||||
if err != nil || deletes.Load() != int64(len(disks)) {
|
||||
t.Fatalf("ordinary DELETE: err=%v, deletes=%d", err, deletes.Load())
|
||||
}
|
||||
if _, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, ObjectOptions{}); !isErrObjectNotFound(err) {
|
||||
t.Fatalf("ordinary DELETE left the object: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireRestoredRenewedOrMissing(t *testing.T) {
|
||||
r := newRestoreSafetyTest(t, 1, versioning.Enabled)
|
||||
oi := r.put("v1", false, true)
|
||||
r.restore(oi)
|
||||
stale, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := r.fileInfo(oi.VersionID)
|
||||
renewed := completedRestoreObj(time.Now().Add(48 * time.Hour)).String()
|
||||
opts := r.opts(oi.VersionID)
|
||||
opts.EvalMetadataFn = func(o *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
o.UserDefined[xhttp.AmzRestore] = renewed
|
||||
return ReplicateDecision{}, nil
|
||||
}
|
||||
if _, err := r.z.PutObjectMetadata(t.Context(), r.bucket, r.object, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = expireTransitionedObject(t.Context(), r.z, &stale, lifecycle.Event{Action: lifecycle.DeleteRestoredAction}, lcEventSrc_Scanner)
|
||||
if !errors.Is(err, errLifecycleNotApplicable) {
|
||||
t.Errorf("stale task should be rejected, got %v", err)
|
||||
}
|
||||
if got := r.fileInfo(oi.VersionID); got.Metadata[xhttp.AmzRestore] != renewed || got.DataDir != before.DataDir {
|
||||
t.Error("stale task changed renewed restore")
|
||||
}
|
||||
if n := r.dataDirs(before.DataDir); n != 4 {
|
||||
t.Errorf("renewed copy has %d directories, want 4", n)
|
||||
}
|
||||
if _, data, err := r.read(oi.VersionID); err != nil || !bytes.Equal(data, r.bodies["v1"]) {
|
||||
t.Fatalf("renewed copy not readable: %v", err)
|
||||
}
|
||||
|
||||
for _, id := range []string{mustGetUUID(), nullVersionID} {
|
||||
opts := r.opts(id)
|
||||
opts.Expiration.Expire = true
|
||||
opts.Transition.ExpireRestored = true
|
||||
if _, err := r.z.DeleteObject(t.Context(), r.bucket, r.object, opts); !isErrVersionNotFound(err) {
|
||||
t.Errorf("missing version %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
missing := r.opts(nullVersionID)
|
||||
missing.Expiration.Expire = true
|
||||
missing.Transition.ExpireRestored = true
|
||||
if _, err := r.z.DeleteObject(t.Context(), r.bucket, "missing-object", missing); !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
||||
t.Errorf("missing object: %v", err)
|
||||
}
|
||||
if got := r.fileInfo(oi.VersionID); got.Metadata[xhttp.AmzRestore] != renewed {
|
||||
t.Error("missing-target cleanup changed existing restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireRestoredStorageBoundary(t *testing.T) {
|
||||
for _, status := range []string{"", lifecycle.TransitionPending, lifecycle.TransitionComplete} {
|
||||
for _, inline := range []bool{false, true} {
|
||||
for _, requestStatus := range []string{"", lifecycle.TransitionComplete} {
|
||||
t.Run(fmt.Sprintf("stored=%s/inline=%v/request=%s", status, inline, requestStatus), func(t *testing.T) {
|
||||
disk, _, err := newXLStorageTestSetup(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const bucket, object = "restore-storage", "obj"
|
||||
ctx := t.Context()
|
||||
if err := disk.MakeVol(ctx, bucket); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi := newFileInfo(object, 2, 2)
|
||||
fi.VersionID, fi.DataDir = mustGetUUID(), mustGetUUID()
|
||||
fi.ModTime, fi.Size = time.Now().UTC(), 1024
|
||||
fi.TransitionStatus = status
|
||||
fi.AddObjectPart(1, "", fi.Size, fi.Size, fi.ModTime, nil, nil)
|
||||
fi.Metadata = map[string]string{xhttp.AmzRestore: completedRestoreObj(time.Now().Add(-time.Hour)).String(), "x-amz-meta-preserve": "keep"}
|
||||
payload := []byte("sole local contents")
|
||||
if inline {
|
||||
fi.Data = payload
|
||||
fi.SetInlineData()
|
||||
}
|
||||
if err := disk.WriteMetadata(ctx, "", bucket, object, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(disk.Endpoint().Path, bucket, object, fi.DataDir)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "part.1"), payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := disk.ReadXL(ctx, bucket, object, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var xl xlMetaV2
|
||||
if err := xl.Load(raw.Buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
del := FileInfo{VersionID: fi.VersionID, ExpireRestored: true, TransitionStatus: requestStatus}
|
||||
dataDir, err := xl.DeleteVersion(del)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDir := ""
|
||||
if status == lifecycle.TransitionComplete {
|
||||
wantDir = fi.DataDir
|
||||
}
|
||||
if dataDir != wantDir {
|
||||
t.Errorf("format authorized directory %q, want %q", dataDir, wantDir)
|
||||
}
|
||||
before, err := getFileInfo(raw.Buf, bucket, object, fi.VersionID, fileInfoOpts{Data: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := disk.DeleteVersion(ctx, bucket, object, del, false, DeleteOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterRaw, err := disk.ReadXL(ctx, bucket, object, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := getFileInfo(afterRaw.Buf, bucket, object, fi.VersionID, fileInfoOpts{Data: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, dirErr := os.ReadFile(filepath.Join(dir, "part.1"))
|
||||
if status != lifecycle.TransitionComplete {
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Error("non-complete cleanup changed metadata or inline data")
|
||||
}
|
||||
if dirErr != nil || !bytes.Equal(data, payload) {
|
||||
t.Errorf("sole local contents removed: %v", dirErr)
|
||||
}
|
||||
} else {
|
||||
if !os.IsNotExist(dirErr) {
|
||||
t.Errorf("completed restore directory still present: %v", dirErr)
|
||||
}
|
||||
if len(after.Data) != 0 || after.Metadata[xhttp.AmzRestore] != "" {
|
||||
t.Error("completed restore kept cached data or restore headers")
|
||||
}
|
||||
if after.TransitionStatus != status || after.VersionID != before.VersionID || after.Metadata["x-amz-meta-preserve"] != "keep" {
|
||||
t.Error("cleanup changed the transitioned version identity or user metadata")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
// Copyright (c) 2026 Denis Babchuk
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// restoreTestTier is a remote tier held in memory that counts its reads.
|
||||
type restoreTestTier struct {
|
||||
*tierExpiryTestBackend
|
||||
gets atomic.Int64
|
||||
}
|
||||
|
||||
func (b *restoreTestTier) Get(ctx context.Context, object string, rv remoteVersionID, opts WarmBackendGetOpts) (io.ReadCloser, error) {
|
||||
b.gets.Add(1)
|
||||
return b.tierExpiryTestBackend.Get(ctx, object, rv, opts)
|
||||
}
|
||||
|
||||
// restoreTest is a cluster with a remote tier WARM and a bucket whose
|
||||
// versions are transitioned to it and restored.
|
||||
type restoreTest struct {
|
||||
t *testing.T
|
||||
z *erasureServerPools
|
||||
bucket string
|
||||
object string
|
||||
queue chan expiryOp
|
||||
labels map[string]string // version ID ("null" for the null version) -> label
|
||||
bodies map[string][]byte // label -> data
|
||||
}
|
||||
|
||||
func restoreTestVersionKey(versionID string) string {
|
||||
if versionID == "" {
|
||||
return nullVersionID
|
||||
}
|
||||
return versionID
|
||||
}
|
||||
|
||||
// put writes a version of the object to the first pool. A restored version
|
||||
// is 1 MiB, so that its restored copy has a data directory of its own.
|
||||
func (r *restoreTest) put(label string, null, restored bool) ObjectInfo {
|
||||
r.t.Helper()
|
||||
body := []byte(r.object + " " + label)
|
||||
if restored {
|
||||
body = bytes.Repeat(body, (1<<20)/len(body)+1)
|
||||
}
|
||||
opts := ObjectOptions{Versioned: !null}
|
||||
if null {
|
||||
opts.VersionSuspended = globalBucketVersioningSys.PrefixSuspended(r.bucket, r.object)
|
||||
}
|
||||
oi, err := r.z.serverPools[0].PutObject(r.t.Context(), r.bucket, r.object,
|
||||
mustGetPutObjReader(r.t, bytes.NewReader(body), int64(len(body)), "", ""), opts)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
r.labels[restoreTestVersionKey(oi.VersionID)] = label
|
||||
r.bodies[label] = body
|
||||
return oi
|
||||
}
|
||||
|
||||
// opts returns the options of a request on a version of the object, as the
|
||||
// S3 handlers set them.
|
||||
func (r *restoreTest) opts(versionID string) ObjectOptions {
|
||||
return ObjectOptions{
|
||||
VersionID: versionID,
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(r.bucket, r.object),
|
||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(r.bucket, r.object),
|
||||
}
|
||||
}
|
||||
|
||||
// restore transitions a version to the tier, restores it, and moves the
|
||||
// expiry date of the restore into the past.
|
||||
func (r *restoreTest) restore(oi ObjectInfo) {
|
||||
r.t.Helper()
|
||||
ctx := r.t.Context()
|
||||
vopts := r.opts(oi.VersionID)
|
||||
topts := vopts
|
||||
topts.MTime = oi.ModTime
|
||||
topts.Transition = TransitionOptions{Tier: "WARM", ETag: oi.ETag}
|
||||
if err := r.z.TransitionObject(ctx, r.bucket, r.object, topts); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
ropts := vopts
|
||||
ropts.Transition = TransitionOptions{RestoreRequest: &RestoreObjectRequest{Days: 1}}
|
||||
// Through the pool, as with a single pool: with more than one pool the
|
||||
// pools layer holds the object lock that the write of the restored copy
|
||||
// waits for.
|
||||
if err := r.z.serverPools[0].RestoreTransitionedObject(ctx, r.bucket, r.object, ropts); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
expired := completedRestoreObj(time.Now().Add(-24 * time.Hour).UTC()).String()
|
||||
mopts := vopts
|
||||
mopts.MTime = oi.ModTime
|
||||
mopts.EvalMetadataFn = func(o *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
o.UserDefined[xhttp.AmzRestore] = expired
|
||||
return ReplicateDecision{}, nil
|
||||
}
|
||||
if _, err := r.z.PutObjectMetadata(ctx, r.bucket, r.object, mopts); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// fileInfo returns the version of the object from a drive of the first pool.
|
||||
func (r *restoreTest) fileInfo(versionID string) FileInfo {
|
||||
r.t.Helper()
|
||||
infos, errs := readAllFileInfo(r.t.Context(), r.z.serverPools[0].getHashedSet(r.object).getDisks(), "", r.bucket, r.object, restoreTestVersionKey(versionID), false, false)
|
||||
if errs[0] != nil {
|
||||
r.t.Fatal(errs[0])
|
||||
}
|
||||
return infos[0]
|
||||
}
|
||||
|
||||
// dataDirs returns how many drives of the first pool hold the data
|
||||
// directory of a version.
|
||||
func (r *restoreTest) dataDirs(dataDir string) int {
|
||||
r.t.Helper()
|
||||
n := 0
|
||||
for _, disk := range r.z.serverPools[0].getHashedSet(r.object).getDisks() {
|
||||
_, err := os.Stat(filepath.Join(disk.Endpoint().Path, r.bucket, r.object, dataDir))
|
||||
if err == nil {
|
||||
n++
|
||||
} else if !os.IsNotExist(err) {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// versions returns the versions of the object in each pool, newest first,
|
||||
// and its free versions in all pools.
|
||||
func (r *restoreTest) versions() (views []string, free int) {
|
||||
r.t.Helper()
|
||||
for _, pool := range r.z.serverPools {
|
||||
rf, err := pool.getHashedSet(r.object).getDisks()[0].ReadXL(r.t.Context(), r.bucket, r.object, false)
|
||||
if errors.Is(err, errFileNotFound) {
|
||||
views = append(views, "")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
fivs, err := getFileInfoVersions(rf.Buf, r.bucket, r.object, false)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
var labels []string
|
||||
for _, fi := range fivs.Versions {
|
||||
if fi.Deleted {
|
||||
labels = append(labels, "DM")
|
||||
} else {
|
||||
labels = append(labels, r.labels[restoreTestVersionKey(fi.VersionID)])
|
||||
}
|
||||
}
|
||||
views = append(views, strings.Join(labels, " "))
|
||||
free += len(fivs.FreeVersions)
|
||||
}
|
||||
return views, free
|
||||
}
|
||||
|
||||
// read returns the label and the data of a version, or of the latest version
|
||||
// when versionID is empty, or the error of the read.
|
||||
func (r *restoreTest) read(versionID string) (string, []byte, error) {
|
||||
r.t.Helper()
|
||||
gr, err := r.z.GetObjectNInfo(r.t.Context(), r.bucket, r.object, nil, nil, ObjectOptions{VersionID: versionID})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer gr.Close()
|
||||
data, err := io.ReadAll(gr)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return r.labels[restoreTestVersionKey(gr.ObjInfo.VersionID)], data, nil
|
||||
}
|
||||
|
||||
// runQueue runs the queued expiries with an expiry worker.
|
||||
func (r *restoreTest) runQueue() {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
globalExpiryState.Worker(r.queue)
|
||||
}()
|
||||
r.queue <- nil // the worker returns once it ran every expiry queued before
|
||||
<-done
|
||||
}
|
||||
|
||||
// objInfos returns the versions of the object as a drive of the
|
||||
// first pool holds them, as the scanner and the walks of decommission and
|
||||
// the drive heal see them.
|
||||
func (r *restoreTest) objInfos() []ObjectInfo {
|
||||
r.t.Helper()
|
||||
rf, err := r.z.serverPools[0].getHashedSet(r.object).getDisks()[0].ReadXL(r.t.Context(), r.bucket, r.object, false)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
fivs, err := getFileInfoVersions(rf.Buf, r.bucket, r.object, false)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
versioned := globalBucketVersioningSys.PrefixEnabled(r.bucket, r.object) || globalBucketVersioningSys.PrefixSuspended(r.bucket, r.object)
|
||||
objInfos := make([]ObjectInfo, len(fivs.Versions))
|
||||
for i, fi := range fivs.Versions {
|
||||
objInfos[i] = fi.ToObjectInfo(r.bucket, r.object, versioned)
|
||||
}
|
||||
return objInfos
|
||||
}
|
||||
|
||||
// The restored copy of a transitioned version expires on the expiry date of
|
||||
// its restore. The expiry removes the copy and nothing else: the version
|
||||
// stays where it is in the version stack, transitioned, and is read from the
|
||||
// tier again. Every path that applies ILM runs the expiry: the scanner and a
|
||||
// GET or HEAD queue it for an expiry worker, decommission and the drive heal
|
||||
// run it at once. The expiry of the restored copy of the latest version used
|
||||
// to delete the object instead: a versioned bucket got a delete marker that
|
||||
// hid it. With versioning suspended it went to the null version, removed its
|
||||
// data if there was one and failed if not, and the restored copy stayed.
|
||||
func TestExpireRestoredCopy(t *testing.T) {
|
||||
prevTier := globalTierConfigMgr
|
||||
t.Cleanup(func() { globalTierConfigMgr = prevTier })
|
||||
prevExpiry := globalExpiryState
|
||||
t.Cleanup(func() { globalExpiryState = prevExpiry })
|
||||
|
||||
// Nothing here is old enough to transition; the rule makes the bucket
|
||||
// one with a tier, as restored versions are in.
|
||||
const rules = `<LifecycleConfiguration><Rule><ID>tier</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter>` +
|
||||
`<Transition><Days>1000</Days><StorageClass>WARM</StorageClass></Transition></Rule></LifecycleConfiguration>`
|
||||
|
||||
type version struct {
|
||||
label string
|
||||
null bool // the null version
|
||||
restored bool // transitioned and restored, the restore has expired
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
versioning versioning.State // "", Enabled or Suspended
|
||||
versions []version // oldest first
|
||||
}{
|
||||
{name: "versioned", versioning: versioning.Enabled, versions: []version{{label: "v1"}, {label: "v2", restored: true}}},
|
||||
{name: "versioned-noncurrent", versioning: versioning.Enabled, versions: []version{{label: "v1", restored: true}, {label: "v2"}}},
|
||||
{name: "versioned-null", versioning: versioning.Enabled, versions: []version{{label: "v1", null: true, restored: true}}},
|
||||
{name: "suspended", versioning: versioning.Suspended, versions: []version{{label: "v1", null: true}, {label: "v2", restored: true}}},
|
||||
{name: "suspended-no-null", versioning: versioning.Suspended, versions: []version{{label: "v1"}, {label: "v2", restored: true}}},
|
||||
{name: "suspended-null", versioning: versioning.Suspended, versions: []version{{label: "v1"}, {label: "v2", null: true, restored: true}}},
|
||||
{name: "suspended-null-noncurrent", versioning: versioning.Suspended, versions: []version{{label: "v1", null: true, restored: true}, {label: "v2"}}},
|
||||
{name: "unversioned", versions: []version{{label: "v1", null: true, restored: true}}},
|
||||
}
|
||||
|
||||
sources := []struct {
|
||||
name string
|
||||
expire func(r *restoreTest, oi ObjectInfo, evt lifecycle.Event)
|
||||
}{
|
||||
{"scanner", func(r *restoreTest, _ ObjectInfo, _ lifecycle.Event) {
|
||||
lc, err := globalLifecycleSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
lr, err := globalBucketObjectLockSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
item := scannerItem{bucket: r.bucket, objectName: r.object, lifeCycle: lc}
|
||||
item.applyActions(r.t.Context(), r.z, r.objInfos(), lr, &sizeSummary{}, nil)
|
||||
r.runQueue()
|
||||
}},
|
||||
{"head", func(r *restoreTest, oi ObjectInfo, evt lifecycle.Event) {
|
||||
applyExpiryRule(evt, lcEventSrc_s3HeadObject, oi)
|
||||
r.runQueue()
|
||||
}},
|
||||
{"list", func(r *restoreTest, _ ObjectInfo, _ lifecycle.Event) {
|
||||
// ListObjects of the key with max-keys=1 reads its latest
|
||||
// version without the versioning of the bucket, a listing of
|
||||
// versions reads them all.
|
||||
if _, err := r.z.ListObjects(r.t.Context(), r.bucket, r.object, "", "", 1); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
if _, err := r.z.ListObjectVersions(r.t.Context(), r.bucket, r.object, "", "", "", 100); err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
r.runQueue()
|
||||
}},
|
||||
{"decom", func(r *restoreTest, oi ObjectInfo, evt lifecycle.Event) {
|
||||
for _, o := range r.objInfos() {
|
||||
if o.VersionID == oi.VersionID {
|
||||
applyExpiryOnTransitionedObject(r.t.Context(), r.z, o, evt, lcEventSrc_Decom)
|
||||
return
|
||||
}
|
||||
}
|
||||
r.t.Fatal("version not found on the drive")
|
||||
}},
|
||||
}
|
||||
|
||||
for _, pools := range []int{1, 2} {
|
||||
t.Run(fmt.Sprintf("pools=%d", pools), func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
dirs, err := getRandomDisks(4 * pools)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { removeRoots(dirs) })
|
||||
var endpoints EndpointServerPools
|
||||
for i := range pools {
|
||||
endpoints = append(endpoints, mustGetPoolEndpoints(i, dirs[4*i:4*i+4]...)...)
|
||||
}
|
||||
obj, _, err := initObjectLayer(ctx, endpoints)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()) })
|
||||
// After initObjectLayer, which sets up a new tier config.
|
||||
tier := &restoreTestTier{tierExpiryTestBackend: &tierExpiryTestBackend{objects: map[string][]byte{}}}
|
||||
globalTierConfigMgr = NewTierConfigMgr()
|
||||
globalTierConfigMgr.drivercache["WARM"] = tier
|
||||
|
||||
for _, tc := range testCases {
|
||||
for _, src := range sources {
|
||||
t.Run(tc.name+"/"+src.name, func(t *testing.T) {
|
||||
r := &restoreTest{
|
||||
t: t, z: z,
|
||||
bucket: "restore-" + tc.name + "-" + src.name, object: "obj",
|
||||
queue: make(chan expiryOp, 100),
|
||||
labels: map[string]string{}, bodies: map[string][]byte{},
|
||||
}
|
||||
if err := z.MakeBucket(ctx, r.bucket, MakeBucketOptions{VersioningEnabled: tc.versioning != ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := globalBucketMetadataSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.versioning == versioning.Suspended {
|
||||
meta.VersioningConfigXML = []byte("<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>")
|
||||
}
|
||||
meta.LifecycleConfigXML = []byte(rules)
|
||||
if err := meta.Save(ctx, z); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(r.bucket, meta)
|
||||
es := &expiryState{ctx: ctx, objAPI: z}
|
||||
workers := []chan expiryOp{r.queue}
|
||||
es.workers.Store(&workers)
|
||||
globalExpiryState = es
|
||||
|
||||
var restored ObjectInfo
|
||||
for _, v := range tc.versions {
|
||||
oi := r.put(v.label, v.null, v.restored)
|
||||
if v.restored {
|
||||
r.restore(oi)
|
||||
restored = oi
|
||||
}
|
||||
}
|
||||
rlabel := r.labels[restoreTestVersionKey(restored.VersionID)]
|
||||
|
||||
// The fixture: the restored copy is on the drives,
|
||||
// its restore has expired, ILM expires it.
|
||||
fi := r.fileInfo(restored.VersionID)
|
||||
if fi.TransitionStatus != lifecycle.TransitionComplete || fi.DataDir == "" || r.dataDirs(fi.DataDir) == 0 {
|
||||
t.Fatalf("fixture: %s transition status %q, restored copy on %d drives", rlabel, fi.TransitionStatus, r.dataDirs(fi.DataDir))
|
||||
}
|
||||
oi, err := z.GetObjectInfo(ctx, r.bucket, r.object, r.opts(restored.VersionID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lc, err := globalLifecycleSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lr, err := globalBucketObjectLockSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evt := evalActionFromLifecycle(ctx, *lc, lr, nil, oi)
|
||||
if !evt.Action.DeleteRestored() || oi.RestoreExpires.IsZero() {
|
||||
t.Fatalf("fixture: %s restore expires %v, action %v", rlabel, oi.RestoreExpires, evt.Action)
|
||||
}
|
||||
before, _ := r.versions()
|
||||
for key, label := range r.labels {
|
||||
if _, data, err := r.read(key); err != nil || !bytes.Equal(data, r.bodies[label]) {
|
||||
t.Fatalf("fixture: GET %s: %v", label, err)
|
||||
}
|
||||
}
|
||||
|
||||
src.expire(r, oi, evt)
|
||||
|
||||
after, free := r.versions()
|
||||
if fmt.Sprint(after) != fmt.Sprint(before) || free != 0 {
|
||||
t.Errorf("versions in each pool: before %q, after %q with %d free versions", before, after, free)
|
||||
}
|
||||
if label, _, err := r.read(""); err != nil || label != tc.versions[len(tc.versions)-1].label {
|
||||
t.Errorf("GET reads %q, %v; want %q", label, err, tc.versions[len(tc.versions)-1].label)
|
||||
}
|
||||
if n := r.dataDirs(fi.DataDir); n != 0 {
|
||||
t.Errorf("restored copy of %s still on %d drives", rlabel, n)
|
||||
}
|
||||
oi, err = z.GetObjectInfo(ctx, r.bucket, r.object, r.opts(restored.VersionID))
|
||||
if err != nil {
|
||||
t.Fatalf("HEAD %s: %v", rlabel, err)
|
||||
}
|
||||
if oi.TransitionedObject.Status != lifecycle.TransitionComplete || oi.UserDefined[xhttp.AmzRestore] != "" || !oi.RestoreExpires.IsZero() {
|
||||
t.Errorf("HEAD %s: transition status %q, x-amz-restore %q", rlabel, oi.TransitionedObject.Status, oi.UserDefined[xhttp.AmzRestore])
|
||||
}
|
||||
for _, v := range tc.versions {
|
||||
for key, label := range r.labels {
|
||||
if label != v.label {
|
||||
continue
|
||||
}
|
||||
gets := tier.gets.Load()
|
||||
_, data, err := r.read(key)
|
||||
switch {
|
||||
case err != nil:
|
||||
t.Errorf("GET %s: %v", v.label, err)
|
||||
case !bytes.Equal(data, r.bodies[v.label]):
|
||||
t.Errorf("GET %s reads %d bytes that differ", v.label, len(data))
|
||||
case v.restored && tier.gets.Load() == gets:
|
||||
t.Errorf("GET %s does not read the tier", v.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+108
-37
@@ -37,7 +37,8 @@ import (
|
||||
"github.com/minio/minio/internal/amztime"
|
||||
sse "github.com/minio/minio/internal/bucket/encryption"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/bucket/object/lock"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
"github.com/minio/minio/internal/s3select"
|
||||
@@ -161,7 +162,7 @@ func (n noncurrentVersionsTask) OpHash() uint64 {
|
||||
}
|
||||
|
||||
func (j jentry) OpHash() uint64 {
|
||||
return xxh3.HashString(j.TierName + j.ObjName)
|
||||
return xxh3.HashString(j.Bucket + j.Object + j.TierName + j.RemoteObject + j.RemoteVersionID)
|
||||
}
|
||||
|
||||
func (e expiryTask) OpHash() uint64 {
|
||||
@@ -353,7 +354,16 @@ func (es *expiryState) Worker(input <-chan expiryOp) {
|
||||
case noncurrentVersionsTask:
|
||||
deleteObjectVersions(es.ctx, es.objAPI, v.bucket, v.versions, v.events)
|
||||
case jentry:
|
||||
transitionLogIf(es.ctx, deleteObjectFromRemoteTier(es.ctx, v.ObjName, v.VersionID, v.TierName))
|
||||
z, ok := es.objAPI.(*erasureServerPools)
|
||||
if !ok {
|
||||
transitionLogIf(es.ctx, errTierOwnershipUnavailable)
|
||||
continue
|
||||
}
|
||||
transitionLogIf(es.ctx, z.deleteTierTuple(es.ctx, v.Bucket, v.Object, tierRemoteTuple{
|
||||
Tier: v.TierName,
|
||||
Object: v.RemoteObject,
|
||||
VersionID: v.RemoteVersionID,
|
||||
}))
|
||||
case freeVersionTask:
|
||||
oi := v.ObjectInfo
|
||||
traceFn := globalLifecycleSys.trace(oi)
|
||||
@@ -369,19 +379,26 @@ func (es *expiryState) Worker(input <-chan expiryOp) {
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Remove the remote object
|
||||
err := deleteObjectFromRemoteTier(es.ctx, oi.TransitionedObject.Name, oi.TransitionedObject.VersionID, oi.TransitionedObject.Tier)
|
||||
z, ok := es.objAPI.(*erasureServerPools)
|
||||
if !ok {
|
||||
transitionLogIf(es.ctx, errTierOwnershipUnavailable)
|
||||
traceFn(ILMFreeVersionDelete, nil, errTierOwnershipUnavailable)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check ownership, remove the remote object, and remove only the
|
||||
// matching free record while the object lock is held.
|
||||
err := z.deleteTierTuple(es.ctx, oi.Bucket, oi.Name, tierRemoteTuple{
|
||||
Tier: oi.TransitionedObject.Tier,
|
||||
Object: oi.TransitionedObject.Name,
|
||||
VersionID: oi.TransitionedObject.VersionID,
|
||||
})
|
||||
if ignoreNotFoundErr(err) != nil {
|
||||
transitionLogIf(es.ctx, err)
|
||||
traceFn(ILMFreeVersionDelete, nil, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove this free version
|
||||
_, err = es.objAPI.DeleteObject(es.ctx, oi.Bucket, oi.Name, ObjectOptions{
|
||||
VersionID: oi.VersionID,
|
||||
InclFreeVersions: true,
|
||||
})
|
||||
if err == nil {
|
||||
auditLogLifecycle(es.ctx, oi, ILMFreeVersionDelete, nil, traceFn)
|
||||
}
|
||||
@@ -610,8 +627,9 @@ func enqueueTransitionImmediate(obj ObjectInfo, src lcEventSrc) {
|
||||
func expireTransitionedObject(ctx context.Context, objectAPI ObjectLayer, oi *ObjectInfo, lcEvent lifecycle.Event, src lcEventSrc) error {
|
||||
traceFn := globalLifecycleSys.trace(*oi)
|
||||
opts := ObjectOptions{
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(oi.Bucket, oi.Name),
|
||||
Expiration: ExpirationOptions{Expire: true},
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(oi.Bucket, oi.Name),
|
||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(oi.Bucket, oi.Name),
|
||||
Expiration: ExpirationOptions{Expire: true},
|
||||
}
|
||||
if lcEvent.Action.DeleteVersioned() {
|
||||
opts.VersionID = oi.VersionID
|
||||
@@ -621,6 +639,17 @@ func expireTransitionedObject(ctx context.Context, objectAPI ObjectLayer, oi *Ob
|
||||
// delete locally restored copy of object or object version
|
||||
// from the source, while leaving metadata behind. The data on
|
||||
// transitioned tier lies untouched and still accessible
|
||||
//
|
||||
// The restored copy belongs to this version, also when it is the
|
||||
// latest one (DeleteRestoredAction). Without its version ID the
|
||||
// delete applies to the object: a versioned bucket gets a delete
|
||||
// marker that hides it, a suspended one strips the null version.
|
||||
// An empty version ID is the null version, as callers that read
|
||||
// it without the versioning of the bucket leave it.
|
||||
opts.VersionID = oi.VersionID
|
||||
if opts.VersionID == "" {
|
||||
opts.VersionID = nullVersionID
|
||||
}
|
||||
opts.Transition.ExpireRestored = true
|
||||
_, err := objectAPI.DeleteObject(ctx, oi.Bucket, oi.Name, opts)
|
||||
if err == nil {
|
||||
@@ -631,32 +660,54 @@ func expireTransitionedObject(ctx context.Context, objectAPI ObjectLayer, oi *Ob
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete remote object from warm-tier
|
||||
err := deleteObjectFromRemoteTier(ctx, oi.TransitionedObject.Name, oi.TransitionedObject.VersionID, oi.TransitionedObject.Tier)
|
||||
if err == nil {
|
||||
// Skip adding free version since we successfully deleted the
|
||||
// remote object
|
||||
opts.SkipFreeVersion = true
|
||||
} else {
|
||||
transitionLogIf(ctx, err)
|
||||
// Recheck the lifecycle decision and create the hot-tier tombstone before
|
||||
// touching the remote tier. If the lifecycle configuration changed after
|
||||
// the scanner queued this task, DeleteObject rejects the stale action while
|
||||
// the remote object is still protected.
|
||||
keepRemote := opts.VersionID == "" &&
|
||||
(opts.Versioned || (opts.VersionSuspended && oi.VersionID != "" && oi.VersionID != nullVersionID))
|
||||
|
||||
// Delete the hot-tier namespace first. For a transitioned version this
|
||||
// leaves a durable free-version record until the remote delete succeeds;
|
||||
// that record is the retry point if the process stops between the two
|
||||
// operations.
|
||||
dobj, err := objectAPI.DeleteObject(ctx, oi.Bucket, oi.Name, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Now, delete object from hot-tier namespace
|
||||
if _, err := objectAPI.DeleteObject(ctx, oi.Bucket, oi.Name, opts); err != nil {
|
||||
return err
|
||||
if !keepRemote {
|
||||
z, ok := objectAPI.(*erasureServerPools)
|
||||
if !ok {
|
||||
transitionLogIf(ctx, errTierOwnershipUnavailable)
|
||||
return nil // The completed local expiry owns a durable free record.
|
||||
}
|
||||
if err := z.deleteTierTuple(ctx, oi.Bucket, oi.Name, tierRemoteTuple{
|
||||
Tier: oi.TransitionedObject.Tier,
|
||||
Object: oi.TransitionedObject.Name,
|
||||
VersionID: oi.TransitionedObject.VersionID,
|
||||
}); err != nil {
|
||||
// The hot delete has already created a durable free record. Keep
|
||||
// the expiry successful while the scanner retries remote cleanup;
|
||||
// ownership or tier errors must never cause a second remote delete
|
||||
// without that record.
|
||||
transitionLogIf(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send audit for the lifecycle delete operation
|
||||
defer auditLogLifecycle(ctx, *oi, ILMExpiry, tags, traceFn)
|
||||
|
||||
eventName := event.ObjectRemovedDelete
|
||||
if oi.DeleteMarker {
|
||||
eventName = event.ObjectRemovedDeleteMarkerCreated
|
||||
}
|
||||
// Name what the delete did: a marker it created, or the version it
|
||||
// removed.
|
||||
eventName := ilmExpiryEventName(opts.VersionID, dobj)
|
||||
objInfo := ObjectInfo{
|
||||
Name: oi.Name,
|
||||
VersionID: oi.VersionID,
|
||||
DeleteMarker: oi.DeleteMarker,
|
||||
VersionID: dobj.VersionID,
|
||||
DeleteMarker: dobj.DeleteMarker,
|
||||
}
|
||||
if objInfo.VersionID == "" {
|
||||
objInfo.VersionID = opts.VersionID
|
||||
}
|
||||
// Notify object deleted event.
|
||||
sendEvent(eventArgs{
|
||||
@@ -931,8 +982,10 @@ func postRestoreOpts(ctx context.Context, r *http.Request, bucket, object string
|
||||
}, nil
|
||||
}
|
||||
|
||||
// set ObjectOptions for PUT call to restore temporary copy of transitioned data
|
||||
func putRestoreOpts(bucket, object string, rreq *RestoreObjectRequest, objInfo ObjectInfo) (putOpts ObjectOptions) {
|
||||
// set ObjectOptions for PUT call to restore temporary copy of transitioned data.
|
||||
// opts are the restore's options, with the versioning POST captured: the
|
||||
// bucket's versioning may have changed since, and must not mint a new version.
|
||||
func putRestoreOpts(rreq *RestoreObjectRequest, objInfo ObjectInfo, opts ObjectOptions) (putOpts ObjectOptions) {
|
||||
meta := make(map[string]string)
|
||||
sc := rreq.OutputLocation.S3.StorageClass
|
||||
if sc == "" {
|
||||
@@ -955,8 +1008,8 @@ func putRestoreOpts(bucket, object string, rreq *RestoreObjectRequest, objInfo O
|
||||
meta[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
|
||||
}
|
||||
return ObjectOptions{
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(bucket, object),
|
||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(bucket, object),
|
||||
Versioned: opts.Versioned,
|
||||
VersionSuspended: opts.VersionSuspended,
|
||||
UserDefined: meta,
|
||||
}
|
||||
}
|
||||
@@ -964,19 +1017,37 @@ func putRestoreOpts(bucket, object string, rreq *RestoreObjectRequest, objInfo O
|
||||
if len(objInfo.UserTags) != 0 {
|
||||
meta[xhttp.AmzObjectTagging] = objInfo.UserTags
|
||||
}
|
||||
// Set restore object status
|
||||
// Set restore object status; a completed restore has no attempt.
|
||||
restoreExpiry := lifecycle.ExpectedExpiryTime(time.Now().UTC(), rreq.Days)
|
||||
meta[xhttp.AmzRestore] = completedRestoreObj(restoreExpiry).String()
|
||||
delete(meta, restoreAttemptKey)
|
||||
return ObjectOptions{
|
||||
Versioned: globalBucketVersioningSys.PrefixEnabled(bucket, object),
|
||||
VersionSuspended: globalBucketVersioningSys.PrefixSuspended(bucket, object),
|
||||
Versioned: opts.Versioned,
|
||||
VersionSuspended: opts.VersionSuspended,
|
||||
UserDefined: meta,
|
||||
VersionID: objInfo.VersionID,
|
||||
VersionID: restoreVersionID(objInfo.VersionID),
|
||||
MTime: objInfo.ModTime,
|
||||
Expires: objInfo.Expires,
|
||||
}
|
||||
}
|
||||
|
||||
// restoredCopyExpires reports whether the restored copy of oi, as read now,
|
||||
// expires by its own reason: lifecycle expires the restored copy of this
|
||||
// version, its restore deadline is valid and has passed, its transition is
|
||||
// complete and no restore is running. Another rule falling due is no reason.
|
||||
func restoredCopyExpires(ctx context.Context, lc lifecycle.Lifecycle, lr lock.Retention, rcfg *replication.Config, oi ObjectInfo) bool {
|
||||
if !evalActionFromLifecycle(ctx, lc, lr, rcfg, oi).Action.DeleteRestored() {
|
||||
return false
|
||||
}
|
||||
status, err := parseRestoreObjStatus(oi.UserDefined[xhttp.AmzRestore])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
expiry, completed := status.Expiry()
|
||||
return completed && !expiry.IsZero() && UTCNow().After(expiry) &&
|
||||
oi.TransitionedObject.Status == lifecycle.TransitionComplete && !oi.RestoreOngoing
|
||||
}
|
||||
|
||||
var errRestoreHDRMalformed = fmt.Errorf("x-amz-restore header malformed")
|
||||
|
||||
// IsRemote returns true if this object version's contents are in its remote
|
||||
|
||||
@@ -19,11 +19,17 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
objectlock "github.com/minio/minio/internal/bucket/object/lock"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
@@ -245,3 +251,180 @@ func TestValidateTransitionTier(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tierExpiryTestBackend is a remote tier held in memory.
|
||||
type tierExpiryTestBackend struct {
|
||||
mu sync.Mutex
|
||||
objects map[string][]byte
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (b *tierExpiryTestBackend) Put(ctx context.Context, object string, r io.Reader, length int64) (remoteVersionID, error) {
|
||||
return b.PutWithMeta(ctx, object, r, length, nil)
|
||||
}
|
||||
|
||||
func (b *tierExpiryTestBackend) PutWithMeta(_ context.Context, object string, r io.Reader, _ int64, _ map[string]string) (remoteVersionID, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.objects[object] = data
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (b *tierExpiryTestBackend) Get(_ context.Context, object string, _ remoteVersionID, opts WarmBackendGetOpts) (io.ReadCloser, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
data, ok := b.objects[object]
|
||||
if !ok {
|
||||
return nil, ObjectNotFound{Bucket: "tier", Object: object}
|
||||
}
|
||||
if opts.length > 0 {
|
||||
data = data[opts.startOffset : opts.startOffset+opts.length]
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(data)), nil
|
||||
}
|
||||
|
||||
func (b *tierExpiryTestBackend) Remove(_ context.Context, object string, _ remoteVersionID) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
delete(b.objects, object)
|
||||
b.removed = append(b.removed, object)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *tierExpiryTestBackend) InUse(context.Context) (bool, error) { return false, nil }
|
||||
|
||||
// The expiry of a transitioned latest version that only puts a delete marker
|
||||
// on top of it must keep its remote object, so the version stays readable as
|
||||
// a noncurrent version. The remote object goes when the version is deleted.
|
||||
func TestExpireTransitionedObjectKeepsNoncurrentData(t *testing.T) {
|
||||
z, _ := migrationTestPools(t)
|
||||
ctx := t.Context()
|
||||
tier := &tierExpiryTestBackend{objects: map[string][]byte{}}
|
||||
prevTier := globalTierConfigMgr
|
||||
globalTierConfigMgr = NewTierConfigMgr()
|
||||
globalTierConfigMgr.drivercache["WARM"] = tier
|
||||
t.Cleanup(func() { globalTierConfigMgr = prevTier })
|
||||
|
||||
const expire = `<LifecycleConfiguration><Rule><ID>exp</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter>` +
|
||||
`<Expiration><Date>2020-01-01T00:00:00Z</Date></Expiration></Rule></LifecycleConfiguration>`
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
versioning string // "", Enabled or Suspended
|
||||
nullLatest bool // the latest version is the null version
|
||||
keep bool // the latest version survives as noncurrent
|
||||
want []string // versions after the expiry, newest first
|
||||
}{
|
||||
{name: "versioned", versioning: "Enabled", keep: true, want: []string{"DM", "v2", "v1"}},
|
||||
{name: "suspended", versioning: "Suspended", keep: true, want: []string{"DM", "v2", "v1"}},
|
||||
{name: "suspended-null", versioning: "Suspended", nullLatest: true, want: []string{"DM", "v1"}},
|
||||
{name: "unversioned", nullLatest: true, want: nil},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
bucket := "tier-expiry-" + tc.name
|
||||
if err := z.MakeBucket(ctx, bucket, MakeBucketOptions{VersioningEnabled: tc.versioning != ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.versioning == "Suspended" {
|
||||
meta.VersioningConfigXML = []byte("<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>")
|
||||
}
|
||||
meta.LifecycleConfigXML = []byte(expire)
|
||||
if err := meta.Save(ctx, z); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(bucket, meta)
|
||||
|
||||
// tiered writes a version whose data is in the remote tier.
|
||||
labels := map[string]string{}
|
||||
tiered := func(label string, opts ObjectOptions) ObjectInfo {
|
||||
t.Helper()
|
||||
oi := putConsistencyObject(t, z, bucket, "obj", 0, "obj "+label, opts)
|
||||
vid := migrationTestVersion(oi)
|
||||
infos, errs := readAllFileInfo(ctx, z.serverPools[0].getHashedSet("obj").getDisks(), "", bucket, "obj", vid, false, false)
|
||||
if errs[0] != nil {
|
||||
t.Fatal(errs[0])
|
||||
}
|
||||
fi := infos[0]
|
||||
fi.TransitionStatus, fi.TransitionTier, fi.TransitionedObjName = lifecycle.TransitionComplete, "WARM", bucket+"/"+label
|
||||
if _, err := tier.Put(ctx, fi.TransitionedObjName, strings.NewReader("obj "+label), -1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := z.serverPools[0].DecomTieredObject(ctx, bucket, "obj", fi, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels[vid] = label
|
||||
return oi
|
||||
}
|
||||
var latest ObjectInfo
|
||||
if tc.versioning != "" {
|
||||
tiered("v1", ObjectOptions{Versioned: true})
|
||||
}
|
||||
if tc.nullLatest {
|
||||
latest = tiered("v2", ObjectOptions{})
|
||||
} else {
|
||||
latest = tiered("v2", ObjectOptions{Versioned: true})
|
||||
}
|
||||
|
||||
// Expire the latest version as the expiry worker does for the scanner.
|
||||
getOpts := ObjectOptions{Versioned: tc.versioning == "Enabled", VersionSuspended: tc.versioning == "Suspended"}
|
||||
oi, err := z.GetObjectInfo(ctx, bucket, "obj", getOpts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lc, err := globalLifecycleSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evt := evalActionFromLifecycle(ctx, *lc, objectlock.Retention{}, nil, oi)
|
||||
if evt.Action != lifecycle.DeleteAction || oi.TransitionedObject.Status != lifecycle.TransitionComplete {
|
||||
t.Fatalf("fixture: action %v, transition status %q", evt.Action, oi.TransitionedObject.Status)
|
||||
}
|
||||
if err := expireTransitionedObject(ctx, z, &oi, evt, lcEventSrc_Scanner); err != nil {
|
||||
t.Errorf("expiry: %v", err)
|
||||
}
|
||||
|
||||
var got []string
|
||||
for _, set := range z.serverPools[0].sets {
|
||||
err := set.listObjectsToDecommission(ctx, decomBucketInfo{Name: bucket}, func(entry metaCacheEntry) {
|
||||
fivs, err := entry.fileInfoVersions(bucket)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
for _, fi := range fivs.Versions {
|
||||
if fi.Deleted {
|
||||
got = append(got, "DM")
|
||||
} else {
|
||||
got = append(got, labels[migrationTestVersion(fi.ToObjectInfo(bucket, "obj", true))])
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Errorf("versions after expiry: got %v, want %v", got, tc.want)
|
||||
}
|
||||
remote := bucket + "/v2"
|
||||
if removed := slices.Contains(tier.removed, remote); removed == tc.keep {
|
||||
t.Errorf("remote object of the expired version removed: %v, want %v", removed, !tc.keep)
|
||||
}
|
||||
if tc.keep {
|
||||
body := migrationTestBody(t, z.GetObjectNInfo, bucket, "obj", migrationTestVersion(latest))
|
||||
if body != "obj v2" {
|
||||
t.Errorf("v2 reads %q after the expiry, want %q", body, "obj v2")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+169
-61
@@ -20,8 +20,8 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -164,76 +164,176 @@ func enforceRetentionBypassForDelete(ctx context.Context, r *http.Request, bucke
|
||||
return nil
|
||||
}
|
||||
|
||||
// enforceRetentionBypassForPut enforces whether an existing object under governance can be overwritten
|
||||
// with governance bypass headers set in the request.
|
||||
// Objects under site wide WORM cannot be overwritten.
|
||||
// For objects in "Governance" mode, overwrite is allowed if a) object retention date is past OR
|
||||
// governance bypass headers are set and user has governance bypass permissions.
|
||||
// Objects in compliance mode can be overwritten only if retention date is being extended. No mode change is permitted.
|
||||
// isRetentionActionAllowed evaluates action for a PutObjectRetention request
|
||||
// with the object-lock condition keys derived from the requested retention.
|
||||
// The keys come from the request, not from the stored version, so the
|
||||
// handler can decide s3:PutObjectRetention before reading any object state.
|
||||
func isRetentionActionAllowed(action policy.Action, bucket, object string, objRetention *objectlock.ObjectRetention, r *http.Request, cred auth.Credentials, owner bool) bool {
|
||||
if cred.AccessKey == "" {
|
||||
return false
|
||||
}
|
||||
t, err := objectlock.UTCNowNTP()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conditions := getConditionValues(r, "", cred)
|
||||
conditions["object-lock-mode"] = []string{string(objRetention.Mode)}
|
||||
conditions["object-lock-retain-until-date"] = []string{objRetention.RetainUntilDate.UTC().Format(time.RFC3339)}
|
||||
// Relative days from now, for "object-lock-remaining-retention-days".
|
||||
// Clearing retention (<Retention/>) has a zero RetainUntilDate, which must
|
||||
// evaluate as 0 remaining days rather than a large positive math.Abs delta
|
||||
// from year 0001.
|
||||
days := 0
|
||||
if !objRetention.RetainUntilDate.IsZero() && objRetention.RetainUntilDate.After(t) {
|
||||
// Time.Sub saturates after about 292 years. Count seconds instead so
|
||||
// maximum-retention policies also apply to distant RFC3339 dates.
|
||||
seconds := objRetention.RetainUntilDate.Unix() - t.Unix()
|
||||
days = int(seconds / 86400)
|
||||
if seconds%86400 != 0 || objRetention.RetainUntilDate.Nanosecond() > t.Nanosecond() {
|
||||
days++
|
||||
}
|
||||
}
|
||||
conditions["object-lock-remaining-retention-days"] = []string{strconv.Itoa(days)}
|
||||
return globalIAMSys.IsAllowed(policy.Args{
|
||||
AccountName: cred.AccessKey,
|
||||
Groups: cred.Groups,
|
||||
Action: action,
|
||||
BucketName: bucket,
|
||||
ConditionValues: conditions,
|
||||
ObjectName: object,
|
||||
IsOwner: owner,
|
||||
Claims: cred.Claims,
|
||||
})
|
||||
}
|
||||
|
||||
// enforceRetentionBypassForPut enforces the stored retention of oi against a
|
||||
// PutObjectRetention request. The caller must already hold
|
||||
// s3:PutObjectRetention; this only decides changes that depend on the stored
|
||||
// state. Expired or absent retention may be changed freely. Unexpired
|
||||
// GOVERNANCE retention may be shortened, removed or have its mode changed only
|
||||
// with the bypass header and s3:BypassGovernanceRetention. Unexpired
|
||||
// COMPLIANCE retention may only be extended.
|
||||
func enforceRetentionBypassForPut(ctx context.Context, r *http.Request, oi ObjectInfo, objRetention *objectlock.ObjectRetention, cred auth.Credentials, owner bool) error {
|
||||
byPassSet := objectlock.IsObjectLockGovernanceBypassSet(r.Header)
|
||||
ret := objectlock.GetObjectRetentionMeta(oi.UserDefined)
|
||||
if !ret.Mode.Valid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
t, err := objectlock.UTCNowNTP()
|
||||
if err != nil {
|
||||
internalLogIf(ctx, err, logger.WarningKind)
|
||||
return ObjectLocked{Bucket: oi.Bucket, Object: oi.Name, VersionID: oi.VersionID}
|
||||
}
|
||||
if ret.RetainUntilDate.Before(t) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pass in relative days from current time, to additionally
|
||||
// to verify "object-lock-remaining-retention-days" policy if any.
|
||||
days := int(math.Ceil(math.Abs(objRetention.RetainUntilDate.Sub(t).Hours()) / 24))
|
||||
|
||||
ret := objectlock.GetObjectRetentionMeta(oi.UserDefined)
|
||||
if ret.Mode.Valid() {
|
||||
// Retention has expired you may change whatever you like.
|
||||
if ret.RetainUntilDate.Before(t) {
|
||||
apiErr := isPutRetentionAllowed(oi.Bucket, oi.Name,
|
||||
days, objRetention.RetainUntilDate.Time,
|
||||
objRetention.Mode, byPassSet, r, cred,
|
||||
owner)
|
||||
if apiErr == ErrAccessDenied {
|
||||
return errAuthentication
|
||||
}
|
||||
return nil
|
||||
weakens := objRetention.Mode != ret.Mode || objRetention.RetainUntilDate.Before(ret.RetainUntilDate.Time)
|
||||
if !weakens {
|
||||
return nil
|
||||
}
|
||||
switch ret.Mode {
|
||||
case objectlock.RetGovernance:
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html#object-lock-bypass-governance
|
||||
if !objectlock.IsObjectLockGovernanceBypassSet(r.Header) {
|
||||
return ObjectLocked{Bucket: oi.Bucket, Object: oi.Name, VersionID: oi.VersionID}
|
||||
}
|
||||
|
||||
switch ret.Mode {
|
||||
case objectlock.RetGovernance:
|
||||
govPerm := isPutRetentionAllowed(oi.Bucket, oi.Name, days,
|
||||
objRetention.RetainUntilDate.Time, objRetention.Mode,
|
||||
byPassSet, r, cred, owner)
|
||||
// Governance mode retention period cannot be shortened, if x-amz-bypass-governance is not set.
|
||||
if !byPassSet {
|
||||
if objRetention.Mode != objectlock.RetGovernance || objRetention.RetainUntilDate.Before(ret.RetainUntilDate.Time) {
|
||||
return ObjectLocked{Bucket: oi.Bucket, Object: oi.Name, VersionID: oi.VersionID}
|
||||
}
|
||||
}
|
||||
if govPerm == ErrAccessDenied {
|
||||
return errAuthentication
|
||||
}
|
||||
return nil
|
||||
case objectlock.RetCompliance:
|
||||
// Compliance retention mode cannot be changed or shortened.
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lock-overview.html#object-lock-retention-modes
|
||||
if objRetention.Mode != objectlock.RetCompliance || objRetention.RetainUntilDate.Before(ret.RetainUntilDate.Time) {
|
||||
return ObjectLocked{Bucket: oi.Bucket, Object: oi.Name, VersionID: oi.VersionID}
|
||||
}
|
||||
apiErr := isPutRetentionAllowed(oi.Bucket, oi.Name,
|
||||
days, objRetention.RetainUntilDate.Time, objRetention.Mode,
|
||||
false, r, cred, owner)
|
||||
if apiErr == ErrAccessDenied {
|
||||
return errAuthentication
|
||||
}
|
||||
return nil
|
||||
if !isRetentionActionAllowed(policy.BypassGovernanceRetentionAction, oi.Bucket, oi.Name, objRetention, r, cred, owner) {
|
||||
return errAuthentication
|
||||
}
|
||||
return nil
|
||||
} // No pre-existing retention metadata present.
|
||||
default:
|
||||
// Compliance retention mode cannot be changed or shortened.
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lock-overview.html#object-lock-retention-modes
|
||||
return ObjectLocked{Bucket: oi.Bucket, Object: oi.Name, VersionID: oi.VersionID}
|
||||
}
|
||||
}
|
||||
|
||||
apiErr := isPutRetentionAllowed(oi.Bucket, oi.Name,
|
||||
days, objRetention.RetainUntilDate.Time,
|
||||
objRetention.Mode, byPassSet, r, cred, owner)
|
||||
if apiErr == ErrAccessDenied {
|
||||
return errAuthentication
|
||||
// checkObjectLockedForOverwrite re-checks under the object write lock whether an
|
||||
// in-place write to the null version (such as a directory object) would
|
||||
// overwrite a locked version in an Object Lock bucket.
|
||||
func checkObjectLockedForOverwrite(ctx context.Context, bucket, object string, getObjectInfoFn GetObjectInfoFn) error {
|
||||
if isMinioMetaBucketName(bucket) {
|
||||
return nil
|
||||
}
|
||||
retentionCfg, err := globalBucketObjectLockSys.Get(bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !retentionCfg.LockEnabled {
|
||||
return nil
|
||||
}
|
||||
objInfo, err := getObjectInfoFn(ctx, bucket, object, ObjectOptions{
|
||||
VersionID: nullVersionID,
|
||||
NoLock: true,
|
||||
NoAuditLog: true,
|
||||
})
|
||||
if err != nil {
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || errors.As(err, &MethodNotAllowed{}) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if objectlock.GetObjectLegalHoldMeta(objInfo.UserDefined).Status == objectlock.LegalHoldOn {
|
||||
return ObjectLocked{Bucket: bucket, Object: decodeDirObject(object), VersionID: nullVersionID}
|
||||
}
|
||||
r := objectlock.GetObjectRetentionMeta(objInfo.UserDefined)
|
||||
if r.Mode == objectlock.RetCompliance || r.Mode == objectlock.RetGovernance {
|
||||
t, err := objectlock.UTCNowNTP()
|
||||
if err != nil {
|
||||
internalLogIf(ctx, err, logger.WarningKind)
|
||||
return ObjectLocked{Bucket: bucket, Object: decodeDirObject(object), VersionID: nullVersionID}
|
||||
}
|
||||
if r.RetainUntilDate.After(t) {
|
||||
return ObjectLocked{Bucket: bucket, Object: decodeDirObject(object), VersionID: nullVersionID}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkVersionLockedForOverwrite re-checks under the object write lock
|
||||
// whether a write would replace the stored version versionID (the null
|
||||
// version if empty) while Object Lock protects it. It reads the version's own
|
||||
// metadata, whatever the bucket configuration, and a failed read is an error.
|
||||
func checkVersionLockedForOverwrite(ctx context.Context, bucket, object, versionID string, getObjectInfoFn GetObjectInfoFn, transition TransitionOptions) error {
|
||||
if isMinioMetaBucketName(bucket) {
|
||||
return nil
|
||||
}
|
||||
if versionID == "" {
|
||||
versionID = nullVersionID
|
||||
}
|
||||
objInfo, err := getObjectInfoFn(ctx, bucket, object, ObjectOptions{
|
||||
VersionID: versionID,
|
||||
Transition: transition,
|
||||
NoLock: true,
|
||||
NoAuditLog: true,
|
||||
})
|
||||
if err != nil {
|
||||
// A delete marker is not protected.
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || errors.As(err, &MethodNotAllowed{}) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if enforceRetentionForDeletion(ctx, objInfo) {
|
||||
return ObjectLocked{Bucket: bucket, Object: decodeDirObject(object), VersionID: versionID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refuseLockedMarkerOverwrite refuses a delete marker write that would replace
|
||||
// the stored version it names while Object Lock protects it. goi and gerr are
|
||||
// that version as read under the write lock. A new marker, or one replacing a
|
||||
// marker, is allowed.
|
||||
func refuseLockedMarkerOverwrite(ctx context.Context, bucket, object, versionID string, goi ObjectInfo, gerr error) error {
|
||||
if gerr != nil {
|
||||
if isErrObjectNotFound(gerr) || isErrVersionNotFound(gerr) || errors.As(gerr, &MethodNotAllowed{}) {
|
||||
return nil
|
||||
}
|
||||
return gerr
|
||||
}
|
||||
if enforceRetentionForDeletion(ctx, goi) {
|
||||
return ObjectLocked{Bucket: bucket, Object: decodeDirObject(object), VersionID: versionID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -274,16 +374,24 @@ func checkPutObjectLockAllowed(ctx context.Context, rq *http.Request, bucket, ob
|
||||
if err != nil {
|
||||
return mode, retainDate, legalHold, toAPIErrorCode(ctx, err)
|
||||
}
|
||||
if isDirObject(object) && opts.VersionID == "" {
|
||||
opts.VersionID = nullVersionID
|
||||
}
|
||||
|
||||
if opts.VersionID != "" && !replicaTrusted {
|
||||
if objInfo, err := getObjectInfoFn(ctx, bucket, object, opts); err == nil {
|
||||
objInfo, err := getObjectInfoFn(ctx, bucket, object, opts)
|
||||
if err != nil {
|
||||
if !isErrObjectNotFound(err) && !isErrVersionNotFound(err) && !errors.As(err, &MethodNotAllowed{}) {
|
||||
return mode, retainDate, legalHold, toAPIErrorCode(ctx, err)
|
||||
}
|
||||
} else {
|
||||
r := objectlock.GetObjectRetentionMeta(objInfo.UserDefined)
|
||||
t, err := objectlock.UTCNowNTP()
|
||||
if err != nil {
|
||||
internalLogIf(ctx, err, logger.WarningKind)
|
||||
return mode, retainDate, legalHold, ErrObjectLocked
|
||||
}
|
||||
if r.Mode == objectlock.RetCompliance && r.RetainUntilDate.After(t) {
|
||||
if (r.Mode == objectlock.RetCompliance || r.Mode == objectlock.RetGovernance) && r.RetainUntilDate.After(t) {
|
||||
return mode, retainDate, legalHold, ErrObjectLocked
|
||||
}
|
||||
mode = r.Mode
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// Copyright (c) 2026 Denis Babchuk
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/minio/minio/internal/hash"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// checksumTestObject builds the xl.meta checksum of an object the way
|
||||
// PutObject and CompleteMultipartUpload store it.
|
||||
func checksumTestObject(t *testing.T, alg hash.ChecksumType, full bool, parts ...[]byte) FileInfo {
|
||||
t.Helper()
|
||||
fi := FileInfo{Volume: "bucket", Name: "object"}
|
||||
if len(parts) == 1 {
|
||||
fi.Size = int64(len(parts[0]))
|
||||
fi.Checksum = hash.NewChecksumFromData(alg, parts[0]).AppendTo(nil, nil)
|
||||
return fi
|
||||
}
|
||||
var combined, all []byte
|
||||
for i, p := range parts {
|
||||
combined = append(combined, hash.NewChecksumFromData(alg, p).Raw...)
|
||||
all = append(all, p...)
|
||||
fi.Parts = append(fi.Parts, ObjectPartInfo{Number: i + 1, Size: int64(len(p)), ActualSize: int64(len(p))})
|
||||
fi.Size += int64(len(p))
|
||||
}
|
||||
typ := alg | hash.ChecksumMultipart | hash.ChecksumIncludesMultipart
|
||||
if full {
|
||||
typ |= hash.ChecksumFullObject
|
||||
}
|
||||
cs := hash.NewChecksumFromData(typ, combined)
|
||||
if typ.FullObjectRequested() {
|
||||
// Full object CRC, as merged from the parts on completion.
|
||||
whole := hash.NewChecksumFromData(alg, all)
|
||||
cs = &hash.Checksum{Type: typ, Raw: whole.Raw, Encoded: whole.Encoded}
|
||||
}
|
||||
fi.Checksum = cs.AppendTo(nil, combined)
|
||||
return fi
|
||||
}
|
||||
|
||||
// TestGetCRCMetaChecksumType checks the checksum headers that replication
|
||||
// sends for an object: a FULL_OBJECT checksum type only for multipart full
|
||||
// object CRCs, where the target needs it to create a matching upload.
|
||||
func TestGetCRCMetaChecksumType(t *testing.T) {
|
||||
p1, p2 := []byte("first part"), []byte("second part")
|
||||
enc := func(alg hash.ChecksumType, b []byte) string { return hash.NewChecksumFromData(alg, b).Encoded }
|
||||
whole := append(append([]byte{}, p1...), p2...)
|
||||
composite := func(alg hash.ChecksumType) string {
|
||||
var combined []byte
|
||||
combined = append(combined, hash.NewChecksumFromData(alg, p1).Raw...)
|
||||
combined = append(combined, hash.NewChecksumFromData(alg, p2).Raw...)
|
||||
return enc(alg, combined) + "-2"
|
||||
}
|
||||
full := map[string]string{xhttp.AmzChecksumType: xhttp.AmzChecksumTypeFullObject}
|
||||
tests := []struct {
|
||||
name string
|
||||
fi FileInfo
|
||||
alg hash.ChecksumType
|
||||
isMP bool
|
||||
value string
|
||||
extra map[string]string
|
||||
}{
|
||||
{"single-part SHA256", checksumTestObject(t, hash.ChecksumSHA256, false, p1), hash.ChecksumSHA256, false, enc(hash.ChecksumSHA256, p1), nil},
|
||||
{"single-part CRC32", checksumTestObject(t, hash.ChecksumCRC32, false, p1), hash.ChecksumCRC32, false, enc(hash.ChecksumCRC32, p1), nil},
|
||||
{"single-part CRC64NVME", checksumTestObject(t, hash.ChecksumCRC64NVME, false, p1), hash.ChecksumCRC64NVME, false, enc(hash.ChecksumCRC64NVME, p1), nil},
|
||||
{"multipart COMPOSITE CRC32C", checksumTestObject(t, hash.ChecksumCRC32C, false, p1, p2), hash.ChecksumCRC32C, true, composite(hash.ChecksumCRC32C), nil},
|
||||
{"multipart COMPOSITE SHA256", checksumTestObject(t, hash.ChecksumSHA256, false, p1, p2), hash.ChecksumSHA256, true, composite(hash.ChecksumSHA256), nil},
|
||||
{"multipart FULL_OBJECT CRC32", checksumTestObject(t, hash.ChecksumCRC32, true, p1, p2), hash.ChecksumCRC32, true, enc(hash.ChecksumCRC32, whole), full},
|
||||
{"multipart FULL_OBJECT CRC32C", checksumTestObject(t, hash.ChecksumCRC32C, true, p1, p2), hash.ChecksumCRC32C, true, enc(hash.ChecksumCRC32C, whole), full},
|
||||
{"multipart CRC64NVME", checksumTestObject(t, hash.ChecksumCRC64NVME, false, p1, p2), hash.ChecksumCRC64NVME, true, enc(hash.ChecksumCRC64NVME, whole), full},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
oi := tt.fi.ToObjectInfo("bucket", "object", true)
|
||||
want := map[string]string{tt.alg.Key(): tt.value, xhttp.AmzChecksumAlgo: tt.alg.String()}
|
||||
maps.Copy(want, tt.extra)
|
||||
got, isMP := getCRCMeta(oi, 0, nil)
|
||||
if isMP != tt.isMP {
|
||||
t.Errorf("isMP: got %t, want %t", isMP, tt.isMP)
|
||||
}
|
||||
if !maps.Equal(got, want) {
|
||||
t.Errorf("object headers: got %v, want %v", got, want)
|
||||
}
|
||||
if !tt.isMP {
|
||||
return
|
||||
}
|
||||
// Part headers carry the part checksum and never a type.
|
||||
want = map[string]string{tt.alg.Key(): enc(tt.alg, p2), xhttp.AmzChecksumAlgo: tt.alg.String()}
|
||||
if got, _ := getCRCMeta(oi, 2, nil); !maps.Equal(got, want) {
|
||||
t.Errorf("part 2 headers: got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplicateObjectChecksumTypes replicates objects with checksums through
|
||||
// the S3 API of a test server, and checks that the replica gets the same
|
||||
// checksum as the source.
|
||||
func TestReplicateObjectChecksumTypes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// Leave no object layer behind: unit tests that run later in the package
|
||||
// rely on newObjectLayerFn() returning nil.
|
||||
defer resetTestGlobals()
|
||||
ts := StartTestServer(t, ErasureSDStr)
|
||||
defer ts.Stop()
|
||||
|
||||
clnt, err := minio.New(strings.TrimPrefix(ts.Server.URL, "http://"), &minio.Options{
|
||||
Creds: credentials.NewStaticV4(ts.AccessKey, ts.SecretKey, ""),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core := &minio.Core{Client: clnt}
|
||||
for _, bucket := range []string{"src", "dst"} {
|
||||
if err := clnt.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := clnt.EnableVersioning(ctx, bucket); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
small := []byte("single part object")
|
||||
p1 := bytes.Repeat([]byte("a"), 5<<20)
|
||||
p2 := []byte("last part")
|
||||
tests := []struct {
|
||||
name string
|
||||
alg hash.ChecksumType
|
||||
multipart bool
|
||||
full bool
|
||||
copyOf string // server-side copy of an object created above
|
||||
}{
|
||||
{"put-SHA256", hash.ChecksumSHA256, false, false, ""},
|
||||
{"put-CRC32", hash.ChecksumCRC32, false, false, ""},
|
||||
{"mp-COMPOSITE-CRC32C", hash.ChecksumCRC32C, true, false, ""},
|
||||
{"mp-COMPOSITE-SHA256", hash.ChecksumSHA256, true, false, ""},
|
||||
{"mp-FULL_OBJECT-CRC32", hash.ChecksumCRC32, true, true, ""},
|
||||
{"mp-FULL_OBJECT-CRC32C", hash.ChecksumCRC32C, true, true, ""},
|
||||
{"mp-CRC64NVME", hash.ChecksumCRC64NVME, true, false, ""},
|
||||
// The copy keeps the multipart FULL_OBJECT checksum of its source
|
||||
// but is not multipart, so it replicates with a single PUT.
|
||||
{"copy-FULL_OBJECT-CRC32", hash.ChecksumCRC32, false, true, "mp-FULL_OBJECT-CRC32"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
object := tt.name
|
||||
// Upload the source object like an S3 client does.
|
||||
switch {
|
||||
case tt.copyOf != "":
|
||||
_, err := core.CopyObject(ctx, "src", tt.copyOf, "src", object, nil, minio.CopySrcOptions{}, minio.PutObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case !tt.multipart:
|
||||
_, err := core.PutObject(ctx, "src", object, bytes.NewReader(small), int64(len(small)), "", "", minio.PutObjectOptions{
|
||||
UserMetadata: map[string]string{tt.alg.Key(): hash.NewChecksumFromData(tt.alg, small).Encoded},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
default:
|
||||
meta := map[string]string{xhttp.AmzChecksumAlgo: tt.alg.String()}
|
||||
if tt.full {
|
||||
meta[xhttp.AmzChecksumType] = xhttp.AmzChecksumTypeFullObject
|
||||
}
|
||||
uploadID, err := core.NewMultipartUpload(ctx, "src", object, minio.PutObjectOptions{UserMetadata: meta})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parts []minio.CompletePart
|
||||
for i, p := range [][]byte{p1, p2} {
|
||||
h := http.Header{}
|
||||
h.Set(tt.alg.Key(), hash.NewChecksumFromData(tt.alg, p).Encoded)
|
||||
pi, err := core.PutObjectPart(ctx, "src", object, uploadID, i+1, bytes.NewReader(p), int64(len(p)), minio.PutObjectPartOptions{CustomHeader: h})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts = append(parts, minio.CompletePart{
|
||||
PartNumber: pi.PartNumber, ETag: pi.ETag,
|
||||
ChecksumCRC32: pi.ChecksumCRC32, ChecksumCRC32C: pi.ChecksumCRC32C,
|
||||
ChecksumSHA1: pi.ChecksumSHA1, ChecksumSHA256: pi.ChecksumSHA256,
|
||||
ChecksumCRC64NVME: pi.ChecksumCRC64NVME,
|
||||
})
|
||||
}
|
||||
if _, err := core.CompleteMultipartUpload(ctx, "src", object, uploadID, parts, minio.PutObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Replicate it the way replicateObject does.
|
||||
gr, err := ts.Obj.GetObjectNInfo(ctx, "src", object, nil, http.Header{}, ObjectOptions{Versioned: true, ReplicationRequest: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gr.Close()
|
||||
objInfo := gr.ObjInfo
|
||||
putOpts, isMP, err := putReplicationOpts(ctx, "", objInfo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isMP != tt.multipart {
|
||||
t.Fatalf("multipart replication: got %t, want %t", isMP, tt.multipart)
|
||||
}
|
||||
if isMP {
|
||||
err = replicateObjectWithMultipart(ctx, core, "dst", object, gr, objInfo, putOpts)
|
||||
} else {
|
||||
size, serr := objInfo.GetActualSize()
|
||||
if serr != nil {
|
||||
t.Fatal(serr)
|
||||
}
|
||||
_, err = core.PutObject(ctx, "dst", object, gr, size, "", "", putOpts)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("replication failed: %v", err)
|
||||
}
|
||||
|
||||
src, err := clnt.StatObject(ctx, "src", object, minio.StatObjectOptions{Checksum: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst, err := clnt.StatObject(ctx, "dst", object, minio.StatObjectOptions{Checksum: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checksums := func(oi minio.ObjectInfo) string {
|
||||
return strings.Join([]string{oi.ChecksumMode, oi.ChecksumCRC32, oi.ChecksumCRC32C, oi.ChecksumSHA1, oi.ChecksumSHA256, oi.ChecksumCRC64NVME}, "|")
|
||||
}
|
||||
if checksums(src) == "|||||" || checksums(dst) != checksums(src) {
|
||||
t.Errorf("replica checksum %q, source %q", checksums(dst), checksums(src))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,10 @@ func (a *ActiveWorkerStat) update() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.Curr = globalReplicationPool.Get().ActiveWorkers()
|
||||
a.Curr = 0
|
||||
if pool := globalReplicationPool.Get(); pool != nil {
|
||||
a.Curr = pool.ActiveWorkers()
|
||||
}
|
||||
a.hist.Update(int64(a.Curr))
|
||||
a.Avg = float32(a.hist.Mean())
|
||||
a.Max = int(a.hist.Max())
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/once"
|
||||
"github.com/rcrowley/go-metrics"
|
||||
)
|
||||
|
||||
func TestReplicationWorkerMetricsWithoutPool(t *testing.T) {
|
||||
previous := globalReplicationPool
|
||||
t.Cleanup(func() { globalReplicationPool = previous })
|
||||
|
||||
globalReplicationPool = once.NewSingleton[ReplicationPool]()
|
||||
globalReplicationPool.Set(&ReplicationPool{activeWorkers: 8})
|
||||
stat := newActiveWorkerStat(metrics.NewRegistry())
|
||||
stat.update()
|
||||
if got := stat.get(); got.Curr != 8 || got.Avg != 8 || got.Max != 8 {
|
||||
t.Fatalf("active pool metrics = %+v, want current/average/maximum 8", got)
|
||||
}
|
||||
|
||||
// A collector may still run after replication has been disabled.
|
||||
globalReplicationPool = once.NewSingleton[ReplicationPool]()
|
||||
globalReplicationPool.Set(nil)
|
||||
stat.update()
|
||||
if got := stat.get(); got.Curr != 0 || got.Avg != 4 || got.Max != 8 {
|
||||
t.Fatalf("disabled pool metrics = %+v, want current 0, average 4, maximum 8", got)
|
||||
}
|
||||
}
|
||||
@@ -4250,6 +4250,11 @@ func getCRCMeta(oi ObjectInfo, partNum int, h http.Header) (cs map[string]string
|
||||
if cktype.IsSet() {
|
||||
meta[cktype.Key()] = v
|
||||
meta[xhttp.AmzChecksumAlgo] = cktype.String()
|
||||
// Without the type the target creates a COMPOSITE multipart
|
||||
// upload and rejects the full object CRC on completion.
|
||||
if isMP && cktype.CanMerge() && cs[xhttp.AmzChecksumType] == xhttp.AmzChecksumTypeFullObject {
|
||||
meta[xhttp.AmzChecksumType] = xhttp.AmzChecksumTypeFullObject
|
||||
}
|
||||
}
|
||||
}
|
||||
return meta, isMP
|
||||
|
||||
@@ -56,6 +56,7 @@ import (
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/color"
|
||||
"github.com/minio/minio/internal/config"
|
||||
"github.com/minio/minio/internal/config/api"
|
||||
"github.com/minio/minio/internal/handlers"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
@@ -881,6 +882,9 @@ func serverHandleEnvVars() {
|
||||
logger.Warning("%s=%s is ignored: Silo in-place updates are permanently disabled", config.EnvUpdate, updateSetting)
|
||||
}
|
||||
globalInplaceUpdateDisabled = true
|
||||
if env.IsSet(api.EnvAPIMultipartListing) {
|
||||
logger.Warning("%s is ignored: multipart listing uses the legacy cache/exact-key behavior; persistent listing is deferred", api.EnvAPIMultipartListing)
|
||||
}
|
||||
|
||||
// Check if the supported credential env vars,
|
||||
// "MINIO_ROOT_USER" and "MINIO_ROOT_PASSWORD" are provided
|
||||
|
||||
@@ -609,7 +609,8 @@ func applyDynamicConfigForSubSys(ctx context.Context, objAPI ObjectLayer, s conf
|
||||
}
|
||||
loggerCfg.HTTP[n] = l
|
||||
}
|
||||
if errs := logger.UpdateHTTPWebhooks(ctx, loggerCfg.HTTP); len(errs) > 0 {
|
||||
// Webhook workers outlive the bounded configuration reload.
|
||||
if errs := logger.UpdateHTTPWebhooks(GlobalContext, loggerCfg.HTTP); len(errs) > 0 {
|
||||
configLogIf(ctx, fmt.Errorf("Unable to update logger webhook config: %v", errs))
|
||||
}
|
||||
case config.AuditWebhookSubSys:
|
||||
@@ -627,7 +628,8 @@ func applyDynamicConfigForSubSys(ctx context.Context, objAPI ObjectLayer, s conf
|
||||
loggerCfg.AuditWebhook[n] = l
|
||||
}
|
||||
|
||||
if errs := logger.UpdateAuditWebhooks(ctx, loggerCfg.AuditWebhook); len(errs) > 0 {
|
||||
// Both the queue worker and disk replay follow the server lifetime.
|
||||
if errs := logger.UpdateAuditWebhooks(GlobalContext, loggerCfg.AuditWebhook); len(errs) > 0 {
|
||||
configLogIf(ctx, fmt.Errorf("Unable to update audit webhook targets: %v", errs))
|
||||
}
|
||||
case config.AuditKafkaSubSys:
|
||||
@@ -644,7 +646,8 @@ func applyDynamicConfigForSubSys(ctx context.Context, objAPI ObjectLayer, s conf
|
||||
loggerCfg.AuditKafka[n] = l
|
||||
}
|
||||
}
|
||||
if errs := logger.UpdateAuditKafkaTargets(ctx, loggerCfg); len(errs) > 0 {
|
||||
// The Kafka disk replay follows the server lifetime as well.
|
||||
if errs := logger.UpdateAuditKafkaTargets(GlobalContext, loggerCfg); len(errs) > 0 {
|
||||
configLogIf(ctx, fmt.Errorf("Unable to update audit kafka targets: %v", errs))
|
||||
}
|
||||
case config.StorageClassSubSys:
|
||||
|
||||
@@ -19,12 +19,246 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/sarama"
|
||||
"github.com/minio/minio/internal/config"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
httpTarget "github.com/minio/minio/internal/logger/target/http"
|
||||
types "github.com/minio/minio/internal/logger/target/loggertypes"
|
||||
)
|
||||
|
||||
type webhookConfigObjectLayer struct {
|
||||
ObjectLayer
|
||||
}
|
||||
|
||||
func (webhookConfigObjectLayer) SetDriveCounts() []int { return []int{1} }
|
||||
|
||||
// TestDynamicWebhookOutlivesConfigContext applies logger targets the way a
|
||||
// peer reload does: under a bounded context that ends when the handler returns.
|
||||
func TestDynamicWebhookOutlivesConfigContext(t *testing.T) {
|
||||
for _, subSys := range []string{config.AuditWebhookSubSys, config.LoggerWebhookSubSys, config.AuditKafkaSubSys} {
|
||||
for _, queue := range []struct {
|
||||
name string
|
||||
disk bool
|
||||
failInit bool
|
||||
}{
|
||||
{name: "disk=false"},
|
||||
{name: "disk=true", disk: true},
|
||||
{name: "disk=unavailable", disk: true, failInit: true},
|
||||
} {
|
||||
if subSys == config.AuditKafkaSubSys && queue.failInit {
|
||||
// An unopenable Kafka queue starts no goroutine.
|
||||
continue
|
||||
}
|
||||
t.Run(subSys+"/"+queue.name, func(t *testing.T) {
|
||||
// Isolate global target registries and wait for all workers to exit
|
||||
// before the parent removes the disk queue.
|
||||
if os.Getenv("_SILO_WEBHOOK_CONTEXT_TEST") != t.Name() {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queueDir := ""
|
||||
if queue.disk {
|
||||
queueDir = t.TempDir()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
child := exec.CommandContext(ctx, executable,
|
||||
"-test.run=^"+strings.ReplaceAll(t.Name(), "/", "$/^")+"$",
|
||||
"-test.v", "-test.timeout=25s")
|
||||
// Logger targets from the environment would join the registries.
|
||||
for _, kv := range os.Environ() {
|
||||
if !strings.HasPrefix(kv, "MINIO_AUDIT_") && !strings.HasPrefix(kv, "MINIO_LOGGER_") {
|
||||
child.Env = append(child.Env, kv)
|
||||
}
|
||||
}
|
||||
child.Env = append(child.Env,
|
||||
"_SILO_WEBHOOK_CONTEXT_TEST="+t.Name(),
|
||||
"_SILO_WEBHOOK_QUEUE_DIR="+queueDir)
|
||||
output, err := child.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("isolated webhook test: %v\n%s", err, output)
|
||||
}
|
||||
t.Logf("%s", output)
|
||||
return
|
||||
}
|
||||
|
||||
if subSys == config.AuditKafkaSubSys {
|
||||
testDynamicKafkaOutlivesConfigContext(t, os.Getenv("_SILO_WEBHOOK_QUEUE_DIR"))
|
||||
return
|
||||
}
|
||||
|
||||
targets := logger.AuditTargets
|
||||
if subSys == config.LoggerWebhookSubSys {
|
||||
targets = logger.SystemTargets
|
||||
}
|
||||
received := make(chan string, 8)
|
||||
receiver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var entry struct{ Marker string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&entry); err != nil {
|
||||
t.Errorf("decode webhook: %v", err)
|
||||
}
|
||||
received <- entry.Marker
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(receiver.Close)
|
||||
|
||||
queueDir := os.Getenv("_SILO_WEBHOOK_QUEUE_DIR")
|
||||
if queue.failInit {
|
||||
queueDir = filepath.Join(queueDir, "queue")
|
||||
if err := os.WriteFile(queueDir, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cfg := newServerConfig()
|
||||
_, err := cfg.SetKVS(fmt.Sprintf("%s:context-test enable=on endpoint=%s queue_size=16 batch_size=1 queue_dir=%s http_timeout=1s retry_interval=100ms",
|
||||
subSys, receiver.URL, queueDir), config.DefaultKVS)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A reload cancels the target it replaces; clean up only the last one.
|
||||
var current *httpTarget.Target
|
||||
t.Cleanup(func() {
|
||||
if current != nil {
|
||||
current.Cancel()
|
||||
}
|
||||
})
|
||||
// reload applies the subsystem the way a peer does and returns the
|
||||
// registered target once the bounded reload context has ended.
|
||||
reload := func() *httpTarget.Target {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := applyDynamicConfigForSubSys(ctx, webhookConfigObjectLayer{}, cfg, subSys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var target *httpTarget.Target
|
||||
for _, candidate := range targets() {
|
||||
if candidate.Endpoint() == receiver.URL {
|
||||
if target != nil {
|
||||
t.Fatal("configured webhook was registered twice")
|
||||
}
|
||||
target = candidate.(*httpTarget.Target)
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
t.Fatal("configured webhook was not registered")
|
||||
}
|
||||
current = target
|
||||
return target
|
||||
}
|
||||
|
||||
target := reload()
|
||||
if err := target.Send(t.Context(), struct{ Marker string }{"after-reload"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queue.failInit {
|
||||
// A failed initialization starts no worker: the record stays queued.
|
||||
select {
|
||||
case got := <-received:
|
||||
t.Fatalf("received %q before the queue was initialized", got)
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
}
|
||||
if got := target.Stats().QueueLength; got != 1 {
|
||||
t.Fatalf("failed queue consumed an event: queue length %d", got)
|
||||
}
|
||||
// After the operator repairs queue_dir, applying the configuration
|
||||
// again replaces the target, which must take over the record.
|
||||
if err := os.Remove(queueDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target = reload()
|
||||
}
|
||||
select {
|
||||
case got := <-received:
|
||||
if got != "after-reload" {
|
||||
t.Fatalf("received marker %q, want after-reload", got)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("webhook stopped after reload returned; stats: %+v", target.Stats())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testDynamicKafkaOutlivesConfigContext runs in the isolated child process.
|
||||
// With a queue_dir, Kafka records reach the broker only through the disk
|
||||
// replay, which polls the queue every few seconds.
|
||||
func testDynamicKafkaOutlivesConfigContext(t *testing.T, queueDir string) {
|
||||
const topic = "audit-context-test"
|
||||
broker := sarama.NewMockBroker(t, 1)
|
||||
t.Cleanup(broker.Close)
|
||||
broker.SetHandlerByMap(map[string]sarama.MockResponse{
|
||||
"ApiVersionsRequest": sarama.NewMockApiVersionsResponse(t).SetApiKeys([]sarama.ApiVersionsResponseKey{
|
||||
{ApiKey: 0, MinVersion: 0, MaxVersion: 8}, // Produce
|
||||
{ApiKey: 3, MinVersion: 0, MaxVersion: 11}, // Metadata
|
||||
{ApiKey: 18, MinVersion: 0, MaxVersion: 3}, // ApiVersions
|
||||
}),
|
||||
"MetadataRequest": sarama.NewMockMetadataResponse(t).
|
||||
SetBroker(broker.Addr(), broker.BrokerID()).
|
||||
SetLeader(topic, 0, broker.BrokerID()),
|
||||
"ProduceRequest": sarama.NewMockProduceResponse(t),
|
||||
})
|
||||
produced := func() (n int) {
|
||||
for _, exchange := range broker.History() {
|
||||
if _, ok := exchange.Request.(*sarama.ProduceRequest); ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
cfg := newServerConfig()
|
||||
_, err := cfg.SetKVS(fmt.Sprintf("%s:context-test enable=on brokers=%s topic=%s queue_size=16 queue_dir=%s",
|
||||
config.AuditKafkaSubSys, broker.Addr(), topic, queueDir), config.DefaultKVS)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := applyDynamicConfigForSubSys(ctx, webhookConfigObjectLayer{}, cfg, config.AuditKafkaSubSys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var target logger.Target
|
||||
for _, candidate := range logger.AuditTargets() {
|
||||
if candidate.Type() == types.TargetKafka {
|
||||
if target != nil {
|
||||
t.Fatal("more than one Kafka target was registered")
|
||||
}
|
||||
target = candidate
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
t.Fatal("configured Kafka target was not registered")
|
||||
}
|
||||
t.Cleanup(target.Cancel)
|
||||
// Returning from the peer reload cancels its bounded context.
|
||||
cancel()
|
||||
|
||||
before := produced()
|
||||
if err := target.Send(t.Context(), struct{ Marker string }{"after-reload"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for deadline := time.Now().Add(10 * time.Second); produced() == before; time.Sleep(50 * time.Millisecond) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("Kafka target stopped after reload returned; stats: %+v", target.Stats())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfig(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
@@ -91,7 +91,7 @@ func readConfigWithoutMigrate(ctx context.Context, objAPI ObjectLayer) (config.C
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err = decryptData(data, configFile)
|
||||
data, err = decryptData(ctx, data, configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+3
-3
@@ -68,7 +68,7 @@ func listServerConfigHistory(ctx context.Context, objAPI ObjectLayer, withData b
|
||||
continue
|
||||
}
|
||||
|
||||
data, err = decryptData(data, obj.Name)
|
||||
data, err = decryptData(ctx, data, obj.Name)
|
||||
if err != nil {
|
||||
// ignore history file that cannot be loaded.
|
||||
continue
|
||||
@@ -110,7 +110,7 @@ func readServerConfigHistory(ctx context.Context, objAPI ObjectLayer, uuidKV str
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decryptData(data, historyFile)
|
||||
return decryptData(ctx, data, historyFile)
|
||||
}
|
||||
|
||||
func saveServerConfigHistory(ctx context.Context, objAPI ObjectLayer, kv []byte) error {
|
||||
@@ -162,7 +162,7 @@ func readServerConfig(ctx context.Context, objAPI ObjectLayer, data []byte) (con
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err = decryptData(data, configFile)
|
||||
data, err = decryptData(ctx, data, configFile)
|
||||
if err != nil {
|
||||
lookupConfigs(srvCfg, objAPI)
|
||||
return nil, err
|
||||
|
||||
+109
-22
@@ -24,10 +24,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"math"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -776,7 +778,7 @@ func (f *folderScanner) scanFolder(ctx context.Context, folder cachedFolder, int
|
||||
}
|
||||
|
||||
// We got an entry which we should be able to heal.
|
||||
fiv, err := entry.fileInfoVersions(bucket)
|
||||
fiv, err := entry.allFileInfoVersions(bucket)
|
||||
if err != nil {
|
||||
err := bgSeq.queueHealTask(healSource{
|
||||
bucket: bucket,
|
||||
@@ -1118,8 +1120,31 @@ eventLoop:
|
||||
size := actualSz
|
||||
switch event.Action {
|
||||
case lifecycle.DeleteAllVersionsAction, lifecycle.DelMarkerDeleteAllVersionsAction:
|
||||
remainingVersions = 0
|
||||
applyExpiryRule(event, lcEventSrc_Scanner, oi)
|
||||
// An expiry of a hot latest version purges the key, which refuses
|
||||
// a key with a version in a remote tier: do not queue it when this
|
||||
// pool holds one. A tiered version only another pool holds is
|
||||
// not known here; the purge decides on it. A transitioned latest
|
||||
// version takes the transitioned expiry, as before.
|
||||
refused := oi.TransitionedObject.Status == "" && slices.ContainsFunc(objInfos, ObjectInfo.deleteAllTierRefuses)
|
||||
if refused {
|
||||
globalILMIncomplete.add(ilmIncompleteTierSkipped)
|
||||
} else {
|
||||
remainingVersions = 0
|
||||
applyExpiryRule(event, lcEventSrc_Scanner, oi)
|
||||
}
|
||||
// The versions hold their space until the purge removes them,
|
||||
// which can refuse the key or stop at its budget: account each
|
||||
// at its size, with no other action. The alerts on many or
|
||||
// large versions count those of a key that is not queued.
|
||||
for _, v := range objInfos[idx:] {
|
||||
sz, _ := v.GetActualSize()
|
||||
if fn != nil {
|
||||
fn(v, sz, sz, sizeS)
|
||||
}
|
||||
if refused {
|
||||
cumulativeSize += sz
|
||||
}
|
||||
}
|
||||
break eventLoop
|
||||
|
||||
case lifecycle.DeleteAction, lifecycle.DeleteRestoredAction, lifecycle.DeleteRestoredVersionAction:
|
||||
@@ -1130,9 +1155,9 @@ eventLoop:
|
||||
applyExpiryRule(event, lcEventSrc_Scanner, oi)
|
||||
|
||||
case lifecycle.DeleteVersionAction: // noncurrent versions expiration
|
||||
opts := objOpts[idx]
|
||||
remainingVersions--
|
||||
size = 0
|
||||
opts := objOpts[idx]
|
||||
toDel = append(toDel, ObjectToDelete{
|
||||
ObjectV: ObjectV{
|
||||
ObjectName: opts.Name,
|
||||
@@ -1210,10 +1235,7 @@ func applyTransitionRule(event lifecycle.Event, src lcEventSrc, obj ObjectInfo)
|
||||
func applyExpiryOnTransitionedObject(ctx context.Context, objLayer ObjectLayer, obj ObjectInfo, lcEvent lifecycle.Event, src lcEventSrc) (ok bool) {
|
||||
timeILM := globalScannerMetrics.timeILM(lcEvent.Action)
|
||||
if err := expireTransitionedObject(ctx, objLayer, &obj, lcEvent, src); err != nil {
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
return false
|
||||
}
|
||||
ilmLogIf(ctx, fmt.Errorf("expireTransitionedObject(%s, %s): %w", obj.Bucket, obj.Name, err))
|
||||
reportILMExpiryNotDone(ctx, obj, lcEvent, src, err, "transition-expiry"+obj.Name, globalLifecycleSys.trace(obj))
|
||||
return false
|
||||
}
|
||||
timeILM(1)
|
||||
@@ -1235,11 +1257,18 @@ func applyExpiryOnNonTransitionedObjects(ctx context.Context, objLayer ObjectLay
|
||||
opts.Versioned = globalBucketVersioningSys.PrefixEnabled(obj.Bucket, obj.Name)
|
||||
opts.VersionSuspended = globalBucketVersioningSys.PrefixSuspended(obj.Bucket, obj.Name)
|
||||
|
||||
// Purge every version of exactly this key, by version ID and under the
|
||||
// object lock, after the pools approved it on the versions they hold;
|
||||
// see erasureServerPools.deleteAllVersions.
|
||||
var purge deleteAllResult
|
||||
if lcEvent.Action.DeleteAll() {
|
||||
opts.DeletePrefix = true
|
||||
// use prefix delete on exact object (this is an optimization to avoid fan-out calls)
|
||||
opts.DeletePrefixObject = true
|
||||
opts = ObjectOptions{
|
||||
Expiration: ExpirationOptions{Expire: true},
|
||||
DeleteAllVersions: true,
|
||||
DeleteAllResult: &purge,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
dobj ObjectInfo
|
||||
err error
|
||||
@@ -1254,22 +1283,21 @@ func applyExpiryOnNonTransitionedObjects(ctx context.Context, objLayer ObjectLay
|
||||
if lcEvent.Action != lifecycle.NoneAction {
|
||||
numVersions := uint64(1)
|
||||
if lcEvent.Action.DeleteAll() {
|
||||
numVersions = uint64(obj.NumVersions)
|
||||
numVersions = uint64(purge.Removed)
|
||||
}
|
||||
timeILM(numVersions)
|
||||
}
|
||||
}()
|
||||
|
||||
dobj, err = objLayer.DeleteObject(ctx, obj.Bucket, encodeDirObject(obj.Name), opts)
|
||||
if lcEvent.Action.DeleteAll() && (err != nil || purge.Removed == 0) {
|
||||
return deleteAllNotCompleted(ctx, obj, lcEvent, src, purge, err, traceFn)
|
||||
}
|
||||
if err != nil {
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
// Assume it is still there, unless it is gone already.
|
||||
if !reportILMExpiryNotDone(ctx, obj, lcEvent, src, err, "non-transition-expiry"+obj.Name, traceFn) {
|
||||
traceFn(ILMExpiry, nil, nil)
|
||||
return false
|
||||
}
|
||||
// Assume it is still there.
|
||||
err := fmt.Errorf("DeleteObject(%s, %s): %w", obj.Bucket, obj.Name, err)
|
||||
ilmLogOnceIf(ctx, err, "non-transition-expiry"+obj.Name)
|
||||
traceFn(ILMExpiry, nil, err)
|
||||
return false
|
||||
}
|
||||
if dobj.Name == "" {
|
||||
@@ -1278,14 +1306,14 @@ func applyExpiryOnNonTransitionedObjects(ctx context.Context, objLayer ObjectLay
|
||||
|
||||
tags := newLifecycleAuditEvent(src, lcEvent).Tags()
|
||||
tags["version-id"] = dobj.VersionID
|
||||
if lcEvent.Action.DeleteAll() {
|
||||
tags[ilmVersionsRemoved] = strconv.Itoa(purge.Removed)
|
||||
}
|
||||
|
||||
// Send audit for the lifecycle delete operation
|
||||
auditLogLifecycle(ctx, dobj, ILMExpiry, tags, traceFn)
|
||||
|
||||
eventName := event.ObjectRemovedDelete
|
||||
if obj.DeleteMarker {
|
||||
eventName = event.ObjectRemovedDeleteMarkerCreated
|
||||
}
|
||||
eventName := ilmExpiryEventName(opts.VersionID, dobj)
|
||||
switch lcEvent.Action {
|
||||
case lifecycle.DeleteAllVersionsAction:
|
||||
eventName = event.ObjectRemovedDeleteAllVersions
|
||||
@@ -1304,6 +1332,65 @@ func applyExpiryOnNonTransitionedObjects(ctx context.Context, objLayer ObjectLay
|
||||
return true
|
||||
}
|
||||
|
||||
// ilmVersionsRemoved is the audit tag with the number of versions that an
|
||||
// expiry of all versions removed; ilmPartial marks one that began to delete
|
||||
// and did not finish.
|
||||
const (
|
||||
ilmVersionsRemoved = "ilm-versions-removed"
|
||||
ilmPartial = "ilm-partial"
|
||||
)
|
||||
|
||||
// deleteAllNotCompleted reports an expiry of all versions of obj that did
|
||||
// not remove them all. A purge that had nothing to remove, or that failed
|
||||
// before it deleted anything, reports what a rejected expiry does. One that
|
||||
// failed or stopped after it began to delete is audited as partial, with the
|
||||
// versions it removed, but sends no removal event: the key is still there.
|
||||
//
|
||||
// Each is counted and traced with its reason, as reportILMExpiryNotDone
|
||||
// reports other expiries. A refusal because a version is in a remote tier is
|
||||
// also logged, since it lasts until such versions can be purged.
|
||||
func deleteAllNotCompleted(ctx context.Context, obj ObjectInfo, lcEvent lifecycle.Event, src lcEventSrc, purge deleteAllResult, err error, traceFn func(event string, metadata map[string]string, err error)) bool {
|
||||
logID := "non-transition-expiry" + obj.Name
|
||||
switch {
|
||||
case err == nil: // there was nothing to remove
|
||||
traceFn(ILMExpiry, nil, nil)
|
||||
return false
|
||||
case purge.Started:
|
||||
reason, _ := ilmExpiryNotDone(err)
|
||||
globalILMIncomplete.add(reason)
|
||||
tags := newLifecycleAuditEvent(src, lcEvent).Tags()
|
||||
tags["version-id"] = purge.Latest.VersionID
|
||||
tags[ilmVersionsRemoved] = strconv.Itoa(purge.Removed)
|
||||
tags[ilmPartial] = "true"
|
||||
tags["ilm-incomplete-reason"] = reason
|
||||
auditLogInternal(ctx, AuditLogOptions{
|
||||
Event: ILMExpiry,
|
||||
APIName: "ILMExpiry",
|
||||
Bucket: obj.Bucket,
|
||||
Object: obj.Name,
|
||||
VersionID: purge.Latest.VersionID,
|
||||
Error: err.Error(),
|
||||
Tags: tags,
|
||||
})
|
||||
trace := maps.Clone(tags)
|
||||
trace["result"], trace["reason"] = "partial", reason
|
||||
traceFn(ILMExpiry, trace, err)
|
||||
if errors.Is(err, errDeleteAllBudget) {
|
||||
return false // a long history; the next expiry continues
|
||||
}
|
||||
// Assume it is still there.
|
||||
ilmLogOnceIf(ctx, fmt.Errorf("ILM expiry of all versions of %s/%s stopped after it removed %d: %w", obj.Bucket, obj.Name, purge.Removed, err), logID)
|
||||
return false
|
||||
case errors.Is(err, errDeleteAllTiered):
|
||||
// Kept until tiered versions can be purged; see deleteAllVersions.
|
||||
ilmLogOnceIf(ctx, fmt.Errorf("ILM expiry of all versions of %s/%s refused: a version is in a remote tier, and a purge of all versions does not remove tiered versions yet", obj.Bucket, obj.Name), logID)
|
||||
}
|
||||
if !reportILMExpiryNotDone(ctx, obj, lcEvent, src, err, logID, traceFn) {
|
||||
traceFn(ILMExpiry, nil, nil) // gone already
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Apply object, object version, restored object or restored object version action on the given object
|
||||
func applyExpiryRule(event lifecycle.Event, src lcEventSrc, obj ObjectInfo) {
|
||||
globalExpiryState.enqueueByDays(obj, event, src)
|
||||
|
||||
@@ -240,8 +240,10 @@ func TestApplyNewerNoncurrentVersionsLimit(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
// Every version is accounted at its size until the purge
|
||||
// removes it.
|
||||
objInfos: allVersExpObjInfos,
|
||||
wants: nil,
|
||||
wants: allVersExpObjInfos,
|
||||
wantExpired: []ObjectToDelete{{ObjectV: ObjectV{ObjectName: obj, VersionID: allVersExpObjInfos[0].VersionID}}},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
|
||||
// Tests create endpoints with ellipses and without.
|
||||
func TestCreateServerEndpoints(t *testing.T) {
|
||||
useStaticTestEndpoints(t)
|
||||
testCases := []struct {
|
||||
serverAddr string
|
||||
args []string
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
@@ -25,9 +26,42 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/dnscache"
|
||||
)
|
||||
|
||||
type endpointTestResolver struct {
|
||||
dnscache.DNSResolver
|
||||
}
|
||||
|
||||
func (r endpointTestResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
|
||||
// These endpoint fixtures test locality and parsing, not public DNS.
|
||||
for i, name := range []string{"example.org", "example.com", "example.net", "example.edu"} {
|
||||
if host == name {
|
||||
return []string{fmt.Sprintf("203.0.113.%d", i+1)}, nil
|
||||
}
|
||||
}
|
||||
return r.DNSResolver.LookupHost(ctx, host)
|
||||
}
|
||||
|
||||
func useTestEndpointDNS(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := globalDNSCache
|
||||
globalDNSCache = &dnscache.Resolver{Resolver: endpointTestResolver{net.DefaultResolver}}
|
||||
t.Cleanup(func() { globalDNSCache = previous })
|
||||
}
|
||||
|
||||
func useStaticTestEndpoints(t *testing.T) {
|
||||
t.Helper()
|
||||
useTestEndpointDNS(t)
|
||||
// These fixtures deliberately use localhost, including inside containers.
|
||||
previous := orchestrated
|
||||
orchestrated = false
|
||||
t.Cleanup(func() { orchestrated = previous })
|
||||
}
|
||||
|
||||
func TestNewEndpoint(t *testing.T) {
|
||||
useTestEndpointDNS(t)
|
||||
u2, _ := url.Parse("https://example.org/path")
|
||||
u4, _ := url.Parse("http://192.168.253.200/path")
|
||||
rootSlashFoo, _ := filepath.Abs("/foo")
|
||||
@@ -130,6 +164,7 @@ func TestNewEndpoints(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateEndpoints(t *testing.T) {
|
||||
useStaticTestEndpoints(t)
|
||||
tempGlobalMinioPort := globalMinioPort
|
||||
defer func() {
|
||||
globalMinioPort = tempGlobalMinioPort
|
||||
@@ -353,6 +388,7 @@ func TestCreateEndpoints(t *testing.T) {
|
||||
// So it means that if you have say localhost:9000 and localhost:9001 as endpointArgs then localhost:9001
|
||||
// is considered a remote service from localhost:9000 perspective.
|
||||
func TestGetLocalPeer(t *testing.T) {
|
||||
useStaticTestEndpoints(t)
|
||||
tempGlobalMinioPort := globalMinioPort
|
||||
defer func() {
|
||||
globalMinioPort = tempGlobalMinioPort
|
||||
@@ -393,6 +429,7 @@ func TestGetLocalPeer(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetRemotePeers(t *testing.T) {
|
||||
useStaticTestEndpoints(t)
|
||||
tempGlobalMinioPort := globalMinioPort
|
||||
defer func() {
|
||||
globalMinioPort = tempGlobalMinioPort
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2026 Jasper Bernhardt
|
||||
//
|
||||
// This file is part of Silo Object Storage.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
)
|
||||
|
||||
// Unimplemented operations panic: the dry-run tests only allow reads and checks.
|
||||
type nativeAuditDisk struct {
|
||||
StorageAPI
|
||||
info FileInfo
|
||||
readErr error
|
||||
verify func() (*CheckPartsResp, error)
|
||||
deletes atomic.Int32
|
||||
}
|
||||
|
||||
func (d *nativeAuditDisk) ReadVersion(context.Context, string, string, string, string, ReadOptions) (FileInfo, error) {
|
||||
return d.info, d.readErr
|
||||
}
|
||||
|
||||
func (d *nativeAuditDisk) VerifyFile(context.Context, string, string, FileInfo) (*CheckPartsResp, error) {
|
||||
return d.verify()
|
||||
}
|
||||
|
||||
func (d *nativeAuditDisk) DeleteVersion(context.Context, string, string, FileInfo, bool, DeleteOptions) error {
|
||||
d.deletes.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func nativeAuditMetadata() []FileInfo {
|
||||
meta := make([]FileInfo, 4)
|
||||
for i := range meta {
|
||||
meta[i] = newFileInfo("object", 2, 2)
|
||||
meta[i].Volume, meta[i].Name = "bucket", "object"
|
||||
meta[i].DataDir = "data"
|
||||
meta[i].ModTime = time.Unix(123, 0).UTC()
|
||||
meta[i].Size = 1024
|
||||
meta[i].Erasure.Distribution = []int{1, 2, 3, 4}
|
||||
meta[i].Erasure.Index = i + 1
|
||||
meta[i].Parts = []ObjectPartInfo{{Number: 1, Size: 512}, {Number: 2, Size: 512}}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func nativeAuditEndpoints() []Endpoint {
|
||||
endpoints := make([]Endpoint, 4)
|
||||
for i := range endpoints {
|
||||
endpoints[i].URL = &url.URL{Path: fmt.Sprintf("/synthetic/disk%d", i)}
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
func TestNativeAuditDryRunPreservesFaults(t *testing.T) {
|
||||
for _, fault := range []string{"metadata-quorum", "corrupt-metadata", "missing-parts", "corrupt-parts", "inline-corrupt", "delete-marker-quorum"} {
|
||||
t.Run(fault, func(t *testing.T) {
|
||||
meta := nativeAuditMetadata()
|
||||
disks := make([]StorageAPI, len(meta))
|
||||
for i := range disks {
|
||||
disk := &nativeAuditDisk{info: meta[i], verify: func() (*CheckPartsResp, error) {
|
||||
return &CheckPartsResp{Results: []int{checkPartSuccess, checkPartSuccess}}, nil
|
||||
}}
|
||||
if i < 3 {
|
||||
switch fault {
|
||||
case "metadata-quorum", "delete-marker-quorum":
|
||||
disk.readErr = errFileVersionNotFound
|
||||
case "corrupt-metadata":
|
||||
disk.readErr = errFileCorrupt
|
||||
case "missing-parts":
|
||||
disk.verify = func() (*CheckPartsResp, error) { return nil, errFileNotFound }
|
||||
case "corrupt-parts":
|
||||
disk.verify = func() (*CheckPartsResp, error) { return nil, errFileCorrupt }
|
||||
}
|
||||
}
|
||||
if fault == "delete-marker-quorum" {
|
||||
disk.info.Deleted = true
|
||||
disk.info.Size = 0
|
||||
}
|
||||
if fault == "inline-corrupt" {
|
||||
disk.info.Data = []byte("corrupt")
|
||||
disk.info.Parts = disk.info.Parts[:1]
|
||||
disk.info.SetInlineData()
|
||||
disk.info.Erasure.Checksums = []ChecksumInfo{{PartNumber: 1, Algorithm: SHA256, Hash: make([]byte, sha256.Size)}}
|
||||
}
|
||||
disks[i] = disk
|
||||
}
|
||||
er := erasureObjects{
|
||||
setDriveCount: 4, defaultParityCount: 2,
|
||||
getDisks: func() []StorageAPI { return disks },
|
||||
getEndpoints: nativeAuditEndpoints,
|
||||
}
|
||||
result, err := er.HealObject(t.Context(), "bucket", "object", "version", madmin.HealOpts{DryRun: true, NoLock: true, Remove: true, ScanMode: madmin.HealDeepScan})
|
||||
for _, disk := range disks {
|
||||
if got := disk.(*nativeAuditDisk).deletes.Load(); got != 0 {
|
||||
t.Fatalf("dry-run deleted data %d times", got)
|
||||
}
|
||||
}
|
||||
quorumFault := fault == "metadata-quorum" || fault == "corrupt-metadata" || fault == "delete-marker-quorum"
|
||||
if quorumFault && err == nil {
|
||||
t.Fatal("metadata quorum failure reported success")
|
||||
}
|
||||
if !quorumFault && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Before.Drives) != len(disks) || !reflect.DeepEqual(result.Before, result.After) {
|
||||
t.Fatalf("dry-run states must preserve observed damage: %+v", result)
|
||||
}
|
||||
switch fault {
|
||||
case "metadata-quorum", "delete-marker-quorum":
|
||||
if !isErrVersionNotFound(err) {
|
||||
t.Fatalf("dry-run must return the reduced metadata error, got %v", err)
|
||||
}
|
||||
case "corrupt-metadata":
|
||||
if !errors.Is(err, errFileCorrupt) {
|
||||
t.Fatalf("dry-run must return the reduced metadata error, got %v", err)
|
||||
}
|
||||
}
|
||||
unhealthy := 0
|
||||
for _, drive := range result.Before.Drives {
|
||||
if drive.State != madmin.DriveStateOk {
|
||||
unhealthy++
|
||||
}
|
||||
}
|
||||
if unhealthy < 3 {
|
||||
t.Fatalf("faults were not reported: %+v", result.Before)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// This control proves the missing-metadata fixture reaches dangling deletion.
|
||||
func TestNativeAuditNonDryRunStillDeletesDangling(t *testing.T) {
|
||||
meta := nativeAuditMetadata()
|
||||
disks := make([]StorageAPI, len(meta))
|
||||
for i := range disks {
|
||||
disk := &nativeAuditDisk{info: meta[i]}
|
||||
if i < 3 {
|
||||
disk.readErr = errFileVersionNotFound
|
||||
}
|
||||
disks[i] = disk
|
||||
}
|
||||
er := erasureObjects{
|
||||
setDriveCount: 4, defaultParityCount: 2,
|
||||
getDisks: func() []StorageAPI { return disks },
|
||||
getEndpoints: nativeAuditEndpoints,
|
||||
}
|
||||
_, err := er.HealObject(t.Context(), "bucket", "object", "version", madmin.HealOpts{NoLock: true, Remove: true, ScanMode: madmin.HealDeepScan})
|
||||
if err == nil {
|
||||
t.Fatal("dangling deletion must report the object removed")
|
||||
}
|
||||
for _, disk := range disks {
|
||||
if got := disk.(*nativeAuditDisk).deletes.Load(); got != 1 {
|
||||
t.Fatalf("control did not reach dangling deletion: %d", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeAuditDryRunPreservesDanglingFiles(t *testing.T) {
|
||||
obj, roots, err := prepareErasure(t.Context(), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setObjectLayer(obj)
|
||||
defer removeRoots(roots)
|
||||
defer obj.Shutdown(t.Context())
|
||||
const bucket, object = "native-audit", "dangling-object"
|
||||
if err = obj.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := bytes.Repeat([]byte("x"), 1024*1024)
|
||||
if _, err = obj.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, root := range roots[:3] {
|
||||
if err = os.Remove(filepath.Join(root, bucket, object, xlStorageFormatFile)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
snapshot := func() map[string]string {
|
||||
files := make(map[string]string)
|
||||
for _, root := range roots {
|
||||
err := filepath.Walk(filepath.Join(root, bucket, object), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[path] = fmt.Sprintf("%d/%d/%x", info.Size(), info.ModTime().UnixNano(), sha256.Sum256(data))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
before := snapshot()
|
||||
if len(before) < 2 {
|
||||
t.Fatal("fixture has no surviving metadata and payload files")
|
||||
}
|
||||
result, err := obj.HealObject(t.Context(), bucket, object, "", madmin.HealOpts{DryRun: true, Remove: true, ScanMode: madmin.HealDeepScan})
|
||||
if err == nil {
|
||||
t.Fatal("missing metadata quorum reported success")
|
||||
}
|
||||
if !reflect.DeepEqual(result.Before, result.After) {
|
||||
t.Fatalf("dry-run reported changes: %+v", result)
|
||||
}
|
||||
if after := snapshot(); !reflect.DeepEqual(before, after) {
|
||||
t.Fatalf("dry-run changed surviving object files: before=%v after=%v", before, after)
|
||||
}
|
||||
}
|
||||
@@ -343,6 +343,11 @@ func (er *erasureObjects) healObject(ctx context.Context, bucket string, object
|
||||
|
||||
readQuorum, _, err := objectQuorumFromMeta(ctx, partsMetadata, errs, er.defaultParityCount)
|
||||
if err != nil {
|
||||
if dryRun {
|
||||
// A metadata quorum failure must not delete dangling data in dry-run.
|
||||
return er.defaultHealResult(FileInfo{}, storageDisks, storageEndpoints,
|
||||
errs, bucket, object, versionID), err
|
||||
}
|
||||
m, derr := er.deleteIfDangling(ctx, bucket, object, partsMetadata, errs, nil, ObjectOptions{
|
||||
VersionID: versionID,
|
||||
})
|
||||
@@ -373,6 +378,13 @@ func (er *erasureObjects) healObject(ctx context.Context, bucket string, object
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
// The restore fields fence native restores and restored-copy cleanup.
|
||||
// Heal copies them only as a quorum of the drives holds them; while the
|
||||
// drives disagree, it waits rather than create a quorum for one side.
|
||||
latestMeta, err = restoreHealMeta(partsMetadata, latestMeta, max(len(storageDisks)/2+1, readQuorum))
|
||||
if err != nil {
|
||||
return result, toObjectErr(err, bucket, object, versionID)
|
||||
}
|
||||
|
||||
// No modtime quorum
|
||||
filterDisksByETag := quorumETag != ""
|
||||
|
||||
@@ -20,6 +20,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"reflect"
|
||||
@@ -110,6 +111,96 @@ func TestReduceErrs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReduceWriteQuorumErrsCommitMetaMissing verifies that an in-quorum
|
||||
// errCommitMetaMissing reduces to the sentinel, which renameData() converts
|
||||
// to errErasureWriteQuorum, while below-quorum reduces directly to
|
||||
// errErasureWriteQuorum.
|
||||
func TestReduceWriteQuorumErrsCommitMetaMissing(t *testing.T) {
|
||||
errs := make([]error, 16)
|
||||
for i := range 9 {
|
||||
errs[i] = errCommitMetaMissing
|
||||
}
|
||||
if err := reduceWriteQuorumErrs(t.Context(), errs, objectOpIgnoredErrs, 9); !errors.Is(err, errCommitMetaMissing) {
|
||||
t.Errorf("expected %s, got %s", errCommitMetaMissing, err)
|
||||
}
|
||||
// The sentinel must be distinguishable from errFileNotFound so that
|
||||
// commit failures are never mapped to ObjectNotFound (404).
|
||||
if errors.Is(errCommitMetaMissing, errFileNotFound) {
|
||||
t.Error("errCommitMetaMissing must not match errFileNotFound")
|
||||
}
|
||||
|
||||
// Below quorum the reduction must yield a write-quorum failure (503);
|
||||
// eight sentinels against eight committed drives is not a quorum.
|
||||
belowQuorum := make([]error, 16)
|
||||
for i := range 8 {
|
||||
belowQuorum[i] = errCommitMetaMissing
|
||||
}
|
||||
if err := reduceWriteQuorumErrs(t.Context(), belowQuorum, objectOpIgnoredErrs, 9); !errors.Is(err, errErasureWriteQuorum) {
|
||||
t.Errorf("expected %s, got %s", errErasureWriteQuorum, err)
|
||||
}
|
||||
}
|
||||
|
||||
// renameDataFailingDisk is a StorageAPI stub returning a fixed error
|
||||
// from RenameData, enough to drive renameData() in unit tests.
|
||||
type renameDataFailingDisk struct {
|
||||
StorageAPI
|
||||
err error
|
||||
undoWrite bool
|
||||
}
|
||||
|
||||
func (d *renameDataFailingDisk) RenameData(context.Context, string, string, FileInfo, string, string, RenameOptions) (RenameDataResp, error) {
|
||||
return RenameDataResp{}, d.err
|
||||
}
|
||||
|
||||
func (d *renameDataFailingDisk) DeleteVersion(_ context.Context, _, _ string, _ FileInfo, _ bool, opts DeleteOptions) error {
|
||||
d.undoWrite = opts.UndoWrite
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestRenameDataCommitMetaMissing verifies that renameData() converts an
|
||||
// in-quorum errCommitMetaMissing into errErasureWriteQuorum (HTTP 503),
|
||||
// while an in-quorum errFileNotFound keeps its meaning so that
|
||||
// PutObject() still reports IncompleteBody for client disconnects.
|
||||
func TestRenameDataCommitMetaMissing(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
failures int
|
||||
diskErr error
|
||||
wantErr error
|
||||
}{
|
||||
{"all_commit_metadata_missing", 16, errCommitMetaMissing, errErasureWriteQuorum},
|
||||
{"quorum_commit_metadata_missing", 9, errCommitMetaMissing, errErasureWriteQuorum},
|
||||
{"neither_side_has_quorum", 8, errCommitMetaMissing, errErasureWriteQuorum},
|
||||
{"successful_write_quorum", 7, errCommitMetaMissing, nil},
|
||||
{"staging_failure", 16, errFileNotFound, errFileNotFound},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
metadata := make([]FileInfo, 16)
|
||||
disks := make([]StorageAPI, 16)
|
||||
for i := range disks {
|
||||
metadata[i] = FileInfo{Deleted: true} // valid without erasure info
|
||||
disk := &renameDataFailingDisk{}
|
||||
if i < tc.failures {
|
||||
disk.err = tc.diskErr
|
||||
}
|
||||
disks[i] = disk
|
||||
}
|
||||
_, _, _, err := renameData(t.Context(), disks, minioMetaTmpBucket, "tmp", metadata, "bucket", "object", 9)
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("expected %v, got %v", tc.wantErr, err)
|
||||
}
|
||||
for i, disk := range disks {
|
||||
// Failed writes undo only the drives that committed. A
|
||||
// successful quorum must never be rolled back.
|
||||
wantUndo := tc.wantErr != nil && i >= tc.failures
|
||||
if got := disk.(*renameDataFailingDisk).undoWrite; got != wantUndo {
|
||||
t.Errorf("disk %d: UndoWrite = %v, want %v", i, got, wantUndo)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashOrder - test order of ints in array
|
||||
func TestHashOrder(t *testing.T) {
|
||||
testCases := []struct {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// Copyright (c) 2026 clown15
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExtractModTimeFromUploadIDDir(t *testing.T) {
|
||||
now := time.Unix(1792056747, 123456789).UTC()
|
||||
nano := now.UnixNano()
|
||||
uuid := "d16ad42a-6fe5-4a3b-8092-69983142238f"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
dirName string
|
||||
expectedMod time.Time
|
||||
wantZero bool
|
||||
}{
|
||||
{
|
||||
name: "Modern Plaintext Format (<UUID>x<UnixNano>)",
|
||||
dirName: fmt.Sprintf("%sx%d", uuid, nano),
|
||||
expectedMod: time.Unix(0, nano),
|
||||
wantZero: false,
|
||||
},
|
||||
{
|
||||
// This valid UUID decodes to bytes containing 'x'. Checking
|
||||
// for that byte before parsing plaintext loses the timestamp.
|
||||
name: "Plaintext Also Decodes To Base64 Containing x",
|
||||
dirName: fmt.Sprintf("4eacd477-e733-4851-b821-6fd42785ff87x%d", nano),
|
||||
expectedMod: now,
|
||||
},
|
||||
{
|
||||
name: "Legacy Format Without Timestamp (<UUID>)",
|
||||
dirName: uuid,
|
||||
expectedMod: time.Time{},
|
||||
wantZero: true,
|
||||
},
|
||||
{
|
||||
name: "Base64 Encoded Format",
|
||||
dirName: base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%sx%d", uuid, nano))),
|
||||
expectedMod: time.Unix(0, nano),
|
||||
wantZero: false,
|
||||
},
|
||||
{
|
||||
name: "Encoded Format With Deployment Prefix",
|
||||
dirName: base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s.%sx%d", uuid, uuid, nano))),
|
||||
expectedMod: now,
|
||||
},
|
||||
{
|
||||
name: "Malformed Suffix (<UUID>xinvalid)",
|
||||
dirName: fmt.Sprintf("%sxnotanumber", uuid),
|
||||
expectedMod: time.Time{},
|
||||
wantZero: true,
|
||||
},
|
||||
{
|
||||
name: "Timestamp Overflow",
|
||||
dirName: uuid + "x9223372036854775808",
|
||||
wantZero: true,
|
||||
},
|
||||
{
|
||||
name: "Malformed Plaintext UUID",
|
||||
dirName: fmt.Sprintf("not-a-uuidx%d", nano),
|
||||
wantZero: true,
|
||||
},
|
||||
{
|
||||
name: "Empty Directory Name",
|
||||
dirName: "",
|
||||
expectedMod: time.Time{},
|
||||
wantZero: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := extractModTimeFromUploadIDDir(tc.dirName)
|
||||
if tc.wantZero {
|
||||
if !got.IsZero() {
|
||||
t.Fatalf("expected zero time for %q, got %v", tc.dirName, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !got.Equal(tc.expectedMod) {
|
||||
t.Fatalf("extractModTimeFromUploadIDDir(%q) = %v; want %v", tc.dirName, got, tc.expectedMod)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type multipartCleanupDisk struct {
|
||||
StorageAPI
|
||||
root string
|
||||
modTime time.Time
|
||||
readErr error
|
||||
reads int
|
||||
}
|
||||
|
||||
func (d *multipartCleanupDisk) Endpoint() Endpoint {
|
||||
return Endpoint{URL: &url.URL{Path: d.root}}
|
||||
}
|
||||
|
||||
func (d *multipartCleanupDisk) ReadVersion(context.Context, string, string, string, string, ReadOptions) (FileInfo, error) {
|
||||
d.reads++
|
||||
return FileInfo{ModTime: d.modTime}, d.readErr
|
||||
}
|
||||
|
||||
func (d *multipartCleanupDisk) StatVol(ctx context.Context, volume string) (VolInfo, error) {
|
||||
return (&xlStorage{drivePath: d.root}).StatVol(ctx, volume)
|
||||
}
|
||||
|
||||
func TestCleanupStaleUploadsOnDisk(t *testing.T) {
|
||||
const uuid = "d16ad42a-6fe5-4a3b-8092-69983142238f"
|
||||
expiry := globalAPIConfig.getStaleUploadsExpiry()
|
||||
for _, tc := range []struct {
|
||||
name, prefix string
|
||||
age time.Duration
|
||||
legacy bool
|
||||
readErr error
|
||||
wantReads int
|
||||
wantDeleted bool
|
||||
}{
|
||||
{name: "recent", prefix: uuid, age: expiry / 2, readErr: errFileNotFound},
|
||||
{name: "plaintext_base64_collision", prefix: "4eacd477-e733-4851-b821-6fd42785ff87", age: expiry / 2, readErr: errFileNotFound},
|
||||
{name: "expired", prefix: uuid, age: 2 * expiry, readErr: errFileNotFound, wantDeleted: true},
|
||||
{name: "legacy_recent", prefix: uuid, age: expiry / 2, legacy: true, wantReads: 1},
|
||||
{name: "legacy_expired", prefix: uuid, age: 2 * expiry, legacy: true, wantReads: 1, wantDeleted: true},
|
||||
{name: "legacy_metadata_missing", prefix: uuid, age: 2 * expiry, legacy: true, readErr: errFileNotFound, wantReads: 1},
|
||||
{name: "malformed_metadata_missing", prefix: "not-a-uuid", age: 2 * expiry, readErr: errFileNotFound, wantReads: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
modTime := time.Now().Add(-tc.age)
|
||||
id := tc.prefix
|
||||
if !tc.legacy {
|
||||
id = fmt.Sprintf("%sx%d", tc.prefix, modTime.UnixNano())
|
||||
}
|
||||
disk := &multipartCleanupDisk{root: t.TempDir(), modTime: modTime, readErr: tc.readErr}
|
||||
uploadDir := filepath.Join(disk.root, minioMetaMultipartBucket, "sha", id)
|
||||
for _, dir := range []string{uploadDir, filepath.Join(disk.root, minioMetaTmpDeletedBucket)} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// An old parent must not bypass a skipped upload's age or read error.
|
||||
if err := os.Chtimes(filepath.Dir(uploadDir), modTime, modTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk)
|
||||
if disk.reads != tc.wantReads {
|
||||
t.Errorf("ReadVersion calls = %d, want %d", disk.reads, tc.wantReads)
|
||||
}
|
||||
_, err := os.Stat(uploadDir)
|
||||
if tc.wantDeleted {
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("expired upload must be removed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(uploadDir)); !os.IsNotExist(err) {
|
||||
t.Errorf("empty hash directory must be removed in the same pass: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("upload must be preserved: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleUploadsEmptyHashDirectory(t *testing.T) {
|
||||
for _, age := range []time.Duration{0, 2 * globalAPIConfig.getStaleUploadsExpiry()} {
|
||||
t.Run(age.String(), func(t *testing.T) {
|
||||
disk := &multipartCleanupDisk{root: t.TempDir()}
|
||||
shaDir := filepath.Join(disk.root, minioMetaMultipartBucket, "sha")
|
||||
for _, dir := range []string{shaDir, filepath.Join(disk.root, minioMetaTmpDeletedBucket)} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
modTime := time.Now().Add(-age)
|
||||
if err := os.Chtimes(shaDir, modTime, modTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk)
|
||||
if _, err := os.Stat(shaDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("empty hash directory must be removed regardless of age: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleUploadsSharedHashDirectory(t *testing.T) {
|
||||
disk := &multipartCleanupDisk{root: t.TempDir(), readErr: errFileNotFound}
|
||||
shaDir := filepath.Join(disk.root, minioMetaMultipartBucket, "sha")
|
||||
now := time.Now()
|
||||
expiredID := fmt.Sprintf("d16ad42a-6fe5-4a3b-8092-69983142238fx%d", now.Add(-2*globalAPIConfig.getStaleUploadsExpiry()).UnixNano())
|
||||
recentID := fmt.Sprintf("4eacd477-e733-4851-b821-6fd42785ff87x%d", now.UnixNano())
|
||||
for _, id := range []string{expiredID, recentID} {
|
||||
if err := os.MkdirAll(filepath.Join(shaDir, id), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
partPath := filepath.Join(shaDir, recentID, "part.1")
|
||||
const part = "recent upload part"
|
||||
if err := os.WriteFile(partPath, []byte(part), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk)
|
||||
if _, err := os.Stat(filepath.Join(shaDir, expiredID)); !os.IsNotExist(err) {
|
||||
t.Errorf("expired upload must be removed: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(partPath); err != nil || string(got) != part {
|
||||
t.Errorf("recent upload must remain intact: got %q, err %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -253,7 +253,7 @@ func testAPICompleteMultipartFullObjectChecksumMismatch(obj ObjectLayer, instanc
|
||||
if apiErr.Code != "BadDigest" {
|
||||
t.Fatalf("%s: expected BadDigest, got %q", instanceType, apiErr.Code)
|
||||
}
|
||||
if want := "The CRC32 checksum you specified did not match the calculated checksum."; apiErr.Message != want {
|
||||
if want := "The CRC32 you specified did not match the calculated checksum."; apiErr.Message != want {
|
||||
t.Fatalf("%s: expected message %q, got %q", instanceType, want, apiErr.Message)
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ func testAPICompleteMultipartCompositeChecksumMismatch(obj ObjectLayer, instance
|
||||
if apiErr.Code != "BadDigest" {
|
||||
t.Fatalf("%s: expected BadDigest, got %q", instanceType, apiErr.Code)
|
||||
}
|
||||
if want := "The CRC32 checksum you specified did not match the calculated checksum."; apiErr.Message != want {
|
||||
if want := "The CRC32 you specified did not match the calculated checksum."; apiErr.Message != want {
|
||||
t.Fatalf("%s: expected message %q, got %q", instanceType, want, apiErr.Message)
|
||||
}
|
||||
if _, err := obj.GetObjectInfo(t.Context(), bucketName, objectName, ObjectOptions{}); err == nil {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) 2026 Ruohang Feng
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Run explicitly with -benchtime=1x. Fixture creation is excluded from timing;
|
||||
// each request still discovers real on-disk metadata. The 16-drive/10k case
|
||||
// deliberately exceeds the existing aggregate directory-entry budget.
|
||||
func BenchmarkMultipartDiagnosticScanCapacity(b *testing.B) {
|
||||
for _, tc := range []struct {
|
||||
drives, uploads int
|
||||
mixed, limited bool
|
||||
}{
|
||||
{4, 1_000, false, false},
|
||||
{4, 10_000, false, false},
|
||||
{4, 10_000, true, false},
|
||||
{16, 1_000, false, false},
|
||||
{16, 10_000, false, true},
|
||||
} {
|
||||
b.Run(fmt.Sprintf("drives=%d/uploads=%d/mixed=%t", tc.drives, tc.uploads, tc.mixed), func(b *testing.B) {
|
||||
obj, dirs, err := prepareErasure(b.Context(), tc.drives)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
b.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||
const bucket, other = "multipart-capacity", "multipart-unrelated"
|
||||
for _, name := range []string{bucket, other} {
|
||||
if err := z.MakeBucket(b.Context(), name, MakeBucketOptions{}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
for n := range tc.uploads {
|
||||
target := bucket
|
||||
if tc.mixed && n%2 != 0 {
|
||||
target = other
|
||||
}
|
||||
key := fmt.Sprintf("dir/%05d", n)
|
||||
mp, err := z.NewMultipartUpload(b.Context(), target, key, ObjectOptions{})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if tc.mixed && n%10 == 0 {
|
||||
rewriteMultipartIdentity(b, z.serverPools[0].getHashedSet(key), target, key, mp.UploadID, removeMultipartIdentity)
|
||||
}
|
||||
}
|
||||
z.mpCache.Clear()
|
||||
var directoryCalls, metadataCalls atomic.Int64
|
||||
for _, set := range z.serverPools[0].sets {
|
||||
original := set.getDisks
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i, disk := range disks {
|
||||
disks[i] = multipartListingCountingDisk{disk, &directoryCalls, &metadataCalls}
|
||||
}
|
||||
return disks
|
||||
}
|
||||
b.Cleanup(func() { set.getDisks = original })
|
||||
}
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
got, err := z.scanMultipartUploadsForTest(b.Context(), bucket, "dir/", "", "", "", 1000)
|
||||
if tc.limited {
|
||||
var limited SlowDown
|
||||
if !errors.As(err, &limited) || len(got.Uploads) != 0 {
|
||||
b.Fatalf("overflow must return SlowDown without a partial page: %+v %v", got, err)
|
||||
}
|
||||
} else if err != nil || len(got.Uploads) != 1000 {
|
||||
b.Fatalf("capacity listing: count=%d err=%v", len(got.Uploads), err)
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
b.ReportMetric(float64(directoryCalls.Load())/float64(b.N), "ListDir/op")
|
||||
b.ReportMetric(float64(metadataCalls.Load())/float64(b.N), "ReadVersion/op")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The public path is measured with a populated cache as well as after cache
|
||||
// loss. An empty post-restart response alone is not capacity acceptance.
|
||||
func BenchmarkMultipartPublicListingCapacity(b *testing.B) {
|
||||
for _, tc := range []struct {
|
||||
drives, uploads int
|
||||
sameKey bool
|
||||
}{
|
||||
{4, 10_000, false},
|
||||
{16, 3_126, false},
|
||||
{16, 10_000, true},
|
||||
} {
|
||||
b.Run(fmt.Sprintf("drives=%d/uploads=%d/same-key=%t", tc.drives, tc.uploads, tc.sameKey), func(b *testing.B) {
|
||||
obj, dirs, err := prepareErasure(b.Context(), tc.drives)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
b.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||
const bucket, other = "multipart-public-capacity", "multipart-public-other"
|
||||
for _, name := range []string{bucket, other} {
|
||||
if err := z.MakeBucket(b.Context(), name, MakeBucketOptions{}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
wantCached, wantExact := 0, 1
|
||||
for n := range tc.uploads {
|
||||
target, key := bucket, fmt.Sprintf("dir/%05d", n)
|
||||
if tc.sameKey {
|
||||
key = "dir/00000"
|
||||
} else if n%2 != 0 {
|
||||
target = other
|
||||
}
|
||||
if target == bucket {
|
||||
wantCached++
|
||||
}
|
||||
if _, err := z.NewMultipartUpload(b.Context(), target, key, ObjectOptions{}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
if tc.sameKey {
|
||||
wantExact = 1000
|
||||
}
|
||||
if tc.drives == 16 && !tc.sameKey {
|
||||
_, err := z.scanMultipartUploadsForTest(b.Context(), bucket, "", "", "", "", 1000)
|
||||
var limited SlowDown
|
||||
if !errors.As(err, &limited) {
|
||||
b.Fatalf("fixture did not reproduce the previous global budget failure: %v", err)
|
||||
}
|
||||
}
|
||||
var directoryCalls, metadataCalls atomic.Int64
|
||||
for _, set := range z.serverPools[0].sets {
|
||||
original := set.getDisks
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i, disk := range disks {
|
||||
disks[i] = multipartListingCountingDisk{disk, &directoryCalls, &metadataCalls}
|
||||
}
|
||||
return disks
|
||||
}
|
||||
b.Cleanup(func() { set.getDisks = original })
|
||||
}
|
||||
measure := func(name, prefix string, want int) {
|
||||
b.Run(name, func(b *testing.B) {
|
||||
directoryCalls.Store(0)
|
||||
metadataCalls.Store(0)
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
got, err := z.ListMultipartUploads(b.Context(), bucket, prefix, "", "", "", 1000)
|
||||
if err != nil || len(got.Uploads) != want {
|
||||
b.Fatalf("public listing: count=%d want=%d err=%v", len(got.Uploads), want, err)
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
b.ReportMetric(float64(directoryCalls.Load())/float64(b.N), "ListDir/op")
|
||||
b.ReportMetric(float64(metadataCalls.Load())/float64(b.N), "ReadVersion/op")
|
||||
})
|
||||
}
|
||||
measure("populated-cache", "", wantCached)
|
||||
z.mpCache.Clear()
|
||||
measure("lost-cache", "", 0)
|
||||
measure("known-key", "dir/00000", wantExact)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ const multipartScanEntryLimit = 100_000
|
||||
|
||||
var (
|
||||
multipartScanSlots = make(chan struct{}, 2)
|
||||
errMultipartListingLegacy = errors.New("legacy multipart uploads require a coordinated upgrade and drain")
|
||||
errMultipartListingIdentity = errors.New("multipart upload identity is invalid")
|
||||
)
|
||||
|
||||
@@ -133,6 +132,14 @@ func (er erasureObjects) multipartIdentity(fi FileInfo, shaDir string) (string,
|
||||
IsValidObjectPrefix(object) && er.getMultipartSHADir(bucket, object) == shaDir
|
||||
}
|
||||
|
||||
// Only the absence of both fields identifies the historical format. Empty,
|
||||
// partial or invalid identities must not hide a damaged new upload as legacy.
|
||||
func multipartLegacyIdentity(fi FileInfo) bool {
|
||||
_, bucketPresent := fi.Metadata[multipartMetaBucket]
|
||||
_, objectPresent := fi.Metadata[multipartMetaObject]
|
||||
return !bucketPresent && !objectPresent
|
||||
}
|
||||
|
||||
func (er erasureObjects) readMultipartUploadCandidate(s *multipartScan, bucket, candidate string, source StorageAPI) (MultipartInfo, bool, bool, error) {
|
||||
if s.ctx.Err() != nil {
|
||||
return MultipartInfo{}, false, false, SlowDown{}
|
||||
@@ -162,8 +169,8 @@ func (er erasureObjects) readMultipartUploadCandidate(s *multipartScan, bucket,
|
||||
disks := er.getDisks()
|
||||
metadata, errs := readAllFileInfo(s.ctx, disks, bucket, minioMetaMultipartBucket, candidate, "", false, false)
|
||||
if s.preflight {
|
||||
// Readiness is stronger than listing liveness: even one readable legacy
|
||||
// copy must be drained, and an unreadable copy cannot certify readiness.
|
||||
// The diagnostic counts even a single readable legacy copy. It does not
|
||||
// gate listing; unreadable copies still prevent a complete diagnosis.
|
||||
var oldest MultipartInfo
|
||||
legacy := false
|
||||
_, nativeID := multipartUploadTime(uploadUUID)
|
||||
@@ -176,10 +183,11 @@ func (er erasureObjects) readMultipartUploadCandidate(s *multipartScan, bucket,
|
||||
}
|
||||
fi := metadata[i]
|
||||
storedBucket, storedObject, valid := er.multipartIdentity(fi, shaDir)
|
||||
if storedBucket != "" && storedObject != "" && !valid {
|
||||
isLegacy := multipartLegacyIdentity(fi)
|
||||
if !isLegacy && (!valid || !nativeID) {
|
||||
return MultipartInfo{}, false, false, errMultipartListingIdentity
|
||||
}
|
||||
if !valid || !nativeID {
|
||||
if isLegacy {
|
||||
info := multipartUploadInfo(storedBucket, storedObject, uploadUUID, fi.ModTime)
|
||||
if !legacy || info.Initiated.Before(oldest.Initiated) {
|
||||
oldest = info
|
||||
@@ -203,16 +211,29 @@ func (er erasureObjects) readMultipartUploadCandidate(s *multipartScan, bucket,
|
||||
if err != nil {
|
||||
return MultipartInfo{}, false, false, err
|
||||
}
|
||||
// The generic metadata quorum does not compare these identity fields.
|
||||
// Contradictory readable copies cannot certify a historical upload: picking
|
||||
// a copy with missing fields must not silently hide a new-format record.
|
||||
legacy := multipartLegacyIdentity(fi)
|
||||
storedBucket, storedObject, valid := er.multipartIdentity(fi, shaDir)
|
||||
for i, other := range metadata {
|
||||
if errs[i] != nil || !other.IsValid() {
|
||||
continue
|
||||
}
|
||||
if multipartLegacyIdentity(other) != legacy ||
|
||||
(!legacy && (other.Metadata[multipartMetaBucket] != storedBucket || other.Metadata[multipartMetaObject] != storedObject)) {
|
||||
return MultipartInfo{}, false, false, fmt.Errorf("%w: inconsistent copies at %s", errMultipartListingIdentity, candidate)
|
||||
}
|
||||
}
|
||||
info := multipartUploadInfo(storedBucket, storedObject, uploadUUID, fi.ModTime)
|
||||
if storedBucket == "" || storedObject == "" {
|
||||
if legacy {
|
||||
return info, false, true, nil
|
||||
}
|
||||
if !valid {
|
||||
return MultipartInfo{}, false, false, fmt.Errorf("%w: %s", errMultipartListingIdentity, candidate)
|
||||
}
|
||||
if _, ok := multipartUploadTime(uploadUUID); !ok {
|
||||
return info, false, true, nil
|
||||
return MultipartInfo{}, false, false, fmt.Errorf("%w: %s", errMultipartListingIdentity, candidate)
|
||||
}
|
||||
if bucket != "" && bucket != storedBucket {
|
||||
return MultipartInfo{}, false, false, nil
|
||||
@@ -220,7 +241,7 @@ func (er erasureObjects) readMultipartUploadCandidate(s *multipartScan, bucket,
|
||||
return info, true, false, nil
|
||||
}
|
||||
|
||||
func (er erasureObjects) scanMultipartUploads(s *multipartScan, bucket string, poolIdx, setIdx int) ([]MultipartInfo, bool, error) {
|
||||
func (er erasureObjects) scanMultipartUploads(s *multipartScan, bucket string, poolIdx, setIdx int) ([]MultipartInfo, error) {
|
||||
disks := er.getDisks()
|
||||
report := multipartScanSet{Pool: poolIdx, Set: setIdx, Drives: er.setDriveCount}
|
||||
var resultErr error
|
||||
@@ -272,7 +293,7 @@ func (er erasureObjects) scanMultipartUploads(s *multipartScan, bucket string, p
|
||||
resultErr = toObjectErr(errErasureReadQuorum, bucket)
|
||||
}
|
||||
if resultErr != nil {
|
||||
return nil, false, resultErr
|
||||
return nil, resultErr
|
||||
}
|
||||
type candidate struct {
|
||||
path string
|
||||
@@ -325,25 +346,23 @@ dispatch:
|
||||
if s.ctx.Err() != nil {
|
||||
resultErr = SlowDown{}
|
||||
}
|
||||
return uploads, report.LegacyUploads != 0, resultErr
|
||||
return uploads, resultErr
|
||||
}
|
||||
|
||||
// multipartPreflight scans all pools, sets and drives, independent of caches.
|
||||
// A majority suffices for normal listing; upgrade readiness requires every
|
||||
// drive to have been inspected. The operator must also upgrade all writers.
|
||||
// A majority suffices for normal listing; a complete diagnosis requires every
|
||||
// drive to have been inspected. Ready describes the absence of legacy copies,
|
||||
// not whether listing is enabled.
|
||||
func (z *erasureServerPools) multipartPreflight(ctx context.Context) (multipartPreflightReport, error) {
|
||||
s, err := startMultipartScan(ctx, true)
|
||||
if err != nil {
|
||||
return multipartPreflightReport{}, err
|
||||
}
|
||||
defer s.close()
|
||||
report := multipartPreflightReport{Complete: true, Mode: "strict"}
|
||||
if globalAPIConfig.getMultipartListingLegacy() {
|
||||
report.Mode = "legacy"
|
||||
}
|
||||
report := multipartPreflightReport{Complete: true, Mode: "legacy"}
|
||||
for p, pool := range z.serverPools {
|
||||
for i, set := range pool.sets {
|
||||
_, _, _ = set.scanMultipartUploads(s, "", p, i)
|
||||
_, _ = set.scanMultipartUploads(s, "", p, i)
|
||||
}
|
||||
}
|
||||
report.Sets = s.sets
|
||||
|
||||
@@ -22,16 +22,59 @@ import (
|
||||
"github.com/minio/minio/internal/config/storageclass"
|
||||
)
|
||||
|
||||
// Check the real handler and storage path, including a successful abort between
|
||||
// pages. Choose IDs increasing in both initiation time and lexical order so the
|
||||
// failure does not depend on differing interpretations of S3 marker ordering.
|
||||
func TestMultipartListingAbortBetweenHTTPPages(t *testing.T) {
|
||||
// The persistent scanner is retained for diagnostics. This test-only wrapper
|
||||
// preserves its pagination coverage without exposing it through public listing.
|
||||
func (z *erasureServerPools) scanMultipartUploadsForTest(ctx context.Context, bucket, prefix, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (ListMultipartsInfo, error) {
|
||||
if err := checkListMultipartArgs(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter); err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
if _, err := z.GetBucketInfo(ctx, bucket, BucketOptions{}); err != nil {
|
||||
return ListMultipartsInfo{}, toObjectErr(err, bucket)
|
||||
}
|
||||
scan, err := startMultipartScan(ctx, false)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
defer scan.close()
|
||||
|
||||
var uploads []MultipartInfo
|
||||
for idx, pool := range z.serverPools {
|
||||
if z.IsSuspended(idx) {
|
||||
continue
|
||||
}
|
||||
poolUploads, err := pool.scanMultipartUploadsForTest(scan, bucket, idx)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
uploads = append(uploads, poolUploads...)
|
||||
}
|
||||
|
||||
// Confirmed historical records lack a recoverable bucket/key and are
|
||||
// omitted by the scan. They do not block listing identifiable uploads.
|
||||
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
|
||||
}
|
||||
|
||||
func (s *erasureSets) scanMultipartUploadsForTest(scan *multipartScan, bucket string, poolIdx int) ([]MultipartInfo, error) {
|
||||
var uploads []MultipartInfo
|
||||
for i, set := range s.sets {
|
||||
setUploads, err := set.scanMultipartUploads(scan, bucket, poolIdx, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uploads = append(uploads, setUploads...)
|
||||
}
|
||||
return uploads, nil
|
||||
}
|
||||
|
||||
// Check that a successful HTTP abort removes the cached upload. The restored
|
||||
// cache path still ignores page limits; this is a documented legacy limitation.
|
||||
func TestMultipartListingAbortHTTPUpdatesCache(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads", "AbortMultipart"}, MakeBucketOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setMultipartListingTestMode(t, false)
|
||||
|
||||
var firstID, secondID string
|
||||
for attempt := 0; attempt < 32; attempt++ {
|
||||
one, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
@@ -81,8 +124,8 @@ func TestMultipartListingAbortBetweenHTTPPages(t *testing.T) {
|
||||
return result
|
||||
}
|
||||
first := list("", "", 1)
|
||||
if len(first.Uploads) != 1 || first.Uploads[0].UploadID != firstID || !first.IsTruncated {
|
||||
t.Fatalf("unexpected first page: %+v", first)
|
||||
if len(first.Uploads) != 3 || first.Uploads[0].UploadID != firstID || first.IsTruncated {
|
||||
t.Fatalf("unexpected legacy cache view: %+v", first)
|
||||
}
|
||||
rec := request(http.MethodDelete, getAbortMultipartUploadURL("", bucket, "a", firstID))
|
||||
if rec.Code != http.StatusNoContent {
|
||||
@@ -93,12 +136,12 @@ func TestMultipartListingAbortBetweenHTTPPages(t *testing.T) {
|
||||
for _, upload := range rest.Uploads {
|
||||
keys = append(keys, upload.Key)
|
||||
}
|
||||
t.Logf("GET first page=200; DELETE marker=204; GET next page=200 keys=%v truncated=%v", keys, rest.IsTruncated)
|
||||
t.Logf("GET cache=200; DELETE upload=204; GET cache=200 keys=%v truncated=%v", keys, rest.IsTruncated)
|
||||
if _, err := z.GetMultipartInfo(t.Context(), bucket, "a", secondID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("remaining upload is not valid: %v", err)
|
||||
}
|
||||
if len(rest.Uploads) != 2 || rest.Uploads[0].UploadID != secondID {
|
||||
t.Fatalf("valid remaining upload for key a is missing from continuation: %+v", rest.Uploads)
|
||||
t.Fatalf("valid remaining upload for key a is missing from cache: %+v", rest.Uploads)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,28 +191,25 @@ func TestMultipartListingLegacyPreflight(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z.mpCache.Clear()
|
||||
t.Run("legacy-upgrade", func(t *testing.T) {
|
||||
setMultipartListingTestMode(t, true)
|
||||
exact, err := z.ListMultipartUploads(t.Context(), other, "old", "", "", "", 10)
|
||||
for _, target := range []string{bucket, other} {
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), target, "", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, exact, "old")
|
||||
const empty = "multipart-legacy-empty"
|
||||
if err := z.MakeBucket(t.Context(), empty, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
if target == bucket {
|
||||
requireMultipartUploadKeys(t, got, "a")
|
||||
} else {
|
||||
requireMultipartUploadKeys(t, got)
|
||||
}
|
||||
got, err := z.ListMultipartUploads(t.Context(), empty, "", "", "", "", 10)
|
||||
if err != nil || len(got.Uploads) != 0 {
|
||||
t.Fatalf("old upload in another bucket broke legacy listing: %+v %v", got, err)
|
||||
}
|
||||
})
|
||||
_, err = z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
if !errors.Is(err, errMultipartListingLegacy) {
|
||||
t.Fatalf("old upload in another bucket: %v", err)
|
||||
}
|
||||
// Diagnostic discovery cannot recover an unidentified historical key.
|
||||
exact, err := z.scanMultipartUploadsForTest(t.Context(), other, "old", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, exact)
|
||||
report, err := z.multipartPreflight(t.Context())
|
||||
if err != nil || report.Ready || !report.Complete || report.LegacyUploads != 1 {
|
||||
if err != nil || report.Ready || !report.Complete || report.LegacyUploads != 1 || report.Mode != "legacy" {
|
||||
t.Fatalf("legacy preflight: %+v %v", report, err)
|
||||
}
|
||||
if err = z.AbortMultipartUpload(t.Context(), other, "old", old.UploadID, ObjectOptions{}); err != nil {
|
||||
@@ -179,15 +219,15 @@ func TestMultipartListingLegacyPreflight(t *testing.T) {
|
||||
if err != nil || !report.Ready || report.LegacyUploads != 0 {
|
||||
t.Fatalf("drained preflight: %+v %v", report, err)
|
||||
}
|
||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, got, "a")
|
||||
}
|
||||
|
||||
func TestMultipartListingIdentityFallback(t *testing.T) {
|
||||
for _, kind := range []string{"old", "corrupt", "read-failure", "wrong-bucket"} {
|
||||
func TestMultipartDiagnosticIdentityFallback(t *testing.T) {
|
||||
for _, kind := range []string{"old", "partial", "corrupt", "read-failure", "wrong-bucket"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
if _, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{}); err != nil {
|
||||
@@ -209,8 +249,11 @@ func TestMultipartListingIdentityFallback(t *testing.T) {
|
||||
return FileInfo{}, errFileCorrupt
|
||||
}
|
||||
fi.Metadata = cloneMSS(fi.Metadata)
|
||||
if kind == "old" {
|
||||
if kind == "old" || kind == "partial" {
|
||||
delete(fi.Metadata, multipartMetaBucket)
|
||||
if kind == "old" {
|
||||
delete(fi.Metadata, multipartMetaObject)
|
||||
}
|
||||
} else {
|
||||
fi.Metadata[multipartMetaBucket] = "another-bucket"
|
||||
}
|
||||
@@ -220,7 +263,7 @@ func TestMultipartListingIdentityFallback(t *testing.T) {
|
||||
}
|
||||
return disks
|
||||
}
|
||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -231,7 +274,7 @@ func TestMultipartListingIdentityFallback(t *testing.T) {
|
||||
|
||||
func TestMultipartAbortPoolsAndRetry(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
setMultipartListingTestMode(t, false)
|
||||
|
||||
mp, err := z.serverPools[1].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -252,10 +295,12 @@ func TestMultipartAbortPoolsAndRetry(t *testing.T) {
|
||||
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
||||
t.Fatalf("unknown pool must not acknowledge cancellation: %v", err)
|
||||
}
|
||||
if _, err := z.serverPools[1].GetMultipartInfo(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("earlier pool error changed the later pool: %v", err)
|
||||
}
|
||||
emptySet.getDisks = original
|
||||
err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{})
|
||||
if _, ok := err.(InvalidUploadID); !ok {
|
||||
t.Fatalf("retry after all pools confirm absence: %v", err)
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("retry after pool recovery: %v", err)
|
||||
}
|
||||
mp, err = z.serverPools[1].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
@@ -289,12 +334,13 @@ func TestMultipartAbortRetryBelowReadQuorum(t *testing.T) {
|
||||
}}
|
||||
return disks
|
||||
}
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err == nil {
|
||||
t.Fatal("lost acknowledgement must fail")
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("best-effort cleanup must retain read-quorum availability: %v", err)
|
||||
}
|
||||
set.getDisks = original
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("one remaining metadata copy prevented retry: %v", err)
|
||||
var invalid InvalidUploadID
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); !errors.As(err, &invalid) {
|
||||
t.Fatalf("minority remnant must retain absent-upload result: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +350,7 @@ func TestMultipartListingMarkerHTTP(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setMultipartListingTestMode(t, false)
|
||||
|
||||
for _, tc := range []struct {
|
||||
key, marker string
|
||||
status int
|
||||
@@ -388,7 +434,7 @@ func TestMultipartAbortLateCreateBoundary(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
setMultipartListingTestMode(t, false)
|
||||
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||
saved := globalStorageClass
|
||||
globalStorageClass.Update(storageclass.Config{Standard: storageclass.StorageClass{Parity: 8}})
|
||||
@@ -417,9 +463,7 @@ func TestMultipartAbortLateCreateBoundary(t *testing.T) {
|
||||
}
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i := 2; i < 9; i++ {
|
||||
disks[i] = nil
|
||||
}
|
||||
disks[8] = nil
|
||||
return disks
|
||||
}
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
@@ -431,7 +475,7 @@ func TestMultipartAbortLateCreateBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
set.getDisks = original
|
||||
_, metadata, err := set.checkUploadIDExists(t.Context(), bucket, "a", mp.UploadID, true)
|
||||
_, metadata, err := set.checkUploadIDExists(t.Context(), bucket, "a", mp.UploadID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("late creation boundary changed; review and remove the documented limitation: %v", err)
|
||||
}
|
||||
@@ -441,10 +485,10 @@ func TestMultipartAbortLateCreateBoundary(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 14 {
|
||||
t.Fatalf("expected fourteen resurrected copies, got %d", count)
|
||||
if count != 8 {
|
||||
t.Fatalf("expected eight resurrected copies, got %d", count)
|
||||
}
|
||||
t.Log("KNOWN UNRESOLVED BOUNDARY: seven delayed creation writes plus seven offline copies restore a writable upload after acknowledged cancellation")
|
||||
t.Log("KNOWN UNRESOLVED BOUNDARY: seven delayed creation writes plus one offline copy restore a readable upload after acknowledged cancellation")
|
||||
}
|
||||
|
||||
type multipartListingCountingDisk struct {
|
||||
@@ -468,13 +512,13 @@ func (d multipartListingCountingDisk) ReadVersion(ctx context.Context, original,
|
||||
}
|
||||
|
||||
// Counts storage API calls; this is not a deployment throughput benchmark.
|
||||
func TestMultipartListingScanCosts(t *testing.T) {
|
||||
func TestMultipartDiagnosticScanCosts(t *testing.T) {
|
||||
obj, dirs, err := prepareErasure(t.Context(), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
setMultipartListingTestMode(t, false)
|
||||
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||
const bucket, otherBucket = "r9-scan-target", "r9-scan-unrelated"
|
||||
for _, name := range []string{bucket, otherBucket} {
|
||||
@@ -506,7 +550,7 @@ func TestMultipartListingScanCosts(t *testing.T) {
|
||||
t.Helper()
|
||||
directoryCalls.Store(0)
|
||||
metadataCalls.Store(0)
|
||||
result, err := z.ListMultipartUploads(t.Context(), bucket, "dir/", "", "", "", 1)
|
||||
result, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "dir/", "", "", "", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -544,7 +588,7 @@ func multipartListingFixture(t *testing.T) (*erasureServerPools, *erasureObjects
|
||||
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setMultipartListingTestMode(t, false)
|
||||
|
||||
return z, z.serverPools[0].getHashedSet("a"), bucket
|
||||
}
|
||||
|
||||
@@ -588,46 +632,7 @@ func TestMultipartListingMissingMarkers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingAbortRecovery(t *testing.T) {
|
||||
for _, offline := range []int{1, 2} {
|
||||
t.Run(fmt.Sprint(offline), func(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := set.getDisks
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i := 0; i < offline; i++ {
|
||||
disks[i] = nil
|
||||
}
|
||||
return disks
|
||||
}
|
||||
err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{})
|
||||
if offline == 1 && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if offline == 2 && (err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503) {
|
||||
t.Fatalf("two offline drives must not acknowledge cancellation: %v", err)
|
||||
}
|
||||
set.getDisks = original
|
||||
if offline == 2 {
|
||||
// Retry a partially completed deletion after the original disks return.
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 100)
|
||||
if err != nil || len(got.Uploads) != 0 {
|
||||
t.Fatalf("canceled upload reappeared: %+v %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingCoverage(t *testing.T) {
|
||||
func TestMultipartDiagnosticCoverage(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
@@ -643,12 +648,12 @@ func TestMultipartListingCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return []StorageAPI{disks[0], disks[1], nil, nil} }
|
||||
_, err = z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
_, err = z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
||||
t.Fatalf("incomplete discovery returned success: %v", err)
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return []StorageAPI{nil, disks[1], disks[2], disks[3]} }
|
||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -659,7 +664,7 @@ func TestMultipartListingCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingBudgetAndAdmission(t *testing.T) {
|
||||
func TestMultipartDiagnosticBudgetAndAdmission(t *testing.T) {
|
||||
_, set, bucket := multipartListingFixture(t)
|
||||
if _, err := set.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -670,7 +675,7 @@ func TestMultipartListingBudgetAndAdmission(t *testing.T) {
|
||||
}
|
||||
defer scan.close()
|
||||
scan.remaining.Store(1)
|
||||
_, _, err = set.scanMultipartUploads(scan, bucket, 0, 0)
|
||||
_, err = set.scanMultipartUploads(scan, bucket, 0, 0)
|
||||
var limited SlowDown
|
||||
if !errors.As(err, &limited) {
|
||||
t.Fatalf("budget: %v", err)
|
||||
@@ -689,13 +694,13 @@ func TestMultipartListingBudgetAndAdmission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingAdmissionHTTP(t *testing.T) {
|
||||
func TestMultipartPublicListingAdmissionHTTP(t *testing.T) {
|
||||
z, _, _ := multipartListingFixture(t)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads"}, MakeBucketOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setMultipartListingTestMode(t, false)
|
||||
|
||||
for range cap(multipartScanSlots) {
|
||||
scan, err := startMultipartScan(t.Context(), false)
|
||||
if err != nil {
|
||||
@@ -711,12 +716,12 @@ func TestMultipartListingAdmissionHTTP(t *testing.T) {
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var response APIErrorResponse
|
||||
var response ListMultipartUploadsResponse
|
||||
if err := xml.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusServiceUnavailable || response.Code != "SlowDown" {
|
||||
t.Fatalf("admission returned %d %s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK || len(response.Uploads) != 0 {
|
||||
t.Fatalf("diagnostic admission blocked public listing: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,12 +748,12 @@ func TestMultipartListingPreflightMinorityLegacy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
report, err := z.multipartPreflight(t.Context())
|
||||
if err != nil || report.Ready || !report.Complete || report.LegacyUploads != 1 {
|
||||
if err != nil || report.Ready || !report.Complete || report.LegacyUploads != 1 || report.Mode != "legacy" {
|
||||
t.Fatalf("minority legacy copy must prevent readiness: %+v %v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingCancellationRetainsAdmission(t *testing.T) {
|
||||
func TestMultipartDiagnosticCancellationRetainsAdmission(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
for i := range 40 {
|
||||
if _, err := z.NewMultipartUpload(t.Context(), bucket, fmt.Sprintf("key-%02d", i), ObjectOptions{}); err != nil {
|
||||
@@ -787,7 +792,7 @@ func TestMultipartListingCancellationRetainsAdmission(t *testing.T) {
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := z.ListMultipartUploads(ctx, bucket, "", "", "", "", 10)
|
||||
_, err := z.scanMultipartUploadsForTest(ctx, bucket, "", "", "", "", 10)
|
||||
done <- err
|
||||
}()
|
||||
for range 16 {
|
||||
|
||||
@@ -12,41 +12,13 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/config/api"
|
||||
"github.com/minio/minio/internal/config/storageclass"
|
||||
"github.com/minio/minio/internal/dsync"
|
||||
"github.com/minio/minio/internal/grid"
|
||||
xnet "github.com/pgsty/silo-pkg/v3/net"
|
||||
)
|
||||
|
||||
func setMultipartListingTestMode(t *testing.T, legacy bool) {
|
||||
t.Helper()
|
||||
globalAPIConfig.mu.Lock()
|
||||
previous := globalAPIConfig.multipartListingStrict
|
||||
globalAPIConfig.multipartListingStrict = !legacy
|
||||
globalAPIConfig.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
globalAPIConfig.mu.Lock()
|
||||
globalAPIConfig.multipartListingStrict = previous
|
||||
globalAPIConfig.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestMultipartListingDefaultMode(t *testing.T) {
|
||||
var uninitialized apiConfig
|
||||
if !uninitialized.getMultipartListingLegacy() {
|
||||
t.Fatal("runtime zero value enabled strict mode before config initialization")
|
||||
}
|
||||
for _, mode := range []string{"", "legacy", "strict"} {
|
||||
var local apiConfig
|
||||
local.init(api.Config{RequestsMax: 1, MultipartListing: mode}, []int{4}, false)
|
||||
if local.getMultipartListingLegacy() != (mode != "strict") {
|
||||
t.Fatalf("unexpected mode for %q", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortLegacyAvailability(t *testing.T) {
|
||||
func TestMultipartAbortDefaultAvailability(t *testing.T) {
|
||||
for _, drives := range []int{4, 16} {
|
||||
for _, offline := range []int{0, 1, drives / 2} {
|
||||
t.Run(fmt.Sprintf("drives=%d/offline=%d", drives, offline), func(t *testing.T) {
|
||||
@@ -56,7 +28,7 @@ func TestMultipartAbortLegacyAvailability(t *testing.T) {
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||
setMultipartListingTestMode(t, true)
|
||||
|
||||
previous := globalStorageClass
|
||||
globalStorageClass.Update(storageclass.Config{Standard: storageclass.StorageClass{Parity: drives / 2}})
|
||||
t.Cleanup(func() { globalStorageClass.Update(previous) })
|
||||
@@ -91,11 +63,11 @@ func TestMultipartAbortLegacyAvailability(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortLegacyPoolOrder(t *testing.T) {
|
||||
func TestMultipartAbortDefaultPoolOrder(t *testing.T) {
|
||||
for _, owner := range []int{0, 1} {
|
||||
t.Run(fmt.Sprintf("owner=%d", owner), func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
setMultipartListingTestMode(t, true)
|
||||
|
||||
mp, err := z.serverPools[owner].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -106,7 +78,7 @@ func TestMultipartAbortLegacyPoolOrder(t *testing.T) {
|
||||
emptySet.getDisks = func() []StorageAPI { return make([]StorageAPI, emptySet.setDriveCount) }
|
||||
err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{})
|
||||
if owner == 0 && err != nil {
|
||||
t.Fatalf("unrelated later pool blocked legacy cancellation: %v", err)
|
||||
t.Fatalf("unrelated later pool blocked cancellation: %v", err)
|
||||
}
|
||||
if owner == 1 {
|
||||
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
||||
@@ -124,123 +96,56 @@ func TestMultipartAbortLegacyPoolOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortMinorityLegacyCleanup(t *testing.T) {
|
||||
for _, failDelete := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("delete-fails=%v", failDelete), func(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "old", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := set.getUploadIDDir(bucket, "old", mp.UploadID)
|
||||
original := set.getDisks
|
||||
disks := original()
|
||||
fi, err := disks[0].ReadVersion(t.Context(), bucket, minioMetaMultipartBucket, path, "", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(fi.Metadata, multipartMetaBucket)
|
||||
delete(fi.Metadata, multipartMetaObject)
|
||||
if err := disks[0].WriteMetadata(t.Context(), bucket, minioMetaMultipartBucket, path, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range disks[1:] {
|
||||
if err := d.Delete(t.Context(), minioMetaMultipartBucket, path, DeleteOptions{Recursive: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
report, err := z.multipartPreflight(t.Context())
|
||||
if err != nil || report.Ready || report.LegacyUploads != 1 {
|
||||
t.Fatalf("invalid legacy fixture: %+v %v", report, err)
|
||||
}
|
||||
if failDelete {
|
||||
set.getDisks = func() []StorageAPI {
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
wrapped[0] = multipartListingFaultDisk{StorageAPI: disks[0], delete: func(context.Context, string, string, DeleteOptions) error { return errFaultyDisk }}
|
||||
return wrapped
|
||||
}
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
err := z.AbortMultipartUpload(t.Context(), bucket, "old", mp.UploadID, ObjectOptions{})
|
||||
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
||||
t.Fatalf("empty drives masked failed deletion of the observed replica: %v", err)
|
||||
}
|
||||
if _, present := z.mpCache.Load(mp.UploadID); !present {
|
||||
t.Fatal("failed cancellation evicted upload from cache")
|
||||
}
|
||||
set.getDisks = original
|
||||
}
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "old", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err = z.multipartPreflight(t.Context())
|
||||
if err != nil || !report.Ready || report.LegacyUploads != 0 {
|
||||
t.Fatalf("acknowledged cleanup left online legacy remnants: %+v %v", report, err)
|
||||
}
|
||||
if _, present := z.mpCache.Load(mp.UploadID); present {
|
||||
t.Fatal("successful cancellation retained cache entry")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortWrongTargetKeepsCache(t *testing.T) {
|
||||
for _, legacy := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("legacy=%v", legacy), func(t *testing.T) {
|
||||
z, _, bucket := multipartListingFixture(t)
|
||||
setMultipartListingTestMode(t, legacy)
|
||||
const other = "multipart-other-bucket"
|
||||
if err := z.MakeBucket(t.Context(), other, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "valid-key", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, target := range [][2]string{{bucket, "wrong-key"}, {other, "valid-key"}} {
|
||||
err := z.AbortMultipartUpload(t.Context(), target[0], target[1], mp.UploadID, ObjectOptions{})
|
||||
var invalid InvalidUploadID
|
||||
if !errors.As(err, &invalid) {
|
||||
t.Fatalf("wrong target result: %v", err)
|
||||
}
|
||||
if _, present := z.mpCache.Load(mp.UploadID); !present {
|
||||
t.Fatal("wrong-target abort evicted another upload")
|
||||
}
|
||||
if _, err := z.GetMultipartInfo(t.Context(), bucket, "valid-key", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("wrong-target abort harmed valid upload: %v", err)
|
||||
}
|
||||
}
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "valid-key", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var invalid InvalidUploadID
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "valid-key", mp.UploadID, ObjectOptions{}); !errors.As(err, &invalid) {
|
||||
t.Fatalf("repeat abort: %v", err)
|
||||
}
|
||||
})
|
||||
z, _, bucket := multipartListingFixture(t)
|
||||
|
||||
const other = "multipart-other-bucket"
|
||||
if err := z.MakeBucket(t.Context(), other, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "valid-key", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, target := range [][2]string{{bucket, "wrong-key"}, {other, "valid-key"}} {
|
||||
err := z.AbortMultipartUpload(t.Context(), target[0], target[1], mp.UploadID, ObjectOptions{})
|
||||
var invalid InvalidUploadID
|
||||
if !errors.As(err, &invalid) {
|
||||
t.Fatalf("wrong target result: %v", err)
|
||||
}
|
||||
if _, present := z.mpCache.Load(mp.UploadID); !present {
|
||||
t.Fatal("wrong-target abort evicted another upload")
|
||||
}
|
||||
if _, err := z.GetMultipartInfo(t.Context(), bucket, "valid-key", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("wrong-target abort harmed valid upload: %v", err)
|
||||
}
|
||||
}
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "valid-key", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var invalid InvalidUploadID
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "valid-key", mp.UploadID, ObjectOptions{}); !errors.As(err, &invalid) {
|
||||
t.Fatalf("repeat abort: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortRejectsUnsafeID(t *testing.T) {
|
||||
for _, legacy := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("legacy=%v", legacy), func(t *testing.T) {
|
||||
z, _, bucket := multipartListingFixture(t)
|
||||
setMultipartListingTestMode(t, legacy)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "keep", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, suffix := range []string{".", "..", "../other", "a/b", "a\\b", ""} {
|
||||
id := base64.RawURLEncoding.EncodeToString([]byte("deployment." + suffix))
|
||||
err := z.AbortMultipartUpload(t.Context(), bucket, "keep", id, ObjectOptions{})
|
||||
var invalid InvalidUploadID
|
||||
if !errors.As(err, &invalid) {
|
||||
t.Fatalf("unsafe ID accepted: suffix=%q err=%v", suffix, err)
|
||||
}
|
||||
if _, err := z.GetMultipartInfo(t.Context(), bucket, "keep", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("valid upload harmed by rejected ID: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
z, _, bucket := multipartListingFixture(t)
|
||||
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "keep", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, suffix := range []string{".", "..", "../other", "a/b", "a\\b", ""} {
|
||||
id := base64.RawURLEncoding.EncodeToString([]byte("deployment." + suffix))
|
||||
err := z.AbortMultipartUpload(t.Context(), bucket, "keep", id, ObjectOptions{})
|
||||
var invalid InvalidUploadID
|
||||
if !errors.As(err, &invalid) {
|
||||
t.Fatalf("unsafe ID accepted: suffix=%q err=%v", suffix, err)
|
||||
}
|
||||
if _, err := z.GetMultipartInfo(t.Context(), bucket, "keep", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("valid upload harmed by rejected ID: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,34 +180,30 @@ func TestMultipartAbortPeerNotification(t *testing.T) {
|
||||
set.nsMutex = newNSLock(true)
|
||||
set.getLockers = func() ([]dsync.NetLocker, string) { return lockers, "multipart-test" }
|
||||
t.Cleanup(func() { set.nsMutex, set.getLockers = oldMutex, oldLockers })
|
||||
for _, legacy := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("legacy=%v", legacy), func(t *testing.T) {
|
||||
setMultipartListingTestMode(t, legacy)
|
||||
for range 4 {
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "valid", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := notifications.Load()
|
||||
var invalid InvalidUploadID
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "wrong", mp.UploadID, ObjectOptions{}); !errors.As(err, &invalid) {
|
||||
t.Fatalf("wrong target: %v", err)
|
||||
}
|
||||
if notifications.Load() != before {
|
||||
t.Fatal("wrong-target abort sent a destructive peer notification")
|
||||
}
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "valid", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notifications.Load() != before+1 {
|
||||
t.Fatal("successful abort lost peer notification after unlocking")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for range 4 {
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "valid", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := notifications.Load()
|
||||
var invalid InvalidUploadID
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "wrong", mp.UploadID, ObjectOptions{}); !errors.As(err, &invalid) {
|
||||
t.Fatalf("wrong target: %v", err)
|
||||
}
|
||||
if notifications.Load() != before {
|
||||
t.Fatal("wrong-target abort sent a destructive peer notification")
|
||||
}
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "valid", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notifications.Load() != before+1 {
|
||||
t.Fatal("successful abort lost peer notification after unlocking")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortStrictMajority(t *testing.T) {
|
||||
func TestMultipartAbortBestEffortCleanup(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
@@ -317,15 +218,19 @@ func TestMultipartAbortStrictMajority(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("ordinary majority cancellation was tightened: %v", err)
|
||||
t.Fatalf("read-quorum cancellation was tightened: %v", err)
|
||||
}
|
||||
// One failed deletion is tolerated in the ordinary majority path. Retrying
|
||||
// after recovery must also clean the now-minority remnant.
|
||||
// Best-effort deletion may leave a remnant. Retrying after recovery cleans
|
||||
// it, but the read quorum no longer recognizes it as an active upload.
|
||||
if _, err := disks[0].ReadVersion(t.Context(), bucket, minioMetaMultipartBucket, set.getUploadIDDir(bucket, "a", mp.UploadID), "", ReadOptions{}); err != nil {
|
||||
t.Fatalf("fault fixture did not leave a remnant: %v", err)
|
||||
}
|
||||
set.getDisks = original
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
var invalid InvalidUploadID
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); !errors.As(err, &invalid) {
|
||||
t.Fatalf("minority retry must preserve default absent-upload result: %v", err)
|
||||
}
|
||||
if _, err := disks[0].ReadVersion(t.Context(), bucket, minioMetaMultipartBucket, set.getUploadIDDir(bucket, "a", mp.UploadID), "", ReadOptions{}); !errors.Is(err, errFileNotFound) {
|
||||
t.Fatalf("best-effort retry did not clean remnant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) 2026 Ruohang Feng
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type multipartRootScanGuard struct {
|
||||
StorageAPI
|
||||
roots *atomic.Int64
|
||||
}
|
||||
|
||||
func (d multipartRootScanGuard) ListDir(ctx context.Context, original, volume, dir string, count int) ([]string, error) {
|
||||
if volume == minioMetaMultipartBucket && dir == "" {
|
||||
d.roots.Add(1)
|
||||
return nil, errFaultyDisk
|
||||
}
|
||||
return d.StorageAPI.ListDir(ctx, original, volume, dir, count)
|
||||
}
|
||||
|
||||
// A busy or unreadable diagnostic namespace must not become a prerequisite for
|
||||
// ordinary listing, including the lower-level exact-object entry points.
|
||||
func TestMultipartPublicListingIndependentOfDiagnostics(t *testing.T) {
|
||||
for _, busy := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "unreadable-root", true: "busy-diagnostics"}[busy], func(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads"}, MakeBucketOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "dir/known"
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, key, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var roots atomic.Int64
|
||||
for _, pool := range z.serverPools {
|
||||
for _, set := range pool.sets {
|
||||
original := set.getDisks
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i, disk := range disks {
|
||||
if disk != nil {
|
||||
disks[i] = multipartRootScanGuard{disk, &roots}
|
||||
}
|
||||
}
|
||||
return disks
|
||||
}
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
}
|
||||
}
|
||||
if busy {
|
||||
for range cap(multipartScanSlots) {
|
||||
scan, err := startMultipartScan(t.Context(), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer scan.close()
|
||||
}
|
||||
}
|
||||
for _, prefix := range []string{"", key, "dir/"} {
|
||||
req, err := newTestSignedRequestV4(http.MethodGet,
|
||||
getListMultipartUploadsURLWithParams("", bucket, prefix, "", "", "", "1"),
|
||||
0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("prefix %q: HTTP %d %s", prefix, rec.Code, rec.Body.String())
|
||||
}
|
||||
var got ListMultipartUploadsResponse
|
||||
if err := xml.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prefix == "dir/" {
|
||||
if len(got.Uploads) != 0 {
|
||||
t.Fatalf("partial prefix must retain the documented exact-key limitation: %+v", got)
|
||||
}
|
||||
} else if len(got.Uploads) != 1 || got.Uploads[0].UploadID != mp.UploadID {
|
||||
t.Fatalf("known upload missing: %+v", got)
|
||||
}
|
||||
}
|
||||
for _, pool := range z.serverPools {
|
||||
for _, list := range []func(context.Context, string, string, string, string, string, int) (ListMultipartsInfo, error){
|
||||
pool.ListMultipartUploads, pool.getHashedSet(key).ListMultipartUploads,
|
||||
} {
|
||||
if _, err := list(t.Context(), bucket, key, "", "", "", 10); err != nil {
|
||||
t.Fatalf("lower-level exact listing: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if roots.Load() != 0 {
|
||||
t.Fatalf("public listing attempted %d global multipart scans", roots.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright (c) 2026 Ruohang Feng
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func rewriteMultipartIdentity(t testing.TB, set *erasureObjects, bucket, key, id string, change func(map[string]string)) {
|
||||
t.Helper()
|
||||
fi, metadata, err := set.checkUploadIDExists(t.Context(), bucket, key, id, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range metadata {
|
||||
change(metadata[i].Metadata)
|
||||
}
|
||||
if _, err := writeAllMetadata(t.Context(), set.getDisks(), bucket, minioMetaMultipartBucket,
|
||||
set.getUploadIDDir(bucket, key, id), metadata, fi.WriteQuorum(set.defaultWQuorum())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func removeMultipartIdentity(metadata map[string]string) {
|
||||
delete(metadata, multipartMetaBucket)
|
||||
delete(metadata, multipartMetaObject)
|
||||
}
|
||||
|
||||
// The retained diagnostic scanner handles mixed records independently of a
|
||||
// stale cache. Public HTTP behavior is tested separately.
|
||||
func TestMultipartDiagnosticMixed(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
for _, key := range []string{"dir/old", "dir/a", "dir/sub/b", "outside"} {
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, key, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key == "dir/old" {
|
||||
rewriteMultipartIdentity(t, set, bucket, key, mp.UploadID, removeMultipartIdentity)
|
||||
}
|
||||
}
|
||||
z.mpCache.Clear()
|
||||
z.mpCache.Store("stale", MultipartInfo{Bucket: bucket, Object: "ghost", UploadID: "stale"})
|
||||
list := func(prefix, keyMarker, uploadMarker, delimiter string) ListMultipartUploadsResponse {
|
||||
t.Helper()
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, prefix, keyMarker, uploadMarker, delimiter, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return generateListMultipartUploadsResponse(bucket, got, "")
|
||||
}
|
||||
var keys []string
|
||||
keyMarker, uploadMarker := "", ""
|
||||
for page := 0; ; page++ {
|
||||
if page >= 4 {
|
||||
t.Fatal("pagination did not terminate")
|
||||
}
|
||||
got := list("", keyMarker, uploadMarker, "")
|
||||
if len(got.Uploads) != 1 {
|
||||
t.Fatalf("page size: %+v", got)
|
||||
}
|
||||
keys = append(keys, got.Uploads[0].Key)
|
||||
if !got.IsTruncated {
|
||||
break
|
||||
}
|
||||
keyMarker, uploadMarker = got.NextKeyMarker, got.NextUploadIDMarker
|
||||
}
|
||||
if !slices.Equal(keys, []string{"dir/a", "dir/sub/b", "outside"}) {
|
||||
t.Fatalf("mixed full listing: %v", keys)
|
||||
}
|
||||
first := list("dir/", "", "", "/")
|
||||
if len(first.Uploads) != 1 || first.Uploads[0].Key != "dir/a" || !first.IsTruncated {
|
||||
t.Fatalf("prefix first page: %+v", first)
|
||||
}
|
||||
rest := list("dir/", first.NextKeyMarker, first.NextUploadIDMarker, "/")
|
||||
if len(rest.Uploads) != 0 || rest.IsTruncated || len(rest.CommonPrefixes) != 1 || rest.CommonPrefixes[0].Prefix != "dir/sub/" {
|
||||
t.Fatalf("prefix continuation: %+v", rest)
|
||||
}
|
||||
if got := list("dir/old", "", "", ""); len(got.Uploads) != 0 || got.IsTruncated {
|
||||
t.Fatalf("historical exact-looking prefix: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartDiagnosticInvalidIdentity(t *testing.T) {
|
||||
for _, kind := range []string{"missing-bucket", "missing-object", "empty-bucket", "empty-object", "empty-both", "wrong-hash", "read-failure"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewriteMultipartIdentity(t, set, bucket, "a", mp.UploadID, func(metadata map[string]string) {
|
||||
switch kind {
|
||||
case "missing-bucket":
|
||||
delete(metadata, multipartMetaBucket)
|
||||
case "missing-object":
|
||||
delete(metadata, multipartMetaObject)
|
||||
case "empty-bucket":
|
||||
metadata[multipartMetaBucket] = ""
|
||||
case "empty-object":
|
||||
metadata[multipartMetaObject] = ""
|
||||
case "empty-both":
|
||||
metadata[multipartMetaBucket], metadata[multipartMetaObject] = "", ""
|
||||
case "wrong-hash":
|
||||
metadata[multipartMetaObject] = "wrong-key"
|
||||
case "read-failure":
|
||||
removeMultipartIdentity(metadata)
|
||||
}
|
||||
})
|
||||
if kind == "read-failure" {
|
||||
original := set.getDisks
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i, disk := range disks {
|
||||
disks[i] = multipartListingFaultDisk{StorageAPI: disk, read: func(context.Context, string, string, string, string, ReadOptions) (FileInfo, error) {
|
||||
return FileInfo{}, errFaultyDisk
|
||||
}}
|
||||
}
|
||||
return disks
|
||||
}
|
||||
}
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if err == nil || len(got.Uploads) != 0 || toAPIError(t.Context(), err).HTTPStatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("invalid/unreadable record returned a successful page: %+v %v", got, err)
|
||||
}
|
||||
if kind != "read-failure" && !errors.Is(err, errMultipartListingIdentity) {
|
||||
t.Fatalf("identity error: %v", err)
|
||||
}
|
||||
report, err := z.multipartPreflight(t.Context())
|
||||
if err != nil || report.Complete || report.Ready || report.LegacyUploads != 0 {
|
||||
t.Fatalf("invalid/unreadable record counted as historical: %+v %v", report, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingRestart(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
obj, dirs, err := prepareErasure(ctx, 4)
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { cancel(); obj.Shutdown(context.Background()); removeRoots(dirs) })
|
||||
const bucket, key = "multipart-restart", "dir/new"
|
||||
if err := obj.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mp, err := obj.NewMultipartUpload(t.Context(), bucket, key, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancel()
|
||||
if err := obj.Shutdown(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, _, err := initObjectLayer(t.Context(), mustGetPoolEndpoints(0, dirs...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
obj = reopened
|
||||
if _, cached := obj.(*erasureServerPools).mpCache.Load(mp.UploadID); cached {
|
||||
t.Fatal("restart unexpectedly preserved the upload cache")
|
||||
}
|
||||
for _, prefix := range []string{"", "dir/"} {
|
||||
got, err := obj.ListMultipartUploads(t.Context(), bucket, prefix, "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, got)
|
||||
}
|
||||
got, err := obj.ListMultipartUploads(t.Context(), bucket, key, "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, got, key)
|
||||
if got.Uploads[0].UploadID != mp.UploadID {
|
||||
t.Fatal("restart changed upload identity")
|
||||
}
|
||||
part, err := obj.PutObjectPart(t.Context(), bucket, key, mp.UploadID, 1,
|
||||
mustGetPutObjReader(t, bytes.NewBufferString("part"), 4, "", ""), ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("continue known upload after restart: %v", err)
|
||||
}
|
||||
if _, err := obj.CompleteMultipartUpload(t.Context(), bucket, key, mp.UploadID,
|
||||
[]CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("complete known upload after restart: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartDiagnosticConflictingIdentityCopies(t *testing.T) {
|
||||
for _, oldCopies := range []int{1, 3} {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := set.getUploadIDDir(bucket, "a", mp.UploadID)
|
||||
for _, disk := range set.getDisks()[:oldCopies] {
|
||||
fi, err := disk.ReadVersion(t.Context(), bucket, minioMetaMultipartBucket, path, "", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
removeMultipartIdentity(fi.Metadata)
|
||||
if err := disk.WriteMetadata(t.Context(), bucket, minioMetaMultipartBucket, path, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if !errors.Is(err, errMultipartListingIdentity) || len(got.Uploads) != 0 {
|
||||
t.Fatalf("%d contradictory copies returned a successful page: %+v %v", oldCopies, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartDiagnosticUnsupportedStoredID(t *testing.T) {
|
||||
for _, historical := range []bool{false, true} {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldPath := set.getUploadIDDir(bucket, "a", mp.UploadID)
|
||||
unsupportedPath := pathJoin(set.getMultipartSHADir(bucket, "a"), "unsupported-id")
|
||||
for _, disk := range set.getDisks() {
|
||||
fi, err := disk.ReadVersion(t.Context(), bucket, minioMetaMultipartBucket, oldPath, "", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if historical {
|
||||
removeMultipartIdentity(fi.Metadata)
|
||||
}
|
||||
if err := disk.WriteMetadata(t.Context(), bucket, minioMetaMultipartBucket, unsupportedPath, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := disk.Delete(t.Context(), minioMetaMultipartBucket, oldPath, DeleteOptions{Recursive: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if historical {
|
||||
if err != nil || len(got.Uploads) != 0 {
|
||||
t.Fatalf("unidentified historical ID: %+v %v", got, err)
|
||||
}
|
||||
} else if !errors.Is(err, errMultipartListingIdentity) {
|
||||
t.Fatalf("invalid new ID silently treated as historical: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
-101
@@ -174,34 +174,46 @@ func (er erasureObjects) deleteAll(ctx context.Context, bucket, prefix string) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// extractModTimeFromUploadIDDir extracts creation time from the upload ID directory on disk.
|
||||
// On-disk directory names are of the form <UUID>x<UnixNano>.
|
||||
// Parse the native format first: plaintext UUIDs can also be valid Base64.
|
||||
// Preserve the previous encoded-format parser for compatibility.
|
||||
func extractModTimeFromUploadIDDir(uploadIDDir string) time.Time {
|
||||
if modTime, ok := multipartUploadTime(uploadIDDir); ok {
|
||||
return modTime
|
||||
}
|
||||
if b64, err := base64.RawURLEncoding.DecodeString(uploadIDDir); err == nil {
|
||||
if split := strings.Split(string(b64), "x"); len(split) == 2 {
|
||||
if t, err := strconv.ParseInt(split[1], 10, 64); err == nil {
|
||||
return time.Unix(0, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Remove the old multipart uploads on the given disk.
|
||||
func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk StorageAPI) {
|
||||
drivePath := disk.Endpoint().Path
|
||||
|
||||
readDirFn(pathJoin(drivePath, minioMetaMultipartBucket), func(shaDir string, typ os.FileMode) error {
|
||||
hasSkippedUploads := false
|
||||
readDirFn(pathJoin(drivePath, minioMetaMultipartBucket, shaDir), func(uploadIDDir string, typ os.FileMode) error {
|
||||
uploadIDPath := pathJoin(shaDir, uploadIDDir)
|
||||
var modTime time.Time
|
||||
// Upload IDs are of the form base64_url(<UUID>x<UnixNano>), we can extract the time from the UUID.
|
||||
if b64, err := base64.RawURLEncoding.DecodeString(uploadIDDir); err == nil {
|
||||
if split := strings.Split(string(b64), "x"); len(split) == 2 {
|
||||
t, err := strconv.ParseInt(split[1], 10, 64)
|
||||
if err == nil {
|
||||
modTime = time.Unix(0, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
modTime := extractModTimeFromUploadIDDir(uploadIDDir)
|
||||
// Fallback for older uploads without time in the ID.
|
||||
if modTime.IsZero() {
|
||||
wait := deleteMultipartCleanupSleeper.Timer(ctx)
|
||||
fi, err := disk.ReadVersion(ctx, "", minioMetaMultipartBucket, uploadIDPath, "", ReadOptions{})
|
||||
if err != nil {
|
||||
hasSkippedUploads = true
|
||||
return nil
|
||||
}
|
||||
modTime = fi.ModTime
|
||||
wait()
|
||||
}
|
||||
if time.Since(modTime) < globalAPIConfig.getStaleUploadsExpiry() {
|
||||
hasSkippedUploads = true
|
||||
return nil
|
||||
}
|
||||
w := xioutil.NewDeadlineWorker(globalDriveConfig.GetMaxTimeout())
|
||||
@@ -214,24 +226,15 @@ func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk Sto
|
||||
return nil
|
||||
})
|
||||
})
|
||||
// Get the modtime of the shaDir.
|
||||
vi, err := disk.StatVol(ctx, pathJoin(minioMetaMultipartBucket, shaDir))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Modtime is returned in the Created field. See (*xlStorage).StatVol
|
||||
if time.Since(vi.Created) < globalAPIConfig.getStaleUploadsExpiry() {
|
||||
if hasSkippedUploads {
|
||||
return nil
|
||||
}
|
||||
w := xioutil.NewDeadlineWorker(globalDriveConfig.GetMaxTimeout())
|
||||
return w.Run(func() error {
|
||||
wait := deleteMultipartCleanupSleeper.Timer(ctx)
|
||||
pathUUID := mustGetUUID()
|
||||
targetPath := pathJoin(drivePath, minioMetaTmpDeletedBucket, pathUUID)
|
||||
|
||||
// We are not deleting shaDir recursively here, if shaDir is empty
|
||||
// and its older then we can happily delete it.
|
||||
Rename(pathJoin(drivePath, minioMetaMultipartBucket, shaDir), targetPath)
|
||||
// Removing an upload updates the parent's mtime. Remove the parent
|
||||
// immediately if it is empty; other uploads keep it in place.
|
||||
_ = Remove(pathJoin(drivePath, minioMetaMultipartBucket, shaDir))
|
||||
wait()
|
||||
return nil
|
||||
})
|
||||
@@ -405,7 +408,7 @@ func paginateMultipartUploads(uploads []MultipartInfo, prefix, keyMarker, upload
|
||||
}
|
||||
|
||||
// listMultipartUploadsExact preserves the hashed exact-object lookup used by
|
||||
// multipart write placement and by rolling-upgrade legacy mode.
|
||||
// multipart write placement and nonempty public listing prefixes.
|
||||
func (er erasureObjects) listMultipartUploadsExact(ctx context.Context, bucket, object, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (result ListMultipartsInfo, err error) {
|
||||
auditObjectErasureSet(ctx, "ListMultipartUploads", object, &er)
|
||||
|
||||
@@ -513,19 +516,7 @@ func (er erasureObjects) ListMultipartUploads(ctx context.Context, bucket, prefi
|
||||
if err := checkListMultipartArgs(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter); err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
scan, err := startMultipartScan(ctx, false)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
defer scan.close()
|
||||
uploads, legacy, err := er.scanMultipartUploads(scan, bucket, 0, 0)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
if legacy {
|
||||
return ListMultipartsInfo{}, errMultipartListingLegacy
|
||||
}
|
||||
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
|
||||
return er.listMultipartUploadsExact(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
||||
}
|
||||
|
||||
// newMultipartUpload - wrapper for initializing a new multipart
|
||||
@@ -1696,6 +1687,41 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
||||
defer lk.Unlock(lkctx)
|
||||
}
|
||||
|
||||
if opts.RefuseLockedOverwrite {
|
||||
getObjectInfo := er.getObjectInfo
|
||||
if opts.replicaObjectInfo != nil {
|
||||
getObjectInfo = opts.replicaObjectInfo
|
||||
}
|
||||
if err := checkVersionLockedForOverwrite(ctx, bucket, object, fi.VersionID, getObjectInfo, opts.Transition); err != nil {
|
||||
return oi, toObjectErr(err, bucket, object)
|
||||
}
|
||||
}
|
||||
if !opts.ReplicationRequest && !opts.ReplicaLockReconcile && !opts.DataMovement {
|
||||
getObjectInfo := er.getObjectInfo
|
||||
if opts.replicaObjectInfo != nil {
|
||||
getObjectInfo = opts.replicaObjectInfo
|
||||
}
|
||||
if fi.VersionID == "" || fi.VersionID == nullVersionID {
|
||||
if err := checkObjectLockedForOverwrite(ctx, bucket, object, getObjectInfo); err != nil {
|
||||
return oi, toObjectErr(err, bucket, object)
|
||||
}
|
||||
} else {
|
||||
_, gerr := getObjectInfo(ctx, bucket, object, ObjectOptions{
|
||||
VersionID: fi.VersionID,
|
||||
Versioned: opts.Versioned,
|
||||
VersionSuspended: opts.VersionSuspended,
|
||||
NoLock: true,
|
||||
})
|
||||
switch {
|
||||
case gerr == nil, errors.As(gerr, &MethodNotAllowed{}):
|
||||
return oi, toObjectErr(errAuthentication, bucket, object)
|
||||
case isErrVersionNotFound(gerr) || isErrObjectNotFound(gerr):
|
||||
default:
|
||||
return oi, toObjectErr(gerr, bucket, object)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
er.cleanupMultipartPath(ctx, paths...) // cleanup all part.N.meta, and skipped part.N's before final rename().
|
||||
|
||||
defer func() {
|
||||
@@ -1753,11 +1779,9 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
||||
return fi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended), nil
|
||||
}
|
||||
|
||||
// abortMultipartUpload retains read-quorum validation and best-effort cleanup
|
||||
// in legacy mode. Strict mode requires majority deletion acknowledgements and
|
||||
// permits retrying remnants below read quorum. Neither mode fences creation
|
||||
// writes still executing after a storage timeout.
|
||||
func (er erasureObjects) abortMultipartUpload(ctx context.Context, bucket, object, uploadID string, opts ObjectOptions, legacy bool) (bool, error) {
|
||||
// abortMultipartUpload retains read-quorum validation and best-effort cleanup.
|
||||
// It does not fence creation writes still executing after a storage timeout.
|
||||
func (er erasureObjects) abortMultipartUpload(ctx context.Context, bucket, object, uploadID string, opts ObjectOptions) (bool, error) {
|
||||
if !opts.NoAuditLog {
|
||||
auditObjectErasureSet(ctx, "AbortMultipartUpload", object, &er)
|
||||
}
|
||||
@@ -1769,70 +1793,19 @@ func (er erasureObjects) abortMultipartUpload(ctx context.Context, bucket, objec
|
||||
if !ok || internalID == "" || internalID == "." || internalID == ".." || strings.ContainsAny(internalID, "/\\") {
|
||||
return false, InvalidUploadID{Bucket: bucket, Object: object, UploadID: uploadID}
|
||||
}
|
||||
if legacy {
|
||||
// Keep the released read-quorum and best-effort cleanup behavior.
|
||||
// The upload ID safety check above applies to both modes.
|
||||
defer er.deleteAll(ctx, minioMetaMultipartBucket, er.getUploadIDDir(bucket, object, uploadID))
|
||||
_, _, err := er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
||||
err = toObjectErr(err, bucket, object, uploadID)
|
||||
if _, absent := err.(InvalidUploadID); absent {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
disks := er.getDisks()
|
||||
uploadPath := er.getUploadIDDir(bucket, object, uploadID)
|
||||
_, errs := readAllFileInfo(ctx, disks, bucket, minioMetaMultipartBucket, uploadPath, "", false, false)
|
||||
quorum := er.setDriveCount/2 + 1
|
||||
found, absent := false, 0
|
||||
observed := make([]bool, len(disks))
|
||||
for i, err := range errs {
|
||||
switch {
|
||||
case err == nil, errors.Is(err, errFileCorrupt):
|
||||
found = true
|
||||
observed[i] = true
|
||||
case errors.Is(err, errFileNotFound), errors.Is(err, errFileVersionNotFound):
|
||||
absent++
|
||||
}
|
||||
}
|
||||
if absent >= quorum && !found {
|
||||
defer er.deleteAll(ctx, minioMetaMultipartBucket, er.getUploadIDDir(bucket, object, uploadID))
|
||||
_, _, err = er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
||||
err = toObjectErr(err, bucket, object, uploadID)
|
||||
if _, absent := err.(InvalidUploadID); absent {
|
||||
return false, nil
|
||||
}
|
||||
if !found {
|
||||
return false, toObjectErr(errErasureReadQuorum, bucket, object, uploadID)
|
||||
}
|
||||
g := errgroup.WithNErrs(len(disks))
|
||||
for i, disk := range disks {
|
||||
g.Go(func() error {
|
||||
if disk == nil {
|
||||
return errDiskNotFound
|
||||
}
|
||||
err := disk.Delete(ctx, minioMetaMultipartBucket, uploadPath, DeleteOptions{Recursive: true, Immediate: false})
|
||||
if errors.Is(err, errFileNotFound) || errors.Is(err, errFileVersionNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}, i)
|
||||
}
|
||||
deleteErrs := g.Wait()
|
||||
if err := reduceWriteQuorumErrs(ctx, deleteErrs, nil, quorum); err != nil {
|
||||
return true, toObjectErr(err, bucket, object, uploadID)
|
||||
}
|
||||
if absent >= quorum {
|
||||
// Missing disks must not mask a failed cleanup of known remnants.
|
||||
for i, found := range observed {
|
||||
if found && deleteErrs[i] != nil {
|
||||
return true, toObjectErr(errErasureWriteQuorum, bucket, object, uploadID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// AbortMultipartUpload cancels an upload using the configured mode. Offline
|
||||
// part data may still need stale-upload cleanup after its drives return.
|
||||
// AbortMultipartUpload cancels an upload using read-quorum validation.
|
||||
// Offline part data may still need stale-upload cleanup after its drives return.
|
||||
func (er erasureObjects) AbortMultipartUpload(ctx context.Context, bucket, object, uploadID string, opts ObjectOptions) (err error) {
|
||||
found, err := er.abortMultipartUpload(ctx, bucket, object, uploadID, opts, globalAPIConfig.getMultipartListingLegacy())
|
||||
found, err := er.abortMultipartUpload(ctx, bucket, object, uploadID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+212
-106
@@ -45,7 +45,6 @@ import (
|
||||
"github.com/minio/minio/internal/crypto"
|
||||
"github.com/minio/minio/internal/event"
|
||||
"github.com/minio/minio/internal/grid"
|
||||
"github.com/minio/minio/internal/hash"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
xioutil "github.com/minio/minio/internal/ioutil"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
@@ -150,6 +149,17 @@ func (er erasureObjects) CopyObject(ctx context.Context, srcBucket, srcObject, d
|
||||
}
|
||||
}
|
||||
|
||||
if !dstOpts.ReplicationRequest && !dstOpts.ReplicaLockReconcile && !dstOpts.DataMovement &&
|
||||
(versionID == "" || versionID == nullVersionID) {
|
||||
getObjectInfo := er.getObjectInfo
|
||||
if dstOpts.replicaObjectInfo != nil {
|
||||
getObjectInfo = dstOpts.replicaObjectInfo
|
||||
}
|
||||
if err := checkObjectLockedForOverwrite(ctx, dstBucket, dstObject, getObjectInfo); err != nil {
|
||||
return oi, toObjectErr(err, dstBucket, dstObject)
|
||||
}
|
||||
}
|
||||
|
||||
modTime = UTCNow() // We only preserve modTime if dstOpts.MTime is true.
|
||||
// in all other cases mtime is latest.
|
||||
|
||||
@@ -498,6 +508,11 @@ func joinErrs(errs []error) string {
|
||||
}
|
||||
|
||||
func (er erasureObjects) deleteIfDangling(ctx context.Context, bucket, object string, metaArr []FileInfo, errs []error, dataErrsByPart map[int][]int, opts ObjectOptions) (FileInfo, error) {
|
||||
// Restore and restored-copy cleanup must preserve the version on read
|
||||
// failure, including lookups and failure cleanup that reread metadata.
|
||||
if opts.Transition.ExpireRestored || opts.Transition.RestoreRequest != nil {
|
||||
return FileInfo{}, errErasureReadQuorum
|
||||
}
|
||||
m, ok := isObjectDangling(metaArr, errs, dataErrsByPart)
|
||||
if !ok {
|
||||
// We only come here if we cannot figure out if the object
|
||||
@@ -645,9 +660,15 @@ func pickLatestQuorumFilesInfo(ctx context.Context, rawFileInfos []RawFileInfo,
|
||||
meta := &xlMetaV2{versions: mergeXLV2Versions(readQuorum, false, 1, metadataShallowVersions...)}
|
||||
lfi, err := meta.ToFileInfo(bucket, object, "", inclFreeVers, true)
|
||||
if err != nil {
|
||||
// No version in quorum proves absence only when every drive answered.
|
||||
// Otherwise a drive holding a version keeps the read retryable.
|
||||
partial := errors.Is(err, errFileNotFound) && !allDrivesAnswered(errs)
|
||||
for i := range errs {
|
||||
if errs[i] == nil {
|
||||
errs[i] = err
|
||||
if partial && hasVersion(metadataArray[i], inclFreeVers) {
|
||||
errs[i] = errErasureReadQuorum
|
||||
}
|
||||
}
|
||||
}
|
||||
return metaFileInfos, errs
|
||||
@@ -691,6 +712,33 @@ func pickLatestQuorumFilesInfo(ctx context.Context, rawFileInfos []RawFileInfo,
|
||||
return metaFileInfos, errs
|
||||
}
|
||||
|
||||
// allDrivesAnswered reports whether every drive returned its metadata or a
|
||||
// definitive answer that it has none. Offline drives keep errDiskOngoingReq
|
||||
// in getObjectFileInfo; unreachable ones return errDiskNotFound or a
|
||||
// transport error.
|
||||
func allDrivesAnswered(errs []error) bool {
|
||||
for _, err := range errs {
|
||||
if err != nil && !IsErr(err, errFileNotFound, errFileVersionNotFound, errFileCorrupt, errVolumeNotFound) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hasVersion reports whether a drive's xl.meta holds a version a read can
|
||||
// return.
|
||||
func hasVersion(xl *xlMetaV2, inclFreeVers bool) bool {
|
||||
if xl == nil {
|
||||
return false
|
||||
}
|
||||
for _, ver := range xl.versions {
|
||||
if inclFreeVers || !ver.header.FreeVersion() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Checking if an object is dangling costs some IOPS; hence implementing this function
|
||||
// which decides which condition it is useful to check if an object is dangling
|
||||
//
|
||||
@@ -718,6 +766,57 @@ func readAllXL(ctx context.Context, disks []StorageAPI, bucket, object string, r
|
||||
return pickLatestQuorumFilesInfo(ctx, rawFileInfos, errs, bucket, object, readData, inclFreeVers)
|
||||
}
|
||||
|
||||
// readListingFileInfo is the read-only, latest-version metadata path for listing
|
||||
// recovery. Ordinary GET keeps its fast-read, healing and dangling semantics.
|
||||
// Absence is proved before decoding can replace a native storage error: every
|
||||
// configured slot must have returned FileNotFound in this read.
|
||||
func (er erasureObjects) readListingFileInfo(ctx context.Context, bucket, object string) (FileInfo, bool, error) {
|
||||
disks := er.getDisks()
|
||||
if len(disks) == 0 || len(disks) != er.setDriveCount {
|
||||
return FileInfo{}, false, listingReadQuorumError(bucket, object)
|
||||
}
|
||||
// An offline slot is not evidence of absence, even if a test or transport
|
||||
// implementation could return a cached not-found response for it.
|
||||
online := append([]StorageAPI(nil), disks...)
|
||||
for i, disk := range online {
|
||||
if disk != nil && !disk.IsOnline() {
|
||||
online[i] = nil
|
||||
}
|
||||
}
|
||||
raw, errs := readAllRawFileInfo(ctx, online, bucket, object, false)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return FileInfo{}, false, err
|
||||
}
|
||||
absent := true
|
||||
for _, err := range errs {
|
||||
absent = absent && err == errFileNotFound
|
||||
}
|
||||
if absent {
|
||||
return FileInfo{}, true, nil
|
||||
}
|
||||
|
||||
// Keep the same latest-version and actual object parity quorum as GET,
|
||||
// without its opportunistic dangling deletion or generic NotFound mapping.
|
||||
meta, errs := pickLatestQuorumFilesInfo(ctx, raw, errs, bucket, object, false, false)
|
||||
quorum, _, err := objectQuorumFromMeta(ctx, meta, errs, er.defaultParityCount)
|
||||
if err == nil {
|
||||
err = reduceReadQuorumErrs(ctx, errs, objectOpIgnoredErrs, quorum)
|
||||
}
|
||||
if err != nil {
|
||||
return FileInfo{}, false, listingReadQuorumError(bucket, object)
|
||||
}
|
||||
_, modTime, etag := listOnlineDisks(disks, meta, errs, quorum)
|
||||
fi, err := pickValidFileInfo(ctx, meta, modTime, etag, quorum)
|
||||
if err != nil || (!fi.Deleted && len(fi.Erasure.Distribution) != len(disks)) {
|
||||
return FileInfo{}, false, listingReadQuorumError(bucket, object)
|
||||
}
|
||||
return fi, false, nil
|
||||
}
|
||||
|
||||
func listingReadQuorumError(bucket, object string) error {
|
||||
return InsufficientReadQuorum{Bucket: bucket, Object: decodeDirObject(object), Err: errErasureReadQuorum, Type: RQInconsistentMeta}
|
||||
}
|
||||
|
||||
func (er erasureObjects) getObjectFileInfo(ctx context.Context, bucket, object string, opts ObjectOptions, readData bool) (FileInfo, []FileInfo, []StorageAPI, error) {
|
||||
rawArr := make([]RawFileInfo, er.setDriveCount)
|
||||
metaArr := make([]FileInfo, er.setDriveCount)
|
||||
@@ -940,8 +1039,10 @@ func (er erasureObjects) getObjectFileInfo(ctx context.Context, bucket, object s
|
||||
}
|
||||
// when we have insufficient read quorum and inconsistent metadata return
|
||||
// file not found, since we can't possibly have a way to recover this object
|
||||
// anyway.
|
||||
if v, ok := err.(InsufficientReadQuorum); ok && v.Type == RQInconsistentMeta {
|
||||
// anyway. That holds only when every drive answered: a drive that is
|
||||
// offline or not yet reconnected may hold the copies that make quorum,
|
||||
// and a 404 for an existing object is final to clients.
|
||||
if v, ok := err.(InsufficientReadQuorum); ok && v.Type == RQInconsistentMeta && allDrivesAnswered(errs) {
|
||||
if opts.VersionID != "" {
|
||||
err = errFileVersionNotFound
|
||||
} else {
|
||||
@@ -1072,6 +1173,12 @@ func renameData(ctx context.Context, disks []StorageAPI, srcBucket, srcEntry str
|
||||
errs := g.Wait()
|
||||
|
||||
err := reduceWriteQuorumErrs(ctx, errs, objectOpIgnoredErrs, writeQuorum)
|
||||
if errors.Is(err, errCommitMetaMissing) {
|
||||
// Commit metadata went missing on a quorum of drives; the write
|
||||
// did not commit. Return a 503 class error so clients retry
|
||||
// instead of treating the object as absent (404).
|
||||
err = errErasureWriteQuorum
|
||||
}
|
||||
if err != nil {
|
||||
dg := errgroup.WithNErrs(len(disks))
|
||||
for index, nerr := range errs {
|
||||
@@ -1594,6 +1701,26 @@ func (er erasureObjects) putObject(ctx context.Context, bucket string, object st
|
||||
defer lk.Unlock(lkctx)
|
||||
}
|
||||
|
||||
if !opts.ReplicationRequest && !opts.ReplicaLockReconcile && !opts.DataMovement &&
|
||||
(fi.VersionID == "" || fi.VersionID == nullVersionID) {
|
||||
getObjectInfo := er.getObjectInfo
|
||||
if opts.replicaObjectInfo != nil {
|
||||
getObjectInfo = opts.replicaObjectInfo
|
||||
}
|
||||
if err := checkObjectLockedForOverwrite(ctx, bucket, object, getObjectInfo); err != nil {
|
||||
return ObjectInfo{}, toObjectErr(err, bucket, object)
|
||||
}
|
||||
}
|
||||
if opts.RefuseLockedOverwrite {
|
||||
getObjectInfo := er.getObjectInfo
|
||||
if opts.replicaObjectInfo != nil {
|
||||
getObjectInfo = opts.replicaObjectInfo
|
||||
}
|
||||
if err := checkVersionLockedForOverwrite(ctx, bucket, object, fi.VersionID, getObjectInfo, opts.Transition); err != nil {
|
||||
return ObjectInfo{}, toObjectErr(err, bucket, object)
|
||||
}
|
||||
}
|
||||
|
||||
// Rename the successfully written temporary object to final location.
|
||||
onlineDisks, versions, oldDataDir, err := renameData(ctx, onlineDisks, minioMetaTmpBucket, tempObj, partsMetadata, bucket, object, writeQuorum)
|
||||
if err != nil {
|
||||
@@ -1690,6 +1817,13 @@ func (er erasureObjects) deleteObjectVersion(ctx context.Context, bucket, object
|
||||
// into smaller bulks if some object names are found to be duplicated in the delete list, splitting
|
||||
// into smaller bulks will avoid holding twice the write lock of the duplicated object names.
|
||||
func (er erasureObjects) DeleteObjects(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions) ([]DeletedObject, []error) {
|
||||
if opts.DeleteAllVersions {
|
||||
errs := make([]error, len(objects))
|
||||
for i := range errs {
|
||||
errs[i] = errDeleteAllUnsupported
|
||||
}
|
||||
return make([]DeletedObject, len(objects)), errs
|
||||
}
|
||||
if !opts.NoAuditLog {
|
||||
for _, obj := range objects {
|
||||
auditObjectErasureSet(ctx, "DeleteObjects", obj.ObjectName, &er)
|
||||
@@ -1812,6 +1946,23 @@ func (er erasureObjects) DeleteObjects(ctx context.Context, bucket string, objec
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if err == errFileNotFound || err == errFileVersionNotFound {
|
||||
// A retry after partial physical deletion may mix removals
|
||||
// with already-absent replies. Both prove the desired outcome.
|
||||
// Marker creation and replication-state updates still require
|
||||
// successful writes; absence cannot acknowledge those operations.
|
||||
physical := true
|
||||
for _, v := range dedupVersions[i].Versions {
|
||||
if v.Deleted || v.MarkDeleted || v.ReplicationState.ReplicationStatusInternal != "" ||
|
||||
v.ReplicationState.VersionPurgeStatusInternal != "" || v.ReplicationState.ReplicaStatus != "" {
|
||||
physical = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if physical {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, v := range dedupVersions[i].Versions {
|
||||
delObjErrs[index][v.Idx] = err
|
||||
}
|
||||
@@ -1923,6 +2074,9 @@ func (er erasureObjects) deletePrefix(ctx context.Context, bucket, prefix string
|
||||
// any error as it is not necessary for the handler to reply back a
|
||||
// response to the client request.
|
||||
func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string, opts ObjectOptions) (objInfo ObjectInfo, err error) {
|
||||
if opts.DeleteAllVersions {
|
||||
return objInfo, errDeleteAllUnsupported
|
||||
}
|
||||
if !opts.NoAuditLog {
|
||||
auditObjectErasureSet(ctx, "DeleteObject", object, &er)
|
||||
}
|
||||
@@ -1949,6 +2103,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
// expiration attempted on a bucket with no lifecycle
|
||||
// rules shall be rejected.
|
||||
if lc == nil && opts.Expiration.Expire {
|
||||
if !opts.Transition.ExpireRestored {
|
||||
return objInfo, errLifecycleNotApplicable
|
||||
}
|
||||
if opts.VersionID != "" {
|
||||
return objInfo, VersionNotFound{
|
||||
Bucket: bucket,
|
||||
@@ -1993,6 +2150,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
isErr = true
|
||||
}
|
||||
if isErr {
|
||||
if !opts.Transition.ExpireRestored {
|
||||
return goi, errLifecycleNotApplicable
|
||||
}
|
||||
if goi.VersionID != "" {
|
||||
return goi, VersionNotFound{
|
||||
Bucket: bucket,
|
||||
@@ -2011,6 +2171,11 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
return ObjectInfo{}, toObjectErr(er.deletePrefix(ctx, bucket, object), bucket, object)
|
||||
}
|
||||
|
||||
// An ILM version expiry that the pools checked with the versions in all
|
||||
// pools; the set still guards the protection it reads, see
|
||||
// checkedExpiryRefused.
|
||||
checkedExpiry := opts.Expiration.Expire && opts.Expiration.CheckedAllPools && opts.VersionID != "" && !opts.Transition.ExpireRestored
|
||||
|
||||
storageDisks := er.getDisks()
|
||||
versionFound := true
|
||||
objInfo = ObjectInfo{VersionID: opts.VersionID} // version id needed in Delete API response.
|
||||
@@ -2018,7 +2183,10 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
tryDel := false
|
||||
if gerr != nil && goi.Name == "" {
|
||||
if _, ok := gerr.(InsufficientReadQuorum); ok {
|
||||
if opts.Versioned || opts.VersionSuspended || countOnlineDisks(storageDisks) < len(storageDisks)/2+1 {
|
||||
// A restored-copy expiry needs fresh metadata to confirm its target
|
||||
// and deadline, a checked expiry to guard its protection; ordinary
|
||||
// unversioned DELETE may still try a blind delete.
|
||||
if opts.Transition.ExpireRestored || checkedExpiry || opts.Versioned || opts.VersionSuspended || countOnlineDisks(storageDisks) < len(storageDisks)/2+1 {
|
||||
// Add an MRF heal for next time.
|
||||
er.addPartial(bucket, object, opts.VersionID)
|
||||
return objInfo, InsufficientWriteQuorum{}
|
||||
@@ -2038,6 +2206,26 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
}
|
||||
}
|
||||
|
||||
// A queued null restore may now address a replacement delete marker.
|
||||
// Such errors carry ObjectInfo, but never authorize cache cleanup.
|
||||
if opts.Transition.ExpireRestored && (gerr != nil || goi.DeleteMarker) {
|
||||
return objInfo, VersionNotFound{Bucket: bucket, Object: object, VersionID: opts.VersionID}
|
||||
}
|
||||
// A restored copy expires only by its own reason, decided on the
|
||||
// version's authoritative generation: a drive that missed a renewal or a
|
||||
// later POST must not authorize removing the cache, nor drives that
|
||||
// disagree on the restore status. Another lifecycle deletion falling due,
|
||||
// or the all-pools check of an ordinary expiry, does not stand in for it.
|
||||
if opts.Transition.ExpireRestored {
|
||||
gen, err := er.readRestoreGeneration(ctx, bucket, object, opts.VersionID)
|
||||
if err != nil || gen.split || gen.fi.Deleted || lc == nil {
|
||||
return goi, errLifecycleNotApplicable
|
||||
}
|
||||
if !restoredCopyExpires(ctx, *lc, rcfg, replcfg, gen.fi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended)) {
|
||||
return goi, errLifecycleNotApplicable
|
||||
}
|
||||
}
|
||||
|
||||
if opts.EvalMetadataFn != nil {
|
||||
dsc, err := opts.EvalMetadataFn(&goi, gerr)
|
||||
if err != nil {
|
||||
@@ -2055,7 +2243,14 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Expiration.Expire {
|
||||
// A delete marker that names a stored version replaces it.
|
||||
if opts.RefuseLockedOverwrite && opts.DeleteMarker && opts.VersionID != "" {
|
||||
if err := refuseLockedMarkerOverwrite(ctx, bucket, object, opts.VersionID, goi, gerr); err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Expiration.Expire && !opts.Expiration.CheckedAllPools {
|
||||
if gerr == nil {
|
||||
evt := evalActionFromLifecycle(ctx, *lc, rcfg, replcfg, goi)
|
||||
var isErr bool
|
||||
@@ -2066,6 +2261,9 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
isErr = true
|
||||
}
|
||||
if isErr {
|
||||
if !opts.Transition.ExpireRestored {
|
||||
return goi, errLifecycleNotApplicable
|
||||
}
|
||||
if goi.VersionID != "" {
|
||||
return goi, VersionNotFound{
|
||||
Bucket: bucket,
|
||||
@@ -2079,6 +2277,8 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if checkedExpiry && checkedExpiryRefused(*lc, rcfg, replcfg, goi, opts.Expiration.ApprovedProtection) {
|
||||
return goi, errLifecycleNotApplicable
|
||||
}
|
||||
|
||||
// Determine whether to mark object deleted for replication
|
||||
@@ -2116,6 +2316,13 @@ func (er erasureObjects) DeleteObject(ctx context.Context, bucket, object string
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DataMovement && opts.DeleteMarker {
|
||||
// Moving a null marker replaces any older null data/marker. Moving
|
||||
// a UUID marker preserves it on retries instead of purging it.
|
||||
deleteMarker = true
|
||||
markDelete = opts.VersionID != nullVersionID
|
||||
}
|
||||
|
||||
purge := opts.isVersionPurge()
|
||||
if purge {
|
||||
// A delete marker describes the version being removed; it is not an
|
||||
@@ -2518,107 +2725,6 @@ func (er erasureObjects) TransitionObject(ctx context.Context, bucket, object st
|
||||
return err
|
||||
}
|
||||
|
||||
// RestoreTransitionedObject - restore transitioned object content locally on this cluster.
|
||||
// This is similar to PostObjectRestore from AWS GLACIER
|
||||
// storage class. When PostObjectRestore API is called, a temporary copy of the object
|
||||
// is restored locally to the bucket on source cluster until the restore expiry date.
|
||||
// The copy that was transitioned continues to reside in the transitioned tier.
|
||||
func (er erasureObjects) RestoreTransitionedObject(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
||||
return er.restoreTransitionedObject(ctx, bucket, object, opts)
|
||||
}
|
||||
|
||||
// update restore status header in the metadata
|
||||
func (er erasureObjects) updateRestoreMetadata(ctx context.Context, bucket, object string, objInfo ObjectInfo, opts ObjectOptions) error {
|
||||
oi := objInfo.Clone()
|
||||
oi.metadataOnly = true // Perform only metadata updates.
|
||||
|
||||
// allow retry in the case of failure to restore
|
||||
delete(oi.UserDefined, xhttp.AmzRestore)
|
||||
|
||||
if _, err := er.CopyObject(ctx, bucket, object, bucket, object, oi, ObjectOptions{
|
||||
VersionID: oi.VersionID,
|
||||
}, ObjectOptions{
|
||||
VersionID: oi.VersionID,
|
||||
}); err != nil {
|
||||
storageLogIf(ctx, fmt.Errorf("Unable to update transition restore metadata for %s/%s(%s): %s", bucket, object, oi.VersionID, err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreTransitionedObject for multipart object chunks the file stream from remote tier into the same number of parts
|
||||
// as in the xl.meta for this version and rehydrates the part.n into the fi.DataDir for this version as in the xl.meta
|
||||
func (er erasureObjects) restoreTransitionedObject(ctx context.Context, bucket string, object string, opts ObjectOptions) error {
|
||||
setRestoreHeaderFn := func(oi ObjectInfo, rerr error) error {
|
||||
if rerr == nil {
|
||||
return nil // nothing to do; restore object was successful
|
||||
}
|
||||
er.updateRestoreMetadata(ctx, bucket, object, oi, opts)
|
||||
return rerr
|
||||
}
|
||||
var oi ObjectInfo
|
||||
// get the file info on disk for transitioned object
|
||||
actualfi, _, _, err := er.getObjectFileInfo(ctx, bucket, object, opts, false)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, toObjectErr(err, bucket, object))
|
||||
}
|
||||
|
||||
oi = actualfi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended)
|
||||
ropts := putRestoreOpts(bucket, object, opts.Transition.RestoreRequest, oi)
|
||||
if len(oi.Parts) == 1 {
|
||||
var rs *HTTPRangeSpec
|
||||
gr, err := getTransitionedObjectReader(ctx, bucket, object, rs, http.Header{}, oi, opts)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, toObjectErr(err, bucket, object))
|
||||
}
|
||||
defer gr.Close()
|
||||
hashReader, err := hash.NewReader(ctx, gr, gr.ObjInfo.Size, "", "", gr.ObjInfo.Size)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, toObjectErr(err, bucket, object))
|
||||
}
|
||||
pReader := NewPutObjReader(hashReader)
|
||||
_, err = er.PutObject(ctx, bucket, object, pReader, ropts)
|
||||
return setRestoreHeaderFn(oi, toObjectErr(err, bucket, object))
|
||||
}
|
||||
|
||||
res, err := er.NewMultipartUpload(ctx, bucket, object, ropts)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, err)
|
||||
}
|
||||
|
||||
var uploadedParts []CompletePart
|
||||
var rs *HTTPRangeSpec
|
||||
// get reader from the warm backend - note that even in the case of encrypted objects, this stream is still encrypted.
|
||||
gr, err := getTransitionedObjectReader(ctx, bucket, object, rs, http.Header{}, oi, opts)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, err)
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
// rehydrate the parts back on disk as per the original xl.meta prior to transition
|
||||
for _, partInfo := range oi.Parts {
|
||||
hr, err := hash.NewReader(ctx, io.LimitReader(gr, partInfo.Size), partInfo.Size, "", "", partInfo.Size)
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, err)
|
||||
}
|
||||
pInfo, err := er.PutObjectPart(ctx, bucket, object, res.UploadID, partInfo.Number, NewPutObjReader(hr), ObjectOptions{})
|
||||
if err != nil {
|
||||
return setRestoreHeaderFn(oi, err)
|
||||
}
|
||||
if pInfo.Size != partInfo.Size {
|
||||
return setRestoreHeaderFn(oi, InvalidObjectState{Bucket: bucket, Object: object})
|
||||
}
|
||||
uploadedParts = append(uploadedParts, CompletePart{
|
||||
PartNumber: pInfo.PartNumber,
|
||||
ETag: pInfo.ETag,
|
||||
})
|
||||
}
|
||||
_, err = er.CompleteMultipartUpload(ctx, bucket, object, res.UploadID, uploadedParts, ObjectOptions{
|
||||
MTime: oi.ModTime,
|
||||
})
|
||||
return setRestoreHeaderFn(oi, err)
|
||||
}
|
||||
|
||||
// DecomTieredObject - moves tiered object to another pool during decommissioning.
|
||||
func (er erasureObjects) DecomTieredObject(ctx context.Context, bucket, object string, fi FileInfo, opts ObjectOptions) error {
|
||||
if opts.UserDefined == nil {
|
||||
|
||||
@@ -28,10 +28,15 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/config/storageclass"
|
||||
)
|
||||
|
||||
@@ -1302,3 +1307,247 @@ func TestJoinErrs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type stagedDataGoneDisk struct {
|
||||
StorageAPI
|
||||
}
|
||||
|
||||
func (d stagedDataGoneDisk) RenameData(ctx context.Context, srcVolume, srcPath string, fi FileInfo, dstVolume, dstPath string, opts RenameOptions) (RenameDataResp, error) {
|
||||
if err := d.Delete(ctx, srcVolume, srcPath, DeleteOptions{Recursive: true, Immediate: true}); err != nil {
|
||||
return RenameDataResp{}, err
|
||||
}
|
||||
return d.StorageAPI.RenameData(ctx, srcVolume, srcPath, fi, dstVolume, dstPath, opts)
|
||||
}
|
||||
|
||||
// stagedDataGoneDisks makes every drive of the only set lose the staged data
|
||||
// of a write right before RenameData, as when the caller has already given up
|
||||
// and removed it. It returns a function that restores the drives.
|
||||
func stagedDataGoneDisks(z *erasureServerPools) func() {
|
||||
// Swap the drives behind the set's getDisks under the mutex that
|
||||
// getDisks takes, so that background readers do not race the swap.
|
||||
sets := z.serverPools[0]
|
||||
sets.erasureDisksMu.Lock()
|
||||
disks := sets.erasureDisks[0]
|
||||
original := slices.Clone(disks)
|
||||
for i, disk := range disks {
|
||||
disks[i] = stagedDataGoneDisk{StorageAPI: disk}
|
||||
}
|
||||
sets.erasureDisksMu.Unlock()
|
||||
return func() {
|
||||
sets.erasureDisksMu.Lock()
|
||||
copy(sets.erasureDisks[0], original)
|
||||
sets.erasureDisksMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// A write whose staged data is gone before the drives rename it fails. The
|
||||
// object directory the drives create for it must not stay behind: it is
|
||||
// invisible while its parent holds other objects, but once they are deleted
|
||||
// the parent can no longer be removed and is listed as an empty prefix.
|
||||
func TestRenameDataStagedDataGoneLeavesNoPrefix(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
obj, dirs, err := prepareErasure16(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer obj.Shutdown(context.Background())
|
||||
defer removeRoots(dirs)
|
||||
z := obj.(*erasureServerPools)
|
||||
const bucket = "bucket"
|
||||
const sibling, putObject, mpuObject = "day=1/b=9/sibling", "day=1/b=9/put", "day=1/b=9/mpu"
|
||||
if err := z.MakeBucket(ctx, bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := bytes.Repeat([]byte{'a'}, 8<<20) // above the inline limit: data goes to a data dir
|
||||
if _, err := z.PutObject(ctx, bucket, sibling, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := z.NewMultipartUpload(ctx, bucket, mpuObject, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pi, err := z.PutObjectPart(ctx, bucket, mpuObject, res.UploadID, 1, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restore := stagedDataGoneDisks(z)
|
||||
if _, err := z.PutObject(ctx, bucket, putObject, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{}); err == nil {
|
||||
t.Error("PutObject succeeded without its staged data")
|
||||
}
|
||||
if _, err := z.CompleteMultipartUpload(ctx, bucket, mpuObject, res.UploadID, []CompletePart{{PartNumber: 1, ETag: pi.ETag}}, ObjectOptions{}); err == nil {
|
||||
t.Error("CompleteMultipartUpload succeeded without its staged data")
|
||||
}
|
||||
restore()
|
||||
|
||||
if _, err := z.DeleteObject(ctx, bucket, sibling, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, prefix := range []string{"", "day=1/", "day=1/b=9/"} {
|
||||
res, err := z.ListObjectsV2(ctx, bucket, prefix, "", "/", 1000, false, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Objects) != 0 || len(res.Prefixes) != 0 {
|
||||
t.Errorf("ListObjectsV2 %q: %d objects, prefixes %v, want none", prefix, len(res.Objects), res.Prefixes)
|
||||
}
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if _, err := os.Stat(filepath.Join(dir, bucket, "day=1")); !os.IsNotExist(err) {
|
||||
t.Errorf("%s: day=1 is still on the drive (%v)", dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A failed overwrite whose staged data is gone must keep the existing object.
|
||||
func TestRenameDataStagedDataGoneKeepsExistingObject(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
obj, dirs, err := prepareErasure16(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer obj.Shutdown(context.Background())
|
||||
defer removeRoots(dirs)
|
||||
z := obj.(*erasureServerPools)
|
||||
const bucket, object = "bucket", "day=1/b=9/object"
|
||||
if err := z.MakeBucket(ctx, bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := bytes.Repeat([]byte{'a'}, 8<<20) // above the inline limit: data goes to a data dir
|
||||
if _, err := z.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restore := stagedDataGoneDisks(z)
|
||||
other := bytes.Repeat([]byte{'b'}, 8<<20)
|
||||
if _, err := z.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewReader(other), int64(len(other)), "", ""), ObjectOptions{}); err == nil {
|
||||
t.Error("overwrite succeeded without its staged data")
|
||||
}
|
||||
restore()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := GetObject(ctx, z, bucket, object, 0, int64(len(body)), &buf, "", ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(buf.Bytes(), body) {
|
||||
t.Error("existing object content changed")
|
||||
}
|
||||
}
|
||||
|
||||
// A failed RenameData that runs late removes its object directory while it is
|
||||
// empty. When it does so on a drive between the directory creation and the
|
||||
// rename of another write of the same key, that write must create the
|
||||
// directory again and succeed instead of failing on the drive.
|
||||
func TestRenameDataSurvivesConcurrentEmptyDirCleanup(t *testing.T) {
|
||||
if runtime.GOOS == globalWindowsOSName {
|
||||
t.Skip("directory creation is not traced on Windows")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
size int
|
||||
multipart bool
|
||||
}{
|
||||
{"put", 8 << 20, false},
|
||||
{"inline_put", 1 << 10, false},
|
||||
{"multipart", 8 << 20, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
obj, dirs, err := prepareErasure16(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer obj.Shutdown(context.Background())
|
||||
defer removeRoots(dirs)
|
||||
z := obj.(*erasureServerPools)
|
||||
const bucket, object = "bucket", "day=1/b=9/object"
|
||||
if err := z.MakeBucket(ctx, bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := bytes.Repeat([]byte{'a'}, tc.size)
|
||||
var uploadID, etag string
|
||||
if tc.multipart {
|
||||
res, err := z.NewMultipartUpload(ctx, bucket, object, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pi, err := z.PutObjectPart(ctx, bucket, object, res.UploadID, 1, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uploadID, etag = res.UploadID, pi.ETag
|
||||
}
|
||||
|
||||
drives := make(map[string]*xlStorage)
|
||||
for _, disk := range z.serverPools[0].sets[0].getDisks() {
|
||||
s := disk.(*xlStorageDiskIDCheck).storage
|
||||
drives[filepath.Join(s.drivePath, bucket, filepath.FromSlash(object))] = s
|
||||
}
|
||||
var mu sync.Mutex
|
||||
hit := make(map[string]bool)
|
||||
var hits atomic.Int32
|
||||
var lateErrs []error
|
||||
traceOSCalls(t, func(tr madmin.TraceInfo) {
|
||||
if tr.FuncName != "os.Mkdir" || tr.Error != "" {
|
||||
return
|
||||
}
|
||||
s := drives[tr.Path]
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
first := !hit[tr.Path]
|
||||
hit[tr.Path] = true
|
||||
mu.Unlock()
|
||||
if !first {
|
||||
return
|
||||
}
|
||||
// The write has just created the object directory on this
|
||||
// drive. A late RenameData of the same key, whose staged data
|
||||
// is gone, fails now and removes the empty directory.
|
||||
fi := newFileInfo(object, 8, 8)
|
||||
fi.DataDir, fi.VersionID = mustGetUUID(), mustGetUUID()
|
||||
fi.Erasure.Index = 1
|
||||
fi.ModTime = time.Now().UTC()
|
||||
fi.Size = 1
|
||||
fi.AddObjectPart(1, "", 1, 1, fi.ModTime, nil, nil)
|
||||
_, err := s.RenameData(context.Background(), minioMetaTmpBucket, mustGetUUID(), fi, bucket, object, RenameOptions{})
|
||||
if _, serr := os.Stat(tr.Path); !os.IsNotExist(serr) {
|
||||
err = fmt.Errorf("late RenameData left %s (%v, rename error %v)", tr.Path, serr, err)
|
||||
} else if errors.Is(err, errFileNotFound) {
|
||||
err = nil
|
||||
}
|
||||
mu.Lock()
|
||||
if err != nil {
|
||||
lateErrs = append(lateErrs, err)
|
||||
}
|
||||
mu.Unlock()
|
||||
hits.Add(1)
|
||||
})
|
||||
|
||||
if tc.multipart {
|
||||
_, err = z.CompleteMultipartUpload(ctx, bucket, object, uploadID, []CompletePart{{PartNumber: 1, ETag: etag}}, ObjectOptions{})
|
||||
} else {
|
||||
_, err = z.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{})
|
||||
}
|
||||
if n := hits.Load(); n != int32(len(dirs)) {
|
||||
t.Fatalf("late cleanup ran on %d drives, want %d", n, len(dirs))
|
||||
}
|
||||
mu.Lock()
|
||||
for _, lateErr := range lateErrs {
|
||||
t.Error(lateErr)
|
||||
}
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatalf("write failed after a concurrent cleanup: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := GetObject(ctx, z, bucket, object, 0, int64(len(body)), &buf, "", ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(buf.Bytes(), body) {
|
||||
t.Fatal("object content differs")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/versioning"
|
||||
)
|
||||
|
||||
// downTier is a remote tier that is unreachable, as a warm backend reports a
|
||||
// refused connection after ErrorRespToObjectError.
|
||||
type downTier struct{ *tierExpiryTestBackend }
|
||||
|
||||
func (downTier) Get(context.Context, string, remoteVersionID, WarmBackendGetOpts) (io.ReadCloser, error) {
|
||||
return nil, BackendDown{Err: "dial tcp 127.0.0.1:9710: connect: connection refused"}
|
||||
}
|
||||
|
||||
// partialView is an 8-drive, EC 4+4 erasure set (the layout of the 4-node x
|
||||
// 2-drive acceptance cluster: positions 0,1 = n1; 2,3 = n2; 4,5 = n3;
|
||||
// 6,7 = n4) with an in-memory tier WARM.
|
||||
type partialView struct {
|
||||
*restoreTest
|
||||
er *erasureObjects
|
||||
original func() []StorageAPI
|
||||
all []StorageAPI
|
||||
}
|
||||
|
||||
func newPartialView(t *testing.T, state versioning.State) *partialView {
|
||||
t.Helper()
|
||||
prevTier := globalTierConfigMgr
|
||||
t.Cleanup(func() { globalTierConfigMgr = prevTier })
|
||||
dirs, err := getRandomDisks(8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { removeRoots(dirs) })
|
||||
obj, _, err := initObjectLayer(t.Context(), mustGetPoolEndpoints(0, dirs...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()) })
|
||||
if n := len(z.serverPools[0].sets); n != 1 {
|
||||
t.Fatalf("want one erasure set, got %d", n)
|
||||
}
|
||||
globalTierConfigMgr = NewTierConfigMgr()
|
||||
backend := &tierExpiryTestBackend{objects: map[string][]byte{}}
|
||||
globalTierConfigMgr.drivercache["WARM"] = &restoreTestTier{tierExpiryTestBackend: backend}
|
||||
r := &restoreTest{t: t, z: z, bucket: getRandomBucketName(), object: "t1", labels: map[string]string{}, bodies: map[string][]byte{}}
|
||||
if err := z.MakeBucket(t.Context(), r.bucket, MakeBucketOptions{VersioningEnabled: state == versioning.Enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := globalBucketMetadataSys.Get(r.bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.LifecycleConfigXML = []byte(restoreSafetyRules)
|
||||
if err := meta.Save(t.Context(), z); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(r.bucket, meta)
|
||||
er := z.serverPools[0].getHashedSet(r.object)
|
||||
if er.setDriveCount != 8 || er.defaultParityCount != 4 {
|
||||
t.Fatalf("want EC 4+4 on 8 drives, got %d drives parity %d", er.setDriveCount, er.defaultParityCount)
|
||||
}
|
||||
p := &partialView{restoreTest: r, er: er, original: er.getDisks, all: er.getDisks()}
|
||||
t.Cleanup(func() { er.getDisks = p.original })
|
||||
return p
|
||||
}
|
||||
|
||||
// see makes only the drives at the given positions reachable from the
|
||||
// erasure set, as for a node whose connections to the others are not yet up.
|
||||
func (p *partialView) see(positions ...int) {
|
||||
visible := make([]StorageAPI, len(p.all))
|
||||
for _, i := range positions {
|
||||
visible[i] = p.all[i]
|
||||
}
|
||||
p.er.getDisks = func() []StorageAPI { return visible }
|
||||
}
|
||||
|
||||
func (p *partialView) seeAll() { p.er.getDisks = p.original }
|
||||
|
||||
func (p *partialView) tierDown() {
|
||||
globalTierConfigMgr.drivercache["WARM"] = downTier{&tierExpiryTestBackend{objects: map[string][]byte{}}}
|
||||
}
|
||||
|
||||
// drives describes the version on each drive: R restored, T transitioned
|
||||
// (not restored), L local, - missing.
|
||||
func (p *partialView) drives(versionID string) string {
|
||||
p.t.Helper()
|
||||
infos, errs := readAllFileInfo(p.t.Context(), p.all, "", p.bucket, p.object, restoreTestVersionKey(versionID), false, false)
|
||||
var b bytes.Buffer
|
||||
for i := range infos {
|
||||
switch {
|
||||
case errs[i] != nil:
|
||||
b.WriteByte('-')
|
||||
case infos[i].IsRemote():
|
||||
b.WriteByte('T')
|
||||
case infos[i].TransitionStatus != "":
|
||||
b.WriteByte('R')
|
||||
default:
|
||||
b.WriteByte('L')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (p *partialView) transition(oi ObjectInfo) {
|
||||
p.t.Helper()
|
||||
topts := p.opts(oi.VersionID)
|
||||
topts.MTime = oi.ModTime
|
||||
topts.Transition = TransitionOptions{Tier: "WARM", ETag: oi.ETag}
|
||||
if err := p.z.TransitionObject(p.t.Context(), p.bucket, p.object, topts); err != nil {
|
||||
p.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *partialView) restoreObject(oi ObjectInfo) {
|
||||
p.t.Helper()
|
||||
ropts := p.opts(oi.VersionID)
|
||||
ropts.Transition = TransitionOptions{RestoreRequest: &RestoreObjectRequest{Days: 1}}
|
||||
if err := p.z.serverPools[0].RestoreTransitionedObject(p.t.Context(), p.bucket, p.object, ropts); err != nil {
|
||||
p.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// getAndHead reads the latest version as an S3 GET and HEAD without a
|
||||
// version ID would.
|
||||
func (p *partialView) getAndHead() (data []byte, getErr, headErr error) {
|
||||
opts := p.opts("")
|
||||
gr, getErr := p.z.GetObjectNInfo(p.t.Context(), p.bucket, p.object, nil, nil, opts)
|
||||
if getErr == nil {
|
||||
data, getErr = io.ReadAll(gr)
|
||||
gr.Close()
|
||||
}
|
||||
_, headErr = p.z.GetObjectInfo(p.t.Context(), p.bucket, p.object, opts)
|
||||
return data, getErr, headErr
|
||||
}
|
||||
|
||||
// wantRetryable requires the answer of a read that cannot reach quorum from a
|
||||
// partial view: a read-quorum error, which the API returns as 503 SlowDownRead,
|
||||
// never a 404 for an object that exists.
|
||||
func wantRetryable(t *testing.T, what string, err error) {
|
||||
t.Helper()
|
||||
var rq InsufficientReadQuorum
|
||||
if !errors.As(err, &rq) {
|
||||
t.Errorf("%s: got %T %v, want InsufficientReadQuorum", what, err, err)
|
||||
return
|
||||
}
|
||||
if code := toAPIError(context.Background(), err).HTTPStatusCode; code != http.StatusServiceUnavailable {
|
||||
t.Errorf("%s: HTTP %d, want 503", what, code)
|
||||
}
|
||||
}
|
||||
|
||||
// The acceptance failure: node1 restores t1 while node2's drives are not
|
||||
// reachable, so the restore commits on 6 of 8 drives (write quorum 5). node2
|
||||
// then restarts and reaches only its own drives and node4's (n1 and n3 not yet
|
||||
// reconnected), with the tier down. The 4 visible xl.meta are 2 transitioned
|
||||
// and 2 restored copies of the same version and mod time.
|
||||
func TestPartialViewRestoreSplitIsRetryable(t *testing.T) {
|
||||
for _, state := range []versioning.State{versioning.Enabled, ""} {
|
||||
t.Run(fmt.Sprintf("versioning=%q", state), func(t *testing.T) {
|
||||
p := newPartialView(t, state)
|
||||
oi := p.put("v1", state == "", true)
|
||||
p.transition(oi)
|
||||
p.see(0, 1, 4, 5, 6, 7) // node1's view: n2 not reachable
|
||||
p.restoreObject(oi)
|
||||
p.seeAll()
|
||||
if got, want := p.drives(oi.VersionID), "RRTTRRRR"; got != want {
|
||||
t.Fatalf("drive states %s, want %s", got, want)
|
||||
}
|
||||
|
||||
p.tierDown()
|
||||
p.see(2, 3, 6, 7) // node2's view after its restart
|
||||
_, getErr, headErr := p.getAndHead()
|
||||
wantRetryable(t, "GET", getErr)
|
||||
wantRetryable(t, "HEAD", headErr)
|
||||
|
||||
// Every view that contains a quorum of the restored copy reads it.
|
||||
p.see(0, 1, 4, 5, 6, 7)
|
||||
data, getErr, headErr := p.getAndHead()
|
||||
if getErr != nil || headErr != nil || !bytes.Equal(data, p.bodies["v1"]) {
|
||||
t.Errorf("node1 view: GET err=%v HEAD err=%v bytes=%d", getErr, headErr, len(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The same split, made by a transition instead of a restore: a node that sees
|
||||
// 2 local and 2 transitioned copies.
|
||||
func TestPartialViewTransitionSplitIsRetryable(t *testing.T) {
|
||||
p := newPartialView(t, versioning.Enabled)
|
||||
oi := p.put("v1", false, true)
|
||||
p.see(0, 1, 4, 5, 6, 7)
|
||||
p.transition(oi)
|
||||
p.seeAll()
|
||||
if got, want := p.drives(oi.VersionID), "TTLLTTTT"; got != want {
|
||||
t.Fatalf("drive states %s, want %s", got, want)
|
||||
}
|
||||
p.see(2, 3, 6, 7)
|
||||
_, getErr, headErr := p.getAndHead()
|
||||
wantRetryable(t, "GET", getErr)
|
||||
wantRetryable(t, "HEAD", headErr)
|
||||
}
|
||||
|
||||
// Generic, without tiering: an overwrite committed on 6 of 8 drives, read by
|
||||
// a node that sees 2 old and 2 new copies.
|
||||
func TestPartialViewOverwriteSplit(t *testing.T) {
|
||||
for _, state := range []versioning.State{versioning.Enabled, ""} {
|
||||
t.Run(fmt.Sprintf("versioning=%q", state), func(t *testing.T) {
|
||||
p := newPartialView(t, state)
|
||||
p.put("old", state == "", true)
|
||||
p.see(0, 1, 4, 5, 6, 7)
|
||||
p.put("new", state == "", true)
|
||||
p.seeAll()
|
||||
p.see(2, 3, 6, 7)
|
||||
data, getErr, headErr := p.getAndHead()
|
||||
if state == "" {
|
||||
wantRetryable(t, "GET", getErr)
|
||||
wantRetryable(t, "HEAD", headErr)
|
||||
return
|
||||
}
|
||||
// A versioned overwrite can be served from the older version
|
||||
// through such a view (existing behavior, out of scope here),
|
||||
// but must never be reported missing.
|
||||
for what, err := range map[string]error{"GET": getErr, "HEAD": headErr} {
|
||||
if err != nil {
|
||||
wantRetryable(t, what, err)
|
||||
}
|
||||
}
|
||||
if getErr == nil && !bytes.Equal(data, p.bodies["old"]) && !bytes.Equal(data, p.bodies["new"]) {
|
||||
t.Errorf("GET returned neither version (%d bytes)", len(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Controls: what must not change.
|
||||
func TestPartialViewControls(t *testing.T) {
|
||||
p := newPartialView(t, versioning.Enabled)
|
||||
// A key that never existed is still 404 from a partial view.
|
||||
p.see(2, 3, 6, 7)
|
||||
if _, getErr, headErr := p.getAndHead(); !isErrObjectNotFound(getErr) || !isErrObjectNotFound(headErr) {
|
||||
t.Errorf("absent key: GET err=%v HEAD err=%v, want ObjectNotFound", getErr, headErr)
|
||||
}
|
||||
// A consistent object is readable from exactly a read quorum of drives.
|
||||
p.seeAll()
|
||||
p.put("v1", false, true)
|
||||
p.see(2, 3, 6, 7)
|
||||
if data, getErr, headErr := p.getAndHead(); getErr != nil || headErr != nil || !bytes.Equal(data, p.bodies["v1"]) {
|
||||
t.Errorf("consistent object: GET err=%v HEAD err=%v", getErr, headErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Upstream behavior that must stay: when every drive answered and no
|
||||
// metadata reaches read quorum, the object is reported missing (#20522).
|
||||
func TestAllDrivesAnsweredInconsistentStays404(t *testing.T) {
|
||||
wipe := func(p *partialView, positions ...int) {
|
||||
for _, i := range positions {
|
||||
if err := p.all[i].Delete(t.Context(), p.bucket, p.object, DeleteOptions{Recursive: true, Immediate: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Run("restore", func(t *testing.T) {
|
||||
p := newPartialView(t, versioning.Enabled)
|
||||
oi := p.put("v1", false, true)
|
||||
p.transition(oi)
|
||||
p.see(0, 1, 5, 6, 7)
|
||||
p.restoreObject(oi)
|
||||
p.seeAll()
|
||||
wipe(p, 0, 1)
|
||||
if got, want := p.drives(oi.VersionID), "--TTTRRR"; got != want {
|
||||
t.Fatalf("drive states %s, want %s", got, want)
|
||||
}
|
||||
_, getErr, headErr := p.getAndHead()
|
||||
if !isErrObjectNotFound(getErr) || !isErrObjectNotFound(headErr) {
|
||||
t.Errorf("GET err=%v HEAD err=%v, want ObjectNotFound", getErr, headErr)
|
||||
}
|
||||
})
|
||||
t.Run("unversioned-overwrite", func(t *testing.T) {
|
||||
p := newPartialView(t, "")
|
||||
p.put("old", true, true)
|
||||
p.see(0, 1, 5, 6, 7)
|
||||
p.put("new", true, true)
|
||||
p.seeAll()
|
||||
wipe(p, 0, 1)
|
||||
_, getErr, headErr := p.getAndHead()
|
||||
if !isErrObjectNotFound(getErr) || !isErrObjectNotFound(headErr) {
|
||||
t.Errorf("GET err=%v HEAD err=%v, want ObjectNotFound", getErr, headErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//go:build consistency_strict
|
||||
|
||||
// Preserved diagnostic for the deferred strict-read policy. This is not a
|
||||
// maintenance release gate; failures record intentionally unresolved risks.
|
||||
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
madmin "github.com/minio/madmin-go/v3"
|
||||
)
|
||||
|
||||
// All metadata is valid and readable. Only the completed generations differ.
|
||||
// No timing, missing files, synthetic read error or malformed metadata is needed.
|
||||
func TestReadGenerationConflictIsNotAbsence(t *testing.T) {
|
||||
raw := make([]RawFileInfo, 8)
|
||||
for i := range raw {
|
||||
fi := newFileInfo("object", 4, 4)
|
||||
fi.Erasure.Index = i + 1
|
||||
fi.ModTime = time.Unix(100+int64(i%4), 0)
|
||||
fi.DataDir = mustGetUUID()
|
||||
fi.Metadata = map[string]string{"etag": fmt.Sprint(i % 4)}
|
||||
var meta xlMetaV2
|
||||
if err := meta.AddVersion(fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf, err := meta.AppendTo(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw[i] = RawFileInfo{Buf: buf}
|
||||
}
|
||||
_, errs := pickLatestQuorumFilesInfo(t.Context(), raw, make([]error, len(raw)), "bucket", "object", true, false)
|
||||
err := reduceReadQuorumErrs(t.Context(), errs, objectOpIgnoredErrs, 4)
|
||||
if api := toAPIError(t.Context(), toObjectErr(err, "bucket", "object")); api.HTTPStatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("readable conflicting generations reported as absence: %v (%+v)", err, api)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsReadGenerationConflictDoesNotExposeOldValue(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
router := initTestAPIEndPoints(z, []string{"GetObject", "HeadObject"})
|
||||
for _, tc := range []struct {
|
||||
pool int
|
||||
mode string
|
||||
}{
|
||||
{0, "split"},
|
||||
{1, "split"},
|
||||
{0, "older-quorum"},
|
||||
{1, "older-quorum"},
|
||||
{0, "newer-quorum"},
|
||||
{1, "newer-quorum"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s/pool-%d", tc.mode, tc.pool), func(t *testing.T) {
|
||||
broken := tc.pool
|
||||
object := fmt.Sprintf("conflict-%s-%d", tc.mode, broken)
|
||||
base := UTCNow().Add(-time.Hour)
|
||||
putConsistencyObject(t, z, bucket, object, 1-broken, "old-acknowledged", ObjectOptions{MTime: base})
|
||||
set := z.serverPools[broken].getHashedSet(object)
|
||||
disks := set.getDisks()
|
||||
saved := make([][]byte, len(disks))
|
||||
savedParts := make([]map[string][]byte, len(disks))
|
||||
for generation := range 4 {
|
||||
putConsistencyObject(t, z, bucket, object, broken, fmt.Sprintf("newer-%d", generation),
|
||||
ObjectOptions{MTime: base.Add(time.Duration(generation+1) * time.Minute)})
|
||||
for i, disk := range disks {
|
||||
g := i % 4
|
||||
switch tc.mode {
|
||||
case "older-quorum":
|
||||
g = 0
|
||||
if i >= len(disks)-3 {
|
||||
g = i - (len(disks) - 3) + 1
|
||||
}
|
||||
case "newer-quorum":
|
||||
g = 3
|
||||
if i < 3 {
|
||||
g = i
|
||||
}
|
||||
}
|
||||
if g != generation {
|
||||
continue
|
||||
}
|
||||
buf, err := disk.ReadAll(ctx, bucket, pathJoin(object, xlStorageFormatFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved[i] = bytes.Clone(buf)
|
||||
savedParts[i] = readGenerationParts(t, disk, bucket, object, buf)
|
||||
}
|
||||
}
|
||||
for i, disk := range disks {
|
||||
for path, data := range savedParts[i] {
|
||||
if err := disk.WriteAll(ctx, bucket, path, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := disk.WriteAll(ctx, bucket, pathJoin(object, xlStorageFormatFile), saved[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, version := range []string{"", nullVersionID} {
|
||||
for _, method := range []string{http.MethodHead, http.MethodGet} {
|
||||
rec := consistencyRequest(t, router, method, bucket, object, version)
|
||||
want := http.StatusServiceUnavailable
|
||||
if tc.mode == "newer-quorum" {
|
||||
want = http.StatusOK
|
||||
}
|
||||
if rec.Code != want {
|
||||
t.Errorf("%s version=%q: want status=%d, got=%d body=%q", method, version, want, rec.Code, rec.Body.String())
|
||||
}
|
||||
if want == http.StatusOK && method == http.MethodGet && rec.Body.String() != "newer-3" {
|
||||
t.Errorf("latest quorum did not return its payload: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if tc.mode != "newer-quorum" {
|
||||
for _, version := range []string{"", nullVersionID} {
|
||||
_, err := set.CopyObject(ctx, bucket, object, bucket, object,
|
||||
ObjectInfo{metadataOnly: true, UserDefined: map[string]string{"copy-test": "conflicting-source"}},
|
||||
ObjectOptions{VersionID: version}, ObjectOptions{})
|
||||
if !isErrReadQuorum(err) {
|
||||
t.Errorf("COPY-to-self version=%q: want read-quorum error, got %v", version, err)
|
||||
}
|
||||
for _, scan := range []madmin.HealScanMode{madmin.HealNormalScan, madmin.HealDeepScan} {
|
||||
_, err := set.HealObject(ctx, bucket, object, version, madmin.HealOpts{ScanMode: scan, Remove: true})
|
||||
if !isErrReadQuorum(err) {
|
||||
t.Errorf("heal version=%q scan=%v: want read-quorum error, got %v", version, scan, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, disk := range disks {
|
||||
buf, err := disk.ReadAll(ctx, bucket, pathJoin(object, xlStorageFormatFile))
|
||||
if err != nil || !bytes.Equal(buf, saved[i]) {
|
||||
t.Errorf("read or heal destroyed conflicting generation on disk %d: %v", i, err)
|
||||
}
|
||||
if tc.mode != "newer-quorum" {
|
||||
for path, data := range savedParts[i] {
|
||||
got, err := disk.ReadAll(ctx, bucket, path)
|
||||
if err != nil || !bytes.Equal(got, data) {
|
||||
t.Errorf("read or heal destroyed generation shard on disk %d: %s: %v", i, path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func readGenerationParts(t *testing.T, disk StorageAPI, bucket, object string, metadata []byte) map[string][]byte {
|
||||
t.Helper()
|
||||
fi, err := fileInfoFromRaw(RawFileInfo{Buf: metadata}, bucket, object, false, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := make(map[string][]byte)
|
||||
if fi.InlineData() || fi.Size == 0 {
|
||||
return parts
|
||||
}
|
||||
// Later PUTs remove the previous data directory. Save its real bitrot
|
||||
// shards too, so restoring metadata never fabricates missing-part damage.
|
||||
for _, part := range fi.Parts {
|
||||
path := pathJoin(object, fi.DataDir, fmt.Sprintf("part.%d", part.Number))
|
||||
data, err := disk.ReadAll(t.Context(), bucket, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts[path] = data
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func TestPoolsReadUnavailablePoolDoesNotExposeOldValue(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
for broken := range 2 {
|
||||
t.Run(fmt.Sprintf("broken-pool-%d", broken), func(t *testing.T) {
|
||||
object := fmt.Sprintf("unavailable-%d", broken)
|
||||
putConsistencyObject(t, z, bucket, object, 1-broken, "old-acknowledged", ObjectOptions{})
|
||||
set := z.serverPools[broken].getHashedSet(object)
|
||||
disks, switchDisks := installListingDiskSwitch(set)
|
||||
defer switchDisks(disks)
|
||||
faulty := append([]StorageAPI(nil), disks...)
|
||||
for i := range faulty {
|
||||
faulty[i] = consistencyReadFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object}
|
||||
}
|
||||
switchDisks(faulty)
|
||||
if _, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); err == nil || isErrObjectNotFound(err) {
|
||||
t.Errorf("unknown pool state allowed an old value: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type generationReadGateDisk struct {
|
||||
StorageAPI
|
||||
ready chan<- struct{}
|
||||
release <-chan struct{}
|
||||
}
|
||||
|
||||
func (d generationReadGateDisk) ReadXL(ctx context.Context, volume, path string, data bool) (RawFileInfo, error) {
|
||||
fi, err := d.StorageAPI.ReadXL(ctx, volume, path, data)
|
||||
d.ready <- struct{}{}
|
||||
if d.release != nil {
|
||||
<-d.release
|
||||
}
|
||||
return fi, err
|
||||
}
|
||||
|
||||
func (d generationReadGateDisk) ReadVersion(ctx context.Context, orig, volume, path, version string, opts ReadOptions) (FileInfo, error) {
|
||||
fi, err := d.StorageAPI.ReadVersion(ctx, orig, volume, path, version, opts)
|
||||
d.ready <- struct{}{}
|
||||
if d.release != nil {
|
||||
<-d.release
|
||||
}
|
||||
return fi, err
|
||||
}
|
||||
|
||||
func TestReadGenerationFastInfoWaitsForSlowDisk(t *testing.T) {
|
||||
for _, version := range []string{"", nullVersionID} {
|
||||
t.Run("version="+version, func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
const object = "slow-newer-generation"
|
||||
set := z.serverPools[0].getHashedSet(object)
|
||||
disks, switchDisks := installListingDiskSwitch(set)
|
||||
defer switchDisks(disks)
|
||||
base := UTCNow().Add(-time.Hour)
|
||||
putConsistencyObject(t, z, bucket, object, 0, "old", ObjectOptions{MTime: base})
|
||||
saved := make([][]byte, len(disks)-1)
|
||||
for i := range saved {
|
||||
saved[i] = mustReadNullQuorumMeta(t, disks[i], bucket, object)
|
||||
}
|
||||
putConsistencyObject(t, z, bucket, object, 0, "new", ObjectOptions{MTime: base.Add(time.Minute)})
|
||||
for i, meta := range saved {
|
||||
if err := disks[i].WriteAll(t.Context(), bucket, pathJoin(object, xlStorageFormatFile), meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ready, release := make(chan struct{}, len(disks)), make(chan struct{})
|
||||
var once sync.Once
|
||||
unblock := func() { once.Do(func() { close(release) }) }
|
||||
defer unblock()
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
for i, disk := range disks {
|
||||
gate := generationReadGateDisk{StorageAPI: disk, ready: ready}
|
||||
if i == len(disks)-1 {
|
||||
gate.release = release
|
||||
}
|
||||
wrapped[i] = gate
|
||||
}
|
||||
switchDisks(wrapped)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, _, err := set.getObjectFileInfo(t.Context(), bucket, object, ObjectOptions{FastGetObjInfo: true, VersionID: version}, false)
|
||||
done <- err
|
||||
}()
|
||||
for range disks {
|
||||
select {
|
||||
case <-ready:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("metadata reads did not reach the barrier")
|
||||
}
|
||||
}
|
||||
early := false
|
||||
select {
|
||||
case err := <-done:
|
||||
early = true
|
||||
t.Errorf("fast read returned before the newer disk: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
unblock()
|
||||
if !early {
|
||||
select {
|
||||
case err := <-done:
|
||||
if !isErrReadQuorum(err) {
|
||||
t.Fatalf("fast read exposed the old generation: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("fast read did not finish after the newer disk")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
madmin "github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/pgsty/silo-pkg/v3/sync/errgroup"
|
||||
)
|
||||
|
||||
// objectPoolInfos reads the addressed version in every pool, including pools
|
||||
@@ -62,6 +64,292 @@ func (z *erasureServerPools) objectPoolInfos(ctx context.Context, bucket, object
|
||||
return copies, nil
|
||||
}
|
||||
|
||||
// checkCurrentPreconditions evaluates the public write conditions in
|
||||
// opts.CheckPrecondFn against the logical current object across pools,
|
||||
// including draining pools. The pool that receives a write may be empty or
|
||||
// hold a stale copy, so its local state cannot decide the condition. A
|
||||
// current delete marker reads as an absent key: If-Match then fails with
|
||||
// ObjectNotFound and If-None-Match may proceed. An unreadable pool may hold
|
||||
// the newest object; it is not absence, and its error is returned without
|
||||
// evaluating the condition. The caller must hold the pools-layer object lock
|
||||
// and, once the condition is accepted, clear the callback so the destination
|
||||
// does not evaluate it again against its local copy.
|
||||
func (z *erasureServerPools) checkCurrentPreconditions(ctx context.Context, bucket, object string, opts ObjectOptions) error {
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, object, ObjectOptions{
|
||||
VersionID: "", // Compare the current object, not the write's version.
|
||||
Versioned: opts.Versioned,
|
||||
VersionSuspended: opts.VersionSuspended,
|
||||
NoAuditLog: true,
|
||||
})
|
||||
if err == nil && copies[0].ObjInfo.DeleteMarker {
|
||||
err = toObjectErr(errFileNotFound, bucket, decodeDirObject(object))
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
if opts.CheckPrecondFn(copies[0].ObjInfo) {
|
||||
return PreConditionFailed{}
|
||||
}
|
||||
return nil
|
||||
case !isErrObjectNotFound(err) && !isErrVersionNotFound(err):
|
||||
return err
|
||||
case opts.HasIfMatch:
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// checkDataMovementCopies rejects moving a version when another pool holds a
|
||||
// newer copy of it, which reads return instead. A cross-pool overwrite can
|
||||
// leave such a copy, and a client write can add one while the move copies.
|
||||
// Equal timestamps normally identify an interrupted move. Conflicting ties
|
||||
// retain the same pool-order winner as reads. While the caller holds the
|
||||
// pools-layer object lock, the returned resolver serves the set layer's
|
||||
// reconciliation of this version from the same reads. Without opts.NoLock
|
||||
// it takes the read lock itself.
|
||||
func (z *erasureServerPools) checkDataMovementCopies(ctx context.Context, bucket, object string, opts ObjectOptions) (GetObjectInfoFn, error) {
|
||||
if opts.MTime.IsZero() {
|
||||
// The set layer would stamp the moved copy as the newest one.
|
||||
return nil, errInvalidArgument
|
||||
}
|
||||
if !opts.NoLock {
|
||||
lk := z.NewNSLock(bucket, object)
|
||||
lkctx, err := lk.GetRLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx = lkctx.Context()
|
||||
defer lk.RUnlock(lkctx)
|
||||
}
|
||||
versionID := opts.VersionID
|
||||
if versionID == "" {
|
||||
versionID = nullVersionID
|
||||
}
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, object, ObjectOptions{VersionID: versionID, NoAuditLog: true})
|
||||
if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
// The snapshot may have been purged or replaced after the reader opened.
|
||||
// In that case the walker must leave the current source state alone.
|
||||
sourcePresent := false
|
||||
for _, copy := range copies {
|
||||
if copy.Index == opts.SrcPoolIdx && copy.ObjInfo.ModTime.Equal(opts.MTime) && copy.ObjInfo.DeleteMarker == opts.DeleteMarker {
|
||||
sourcePresent = true
|
||||
}
|
||||
}
|
||||
if !sourcePresent {
|
||||
return nil, VersionNotFound{Bucket: bucket, Object: decodeDirObject(object), VersionID: versionID}
|
||||
}
|
||||
for _, copy := range copies {
|
||||
if copy.Index != opts.SrcPoolIdx && copy.ObjInfo.ModTime.After(opts.MTime) {
|
||||
return nil, DataMovementOverwriteErr{Bucket: bucket, Object: object, VersionID: opts.VersionID, Err: errDataMovementSuperseded}
|
||||
}
|
||||
}
|
||||
if winner := copies[0]; winner.Index != opts.SrcPoolIdx && winner.ObjInfo.ModTime.Equal(opts.MTime) {
|
||||
for _, copy := range copies {
|
||||
if copy.Index == opts.SrcPoolIdx && (copy.ObjInfo.ETag != winner.ObjInfo.ETag || copy.ObjInfo.DeleteMarker != winner.ObjInfo.DeleteMarker || copy.ObjInfo.Size != winner.ObjInfo.Size) {
|
||||
return nil, DataMovementOverwriteErr{Bucket: bucket, Object: object, VersionID: opts.VersionID, Err: errDataMovementSuperseded}
|
||||
}
|
||||
}
|
||||
}
|
||||
return func(ctx context.Context, b, o string, ropts ObjectOptions) (ObjectInfo, error) {
|
||||
if ropts.VersionID == "" {
|
||||
ropts.VersionID = nullVersionID
|
||||
}
|
||||
if b != bucket || o != object || ropts.VersionID != versionID {
|
||||
return z.replicaObjectInfo(ctx, b, o, ropts)
|
||||
}
|
||||
return replicaCopiesInfo(copies, err, bucket, object, versionID)
|
||||
}, nil
|
||||
}
|
||||
|
||||
type migrationExpiredVersion struct {
|
||||
version FileInfo
|
||||
event lifecycle.Event
|
||||
lifecycleUpdatedAt time.Time
|
||||
source lcEventSrc
|
||||
}
|
||||
|
||||
// applyMigrationExpiry runs the logical expiry path before migration retires
|
||||
// any remaining source copies. This keeps replication and tier cleanup under
|
||||
// their normal ownership and makes lifecycle configuration changes fail safe.
|
||||
func (z *erasureServerPools) applyMigrationExpiry(ctx context.Context, bucket, object string, source int, expired []migrationExpiredVersion) error {
|
||||
if len(expired) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
lc, lifecycleUpdatedAt, err := globalBucketMetadataSys.GetLifecycleConfig(bucket)
|
||||
if err != nil {
|
||||
return PreConditionFailed{}
|
||||
}
|
||||
for _, pending := range expired {
|
||||
if !pending.lifecycleUpdatedAt.Equal(lifecycleUpdatedAt) {
|
||||
return PreConditionFailed{}
|
||||
}
|
||||
}
|
||||
lr, err := globalBucketObjectLockSys.Get(bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rcfg, err := getReplicationConfig(ctx, bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encodedObject := encodeDirObject(object)
|
||||
seenDeleteAll := false
|
||||
eventSource := lcEventSrc_Decom
|
||||
for _, pending := range expired {
|
||||
if pending.source != lcEventSrc_None {
|
||||
eventSource = pending.source
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, pending := range expired {
|
||||
if pending.event.Action.DeleteAll() {
|
||||
if seenDeleteAll {
|
||||
continue
|
||||
}
|
||||
seenDeleteAll = true
|
||||
}
|
||||
|
||||
versionID := pending.version.VersionID
|
||||
if versionID == "" {
|
||||
versionID = nullVersionID
|
||||
}
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, encodedObject, ObjectOptions{
|
||||
VersionID: versionID,
|
||||
NoAuditLog: true,
|
||||
})
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current := copies[0].ObjInfo
|
||||
for _, copy := range copies {
|
||||
if copy.Index == source {
|
||||
current = copy.ObjInfo
|
||||
break
|
||||
}
|
||||
}
|
||||
current.Bucket = bucket
|
||||
current.Name = object
|
||||
currentEvent := evalActionFromLifecycle(ctx, *lc, lr, rcfg, current)
|
||||
if currentEvent.Action != pending.event.Action {
|
||||
return PreConditionFailed{}
|
||||
}
|
||||
|
||||
var ok bool
|
||||
if current.TransitionedObject.Status != "" && !pending.event.Action.DeleteAll() {
|
||||
ok = applyExpiryOnTransitionedObject(ctx, z, current, pending.event, eventSource)
|
||||
} else {
|
||||
ok = applyExpiryOnNonTransitionedObjects(ctx, z, current, pending.event, eventSource)
|
||||
}
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// A concurrent expiry may have removed the addressed version after
|
||||
// the first lookup. Anything still present means the logical delete
|
||||
// was not committed and migration must stop before purging its source.
|
||||
if _, err := z.objectPoolInfos(ctx, bucket, encodedObject, ObjectOptions{VersionID: versionID, NoAuditLog: true}); err == nil {
|
||||
return PreConditionFailed{}
|
||||
} else if !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeMovedVersions retires only the source versions in the walker's
|
||||
// snapshot. A concurrent delete can create a new marker in the source pool;
|
||||
// deleting the whole prefix would remove that marker and expose moved data.
|
||||
// Versions in expired were not moved because ILM deletes them. They must stay
|
||||
// in the source until the ordinary expiry path has deleted them: that path
|
||||
// owns replication state and tier cleanup, and it re-evaluates the current
|
||||
// lifecycle rule. A migration cleanup must never turn an asynchronous ILM
|
||||
// decision into an unconditional physical purge.
|
||||
func (z *erasureServerPools) removeMovedVersions(ctx context.Context, bucket, object string, source int, versions []FileInfo, expired []migrationExpiredVersion) error {
|
||||
if err := z.applyMigrationExpiry(ctx, bucket, object, source, expired); err != nil {
|
||||
return err
|
||||
}
|
||||
object = encodeDirObject(object)
|
||||
lk := z.NewNSLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = lkctx.Context()
|
||||
defer lk.Unlock(lkctx)
|
||||
for _, version := range versions {
|
||||
vid := version.VersionID
|
||||
if vid == "" {
|
||||
vid = nullVersionID
|
||||
}
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, object, ObjectOptions{VersionID: vid, NoAuditLog: true})
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var src *ObjectInfo
|
||||
var retained []PoolObjInfo
|
||||
for _, copy := range copies {
|
||||
if copy.Index == source {
|
||||
src = ©.ObjInfo
|
||||
} else {
|
||||
retained = append(retained, copy)
|
||||
}
|
||||
}
|
||||
if src == nil {
|
||||
continue
|
||||
}
|
||||
if !src.ModTime.Equal(version.ModTime) || src.DeleteMarker != version.Deleted {
|
||||
return PreConditionFailed{}
|
||||
}
|
||||
if isExpiredVersion(expired, version) {
|
||||
// The logical expiry path owns this version. Keeping the source is
|
||||
// also the retry point if that path failed or the pool check races it.
|
||||
continue
|
||||
}
|
||||
// Successful movement is not enough if the destination changed again.
|
||||
// Keep the source unless another copy still preserves its read result.
|
||||
if len(retained) == 0 || retained[0].ObjInfo.ModTime.Before(src.ModTime) {
|
||||
return PreConditionFailed{}
|
||||
}
|
||||
_, err = z.serverPools[source].DeleteObject(ctx, bucket, object, ObjectOptions{
|
||||
VersionID: vid, NoLock: true, NoAuditLog: true,
|
||||
SkipFreeVersion: true, // preserve the old prefix cleanup's tier-GC behavior
|
||||
})
|
||||
if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ilmDeletesVersionOnly reports whether the ILM action queued for a version
|
||||
// that data movement skipped deletes that version and nothing else, rather
|
||||
// than every version of the object or only the restored copy of a
|
||||
// transitioned version. Such a version is passed to removeMovedVersions.
|
||||
func ilmDeletesVersionOnly(action lifecycle.Action) bool {
|
||||
return action == lifecycle.DeleteAction || action == lifecycle.DeleteVersionAction
|
||||
}
|
||||
|
||||
func isExpiredVersion(expired []migrationExpiredVersion, version FileInfo) bool {
|
||||
for _, pending := range expired {
|
||||
fi := pending.version
|
||||
if fi.VersionID == version.VersionID && fi.ModTime.Equal(version.ModTime) && fi.Deleted == version.Deleted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Healing also commits object metadata. Both queued healing and drive healing
|
||||
// must use the same namespace as pooled writes, rather than racing them under
|
||||
// a destination set's independent namespace.
|
||||
@@ -137,11 +425,15 @@ func (z *erasureServerPools) replicaObjectInfo(ctx context.Context, bucket, obje
|
||||
opts.VersionID = nullVersionID
|
||||
}
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, object, opts)
|
||||
return replicaCopiesInfo(copies, err, bucket, object, opts.VersionID)
|
||||
}
|
||||
|
||||
func replicaCopiesInfo(copies []PoolObjInfo, err error, bucket, object, versionID string) (ObjectInfo, error) {
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
if copies[0].ObjInfo.DeleteMarker {
|
||||
return copies[0].ObjInfo, MethodNotAllowed{Bucket: bucket, Object: decodeDirObject(object), VersionID: opts.VersionID}
|
||||
return copies[0].ObjInfo, MethodNotAllowed{Bucket: bucket, Object: decodeDirObject(object), VersionID: versionID}
|
||||
}
|
||||
return mergedPoolObjectInfo(copies), nil
|
||||
}
|
||||
@@ -410,3 +702,124 @@ func (z *erasureServerPools) deleteObjectReconciled(ctx context.Context, bucket,
|
||||
oi.replicationDecision = opts.DeleteReplication.ReplicateDecisionStr
|
||||
return oi, err
|
||||
}
|
||||
|
||||
// deleteObjectsReconciled removes data while retaining each item's authoritative
|
||||
// copy until every other pool has completed its cleanup. The caller holds the
|
||||
// pools-layer write locks and separates marker creation from this batch.
|
||||
// approved holds the Object Lock state each checked ILM expiry was approved
|
||||
// with, see ExpirationOptions.ApprovedProtection.
|
||||
func (z *erasureServerPools) deleteObjectsReconciled(ctx context.Context, bucket string, objects []ObjectToDelete, approved []objectLockState, opts ObjectOptions) ([]DeletedObject, []error) {
|
||||
deleted := make([]DeletedObject, len(objects))
|
||||
errs := make([]error, len(objects))
|
||||
primary := make([]int, len(objects))
|
||||
tiered := make([]bool, len(objects))
|
||||
lookup := errgroup.WithNErrs(len(objects)).WithConcurrency(32)
|
||||
for i, obj := range objects {
|
||||
// Preserve the full request identity even if lookup or cleanup fails.
|
||||
// The HTTP handler includes replication state in its response-index key.
|
||||
deleted[i] = DeletedObject{
|
||||
ObjectName: obj.ObjectName,
|
||||
VersionID: obj.VersionID,
|
||||
ReplicationState: obj.ReplicationState(),
|
||||
}
|
||||
primary[i] = -1
|
||||
lookup.Go(func() error {
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, obj.ObjectName, ObjectOptions{VersionID: obj.VersionID, NoAuditLog: true})
|
||||
if err == nil {
|
||||
primary[i] = copies[0].Index
|
||||
for _, copy := range copies {
|
||||
tiered[i] = tiered[i] || copy.ObjInfo.TransitionedObject.Status == lifecycle.TransitionComplete
|
||||
}
|
||||
} else if !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
if isErrReadQuorum(err) {
|
||||
err = InsufficientWriteQuorum{Bucket: bucket, Object: decodeDirObject(obj.ObjectName)}
|
||||
}
|
||||
errs[i] = err
|
||||
}
|
||||
return nil
|
||||
}, i)
|
||||
}
|
||||
lookup.Wait()
|
||||
|
||||
type poolBatch struct {
|
||||
objects []ObjectToDelete
|
||||
indices []int
|
||||
deleted []DeletedObject
|
||||
errs []error
|
||||
}
|
||||
for phase := 0; phase < 2; phase++ {
|
||||
batches := make([]poolBatch, len(z.serverPools))
|
||||
for i, obj := range objects {
|
||||
if errs[i] != nil || tiered[i] {
|
||||
continue
|
||||
}
|
||||
for p := range z.serverPools {
|
||||
if (p == primary[i]) != (phase == 1) {
|
||||
continue
|
||||
}
|
||||
batches[p].objects = append(batches[p].objects, obj)
|
||||
batches[p].indices = append(batches[p].indices, i)
|
||||
}
|
||||
}
|
||||
// Each phase still uses the set layer's bulk disk I/O and runs pools
|
||||
// concurrently. Include apparently absent pools in cleanup: read-quorum
|
||||
// absence is not proof that an offline drive holds no remaining copy.
|
||||
group := errgroup.WithNErrs(len(z.serverPools))
|
||||
for p, pool := range z.serverPools {
|
||||
if len(batches[p].objects) == 0 {
|
||||
continue
|
||||
}
|
||||
group.Go(func() error {
|
||||
batches[p].deleted, batches[p].errs = pool.DeleteObjects(ctx, bucket, batches[p].objects, opts)
|
||||
return nil
|
||||
}, p)
|
||||
}
|
||||
group.Wait()
|
||||
for _, batch := range batches {
|
||||
for j, i := range batch.indices {
|
||||
if errs[i] != nil {
|
||||
continue
|
||||
}
|
||||
if batch.errs[j] != nil {
|
||||
errs[i] = batch.errs[j]
|
||||
} else if phase == 1 || primary[i] == -1 {
|
||||
deleted[i] = batch.deleted[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tiered copies can share one remote object, including restored copies.
|
||||
// Reuse the existing reconciliation which suppresses premature tier GC.
|
||||
// Serializing this uncommon path also keeps duplicate key/version entries
|
||||
// in one request from racing their metadata updates under the shared lock.
|
||||
for i, obj := range objects {
|
||||
if !tiered[i] || errs[i] != nil {
|
||||
continue
|
||||
}
|
||||
deleteOpts := opts
|
||||
deleteOpts.VersionID, deleteOpts.NoLock = obj.VersionID, true
|
||||
// DeleteObjects already checked ordinary ILM UUID/explicit null
|
||||
// versions against all pools. Empty IDs and user deletes have no such
|
||||
// authorization; restored-copy expiry uses the single-object path.
|
||||
deleteOpts.Expiration.CheckedAllPools = opts.Expiration.Expire && obj.VersionID != ""
|
||||
if deleteOpts.Expiration.CheckedAllPools {
|
||||
deleteOpts.Expiration.ApprovedProtection = approved[i]
|
||||
}
|
||||
deleteOpts.DeleteReplication = obj.ReplicationState()
|
||||
if opts.PrefixEnabledFn != nil {
|
||||
deleteOpts.Versioned = opts.PrefixEnabledFn(obj.ObjectName)
|
||||
}
|
||||
_, err := z.deleteObjectReconciled(ctx, bucket, obj.ObjectName, deleteOpts)
|
||||
if errors.Is(err, errLifecycleNotApplicable) {
|
||||
// A later lifecycle rejection did not commit a local purge. Do
|
||||
// not let the worker replicate the request's pending decision.
|
||||
deleted[i].ReplicationState = ReplicationState{}
|
||||
}
|
||||
if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
errs[i] = err
|
||||
} else {
|
||||
deleted[i].found = true
|
||||
}
|
||||
}
|
||||
return deleted, errs
|
||||
}
|
||||
|
||||
@@ -522,10 +522,20 @@ func TestPoolsDeleteVersionSpecialCalls(t *testing.T) {
|
||||
opts := ObjectOptions{Versioned: true, VersionID: oi.VersionID, InclFreeVersions: !expiration, Expiration: ExpirationOptions{Expire: expiration}}
|
||||
_, err := z.DeleteObject(t.Context(), bucket, object, opts)
|
||||
if expiration {
|
||||
// No lifecycle rule authorizes expiration. Preserve its existing
|
||||
// version-not-found result, even with an unrelated unreadable pool.
|
||||
if !isErrVersionNotFound(err) {
|
||||
t.Errorf("expiration changed its scanner contract: %v", err)
|
||||
// No rule authorizes expiration. Distinguish refusal from real
|
||||
// absence, even with an unrelated unreadable pool, so a worker
|
||||
// cannot acknowledge or replicate a purge that never happened.
|
||||
if !errors.Is(err, errLifecycleNotApplicable) {
|
||||
t.Errorf("expiration did not preserve its rejection: %v", err)
|
||||
}
|
||||
gr, err := z.serverPools[0].GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{VersionID: oi.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(gr)
|
||||
gr.Close()
|
||||
if err != nil || string(data) != "payload" || gr.ObjInfo.VersionID != oi.VersionID {
|
||||
t.Errorf("rejected expiration changed the stored version: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("free-version cleanup was forced through all-pool resolution: %v", err)
|
||||
@@ -1071,6 +1081,8 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
restored bool
|
||||
unconditional bool
|
||||
skipFreeVersion bool
|
||||
bulk bool
|
||||
unversioned bool
|
||||
}{
|
||||
{name: "metadata-copy"},
|
||||
{name: "restored-metadata-copy", restored: true},
|
||||
@@ -1081,6 +1093,14 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
{name: "ordinary-failed-primary-delete", deleting: true, unconditional: true, failPrimaryDelete: true},
|
||||
{name: "ordinary-successful-delete", deleting: true, unconditional: true},
|
||||
{name: "ordinary-skip-free-version", deleting: true, unconditional: true, skipFreeVersion: true},
|
||||
{name: "bulk-failed-primary-delete", deleting: true, bulk: true, failPrimaryDelete: true},
|
||||
{name: "bulk-restored-failed-primary-delete", deleting: true, bulk: true, restored: true, failPrimaryDelete: true},
|
||||
{name: "bulk-successful-delete", deleting: true, bulk: true},
|
||||
{name: "bulk-distinct-reference", deleting: true, bulk: true, differentRemote: true},
|
||||
{name: "bulk-skip-free-version", deleting: true, bulk: true, skipFreeVersion: true},
|
||||
{name: "unversioned-failed-primary-delete", deleting: true, unconditional: true, unversioned: true, failPrimaryDelete: true},
|
||||
{name: "unversioned-successful-delete", deleting: true, unconditional: true, unversioned: true},
|
||||
{name: "bulk-unversioned-failed-primary-delete", deleting: true, bulk: true, unversioned: true, failPrimaryDelete: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
@@ -1094,13 +1114,13 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
if test.restored {
|
||||
metadata[xhttp.AmzRestore] = completedRestoreObj(time.Now().Add(time.Hour)).String()
|
||||
}
|
||||
oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{Versioned: true, UserDefined: metadata})
|
||||
oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{Versioned: !test.unversioned, UserDefined: metadata})
|
||||
secondaryMetadata := maps.Clone(metadata)
|
||||
if test.differentRemote {
|
||||
secondaryMetadata[ReservedMetadataPrefixLower+TransitionedObjectName] = "other-remote-object"
|
||||
}
|
||||
putConsistencyObject(t, z, bucket, object, 1, "data", ObjectOptions{
|
||||
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime, UserDefined: secondaryMetadata,
|
||||
Versioned: !test.unversioned, VersionID: oi.VersionID, MTime: oi.ModTime, UserDefined: secondaryMetadata,
|
||||
})
|
||||
current, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
|
||||
if err != nil || current.TransitionedObject.Status != "complete" || current.IsRemote() == test.restored {
|
||||
@@ -1113,7 +1133,11 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
set := z.serverPools[0].getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
faulty := append([]StorageAPI(nil), getDisks()...)
|
||||
var failed, attempted atomic.Int32
|
||||
for i := range faulty {
|
||||
if test.bulk {
|
||||
faulty[i] = bulkDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, mode: "eio", fail: true, failed: &failed, attempted: &attempted}
|
||||
}
|
||||
faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return faulty }
|
||||
@@ -1121,13 +1145,20 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
}
|
||||
if test.deleting {
|
||||
opts := ObjectOptions{
|
||||
Versioned: true, VersionID: oi.VersionID, SkipFreeVersion: test.skipFreeVersion,
|
||||
Versioned: !test.unversioned, VersionID: oi.VersionID, SkipFreeVersion: test.skipFreeVersion,
|
||||
CheckPrecondFn: func(info ObjectInfo) bool { return info.ETag != current.ETag },
|
||||
}
|
||||
if test.unconditional {
|
||||
opts.CheckPrecondFn = nil
|
||||
}
|
||||
_, err := z.DeleteObject(t.Context(), bucket, object, opts)
|
||||
var err error
|
||||
if test.bulk {
|
||||
opts.CheckPrecondFn = nil
|
||||
_, errs := z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object, VersionID: oi.VersionID}}}, opts)
|
||||
err = errs[0]
|
||||
} else {
|
||||
_, err = z.DeleteObject(t.Context(), bucket, object, opts)
|
||||
}
|
||||
if (err != nil) != test.failPrimaryDelete {
|
||||
t.Fatalf("unexpected authoritative delete result: %v", err)
|
||||
}
|
||||
@@ -1145,7 +1176,7 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
_, err = z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
|
||||
primaryDeleted := test.deleting && !test.failPrimaryDelete
|
||||
if primaryDeleted {
|
||||
if !isErrVersionNotFound(err) {
|
||||
if !isErrVersionNotFound(err) && (!test.unversioned || !isErrObjectNotFound(err)) {
|
||||
t.Fatalf("authoritative copy survived successful delete: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
@@ -1306,3 +1337,182 @@ func TestPoolsDeleteDirectoryMarker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unconditional DELETE of an object without versions removes its copies in
|
||||
// every pool. When the delete fails in one pool, the latest copy must stay in
|
||||
// place: removing it anyway serves an older write of the object.
|
||||
func TestPoolsDeleteUnversionedReportsOtherPoolFailure(t *testing.T) {
|
||||
for _, offline := range []bool{false, true} {
|
||||
for latest := range 2 {
|
||||
t.Run(fmt.Sprintf("offline=%v/latest=%d", offline, latest), func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
const object = "unversioned-failure"
|
||||
older := 1 - latest
|
||||
putConsistencyObject(t, z, bucket, object, older, "older", ObjectOptions{MaxParity: true, MTime: time.Now().Add(-time.Hour)})
|
||||
oi := putConsistencyObject(t, z, bucket, object, latest, "latest", ObjectOptions{MaxParity: true})
|
||||
set := z.serverPools[older].getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
disks := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range disks {
|
||||
switch {
|
||||
case !offline:
|
||||
disks[i] = consistencyDeleteFaultDisk{StorageAPI: disks[i], bucket: bucket, object: object}
|
||||
case i < len(disks)/2:
|
||||
// An EC 8+8 copy stays readable, but a delete needs 9 drives.
|
||||
disks[i] = nil
|
||||
}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return disks }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
if _, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{}); err == nil {
|
||||
t.Error("delete reported success despite the other pool's write failure")
|
||||
}
|
||||
set.getDisks = getDisks
|
||||
if got, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); err != nil || got.ETag != oi.ETag {
|
||||
t.Errorf("after the failed delete the object reads ETag %q, %v; want the latest write %q", got.ETag, err, oi.ETag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// consistencyBulkDeleteFaultDisk fails the multi-object deletes of one object.
|
||||
type consistencyBulkDeleteFaultDisk struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
}
|
||||
|
||||
func (d consistencyBulkDeleteFaultDisk) DeleteVersions(ctx context.Context, volume string, versions []FileInfoVersions, opts DeleteOptions) []error {
|
||||
errs := make([]error, len(versions))
|
||||
var rest []FileInfoVersions
|
||||
var restIdx []int
|
||||
for i, v := range versions {
|
||||
if volume == d.bucket && v.Name == d.object {
|
||||
errs[i] = errDiskFull
|
||||
continue
|
||||
}
|
||||
rest = append(rest, v)
|
||||
restIdx = append(restIdx, i)
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
for i, err := range d.StorageAPI.DeleteVersions(ctx, volume, rest, opts) {
|
||||
errs[restIdx[i]] = err
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// A multi-object delete must not report an object or version as deleted while
|
||||
// a pool that failed to delete it still holds a copy.
|
||||
func TestPoolsDeleteObjectsReportsOtherPoolFailure(t *testing.T) {
|
||||
for _, versioned := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("versioned=%v", versioned), func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
const object = "bulk-delete-failure"
|
||||
opts := ObjectOptions{Versioned: versioned}
|
||||
oi := putConsistencyObject(t, z, bucket, object, 0, "latest", opts)
|
||||
if versioned {
|
||||
// The copy a data movement leaves until it removes the source.
|
||||
putConsistencyObject(t, z, bucket, object, 1, "latest", ObjectOptions{Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime})
|
||||
} else {
|
||||
putConsistencyObject(t, z, bucket, object, 1, "older", ObjectOptions{MTime: time.Now().Add(-time.Hour)})
|
||||
}
|
||||
set := z.serverPools[1].getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
faulty := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range faulty {
|
||||
faulty[i] = consistencyBulkDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return faulty }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
_, errs := z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object, VersionID: oi.VersionID}}}, opts)
|
||||
if errs[0] == nil {
|
||||
t.Error("multi-object delete reported success despite the other pool's write failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Pools delete a multi-object batch in parallel. With half the drives of the
|
||||
// older copy's set offline, an EC 8+8 copy stays readable but cannot be
|
||||
// deleted: the batch must fail the object and keep its latest copy, instead of
|
||||
// deleting it and serving the older write once the drives are back.
|
||||
func TestPoolsDeleteObjectsKeepsLatestWithoutDeleteQuorum(t *testing.T) {
|
||||
for latest := range 2 {
|
||||
t.Run(fmt.Sprintf("latest=%d", latest), func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
const object = "bulk-delete-quorum"
|
||||
older := 1 - latest
|
||||
putConsistencyObject(t, z, bucket, object, older, "older", ObjectOptions{MaxParity: true, MTime: time.Now().Add(-time.Hour)})
|
||||
oi := putConsistencyObject(t, z, bucket, object, latest, "latest", ObjectOptions{MaxParity: true})
|
||||
set := z.serverPools[older].getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
disks := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range len(disks) / 2 {
|
||||
disks[i] = nil
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return disks }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
_, errs := z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object}}}, ObjectOptions{})
|
||||
if !errors.As(errs[0], &InsufficientWriteQuorum{}) {
|
||||
t.Errorf("multi-object delete: got %v, want InsufficientWriteQuorum", errs[0])
|
||||
}
|
||||
set.getDisks = getDisks
|
||||
if got, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); err != nil || got.ETag != oi.ETag {
|
||||
t.Errorf("after the failed delete the object reads ETag %q, %v; want the latest write %q", got.ETag, err, oi.ETag)
|
||||
}
|
||||
// Nothing was deleted, so the retry removes every copy.
|
||||
if _, errs = z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object}}}, ObjectOptions{}); errs[0] != nil {
|
||||
t.Fatal(errs[0])
|
||||
}
|
||||
for i, pool := range z.serverPools {
|
||||
if _, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); !isErrObjectNotFound(err) {
|
||||
t.Errorf("pool %d retained the object after the retry: %v", i, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A delete marker hides the object in every pool. When the versions of an
|
||||
// object are in two pools and the set of one of them cannot take a write, the
|
||||
// marker goes to the other pool instead of failing the delete. The versions
|
||||
// stay below the marker.
|
||||
func TestPoolsDeleteMarkerSkipsSetWithoutQuorum(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
degraded int // pool whose set loses drives
|
||||
offline int
|
||||
}{
|
||||
{"older version readable", 0, 8},
|
||||
{"older version unreadable", 0, 9},
|
||||
{"latest version readable", 1, 8},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
const object = "marker-quorum"
|
||||
v1 := putConsistencyObject(t, z, bucket, object, 0, "v1", ObjectOptions{Versioned: true, MaxParity: true, MTime: time.Now().Add(-time.Hour)})
|
||||
v2 := putConsistencyObject(t, z, bucket, object, 1, "v2", ObjectOptions{Versioned: true, MaxParity: true})
|
||||
set := z.serverPools[tc.degraded].getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
disks := append([]StorageAPI(nil), getDisks()...)
|
||||
for i := range tc.offline {
|
||||
disks[i] = nil
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return disks }
|
||||
defer func() { set.getDisks = getDisks }()
|
||||
if _, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{Versioned: true}); err != nil {
|
||||
t.Fatalf("delete with the set of pool %d without quorum: %v", tc.degraded, err)
|
||||
}
|
||||
set.getDisks = getDisks
|
||||
if _, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); !isErrObjectNotFound(err) && !isErrMethodNotAllowed(err) {
|
||||
t.Errorf("object still readable after the delete: %v", err)
|
||||
}
|
||||
for pool, v := range []ObjectInfo{v1, v2} {
|
||||
if _, err := z.serverPools[pool].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: v.VersionID}); err != nil {
|
||||
t.Errorf("pool %d lost version %s: %v", pool, v.VersionID, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+170
-132
@@ -182,7 +182,9 @@ func (p *poolMeta) returnResumablePools() []PoolStatus {
|
||||
}
|
||||
|
||||
func (p *poolMeta) DecommissionComplete(idx int) bool {
|
||||
if p.Pools[idx].Decommission != nil && !p.Pools[idx].Decommission.Complete {
|
||||
if p.Pools[idx].Decommission != nil &&
|
||||
!p.Pools[idx].Decommission.Complete &&
|
||||
!p.Pools[idx].Decommission.Canceled {
|
||||
p.Pools[idx].LastUpdate = UTCNow()
|
||||
p.Pools[idx].Decommission.Complete = true
|
||||
p.Pools[idx].Decommission.Failed = false
|
||||
@@ -193,7 +195,9 @@ func (p *poolMeta) DecommissionComplete(idx int) bool {
|
||||
}
|
||||
|
||||
func (p *poolMeta) DecommissionFailed(idx int) bool {
|
||||
if p.Pools[idx].Decommission != nil && !p.Pools[idx].Decommission.Failed {
|
||||
if p.Pools[idx].Decommission != nil &&
|
||||
!p.Pools[idx].Decommission.Failed &&
|
||||
!p.Pools[idx].Decommission.Canceled {
|
||||
p.Pools[idx].LastUpdate = UTCNow()
|
||||
p.Pools[idx].Decommission.StartTime = time.Time{}
|
||||
p.Pools[idx].Decommission.Complete = false
|
||||
@@ -616,6 +620,12 @@ func (z *erasureServerPools) decommissionObject(ctx context.Context, idx int, bu
|
||||
}
|
||||
|
||||
if objInfo.isMultipart() {
|
||||
// Completion repeats this check under the object write lock.
|
||||
if _, err := z.checkDataMovementCopies(ctx, bucket, encodeDirObject(objInfo.Name), ObjectOptions{
|
||||
VersionID: objInfo.VersionID, MTime: objInfo.ModTime, SrcPoolIdx: idx,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := z.NewMultipartUpload(ctx, bucket, objInfo.Name, ObjectOptions{
|
||||
VersionID: objInfo.VersionID,
|
||||
UserDefined: migrationObjectMetadata(objInfo),
|
||||
@@ -656,6 +666,7 @@ func (z *erasureServerPools) decommissionObject(ctx context.Context, idx int, bu
|
||||
}
|
||||
}
|
||||
_, err = z.CompleteMultipartUpload(ctx, bucket, objInfo.Name, res.UploadID, parts, ObjectOptions{
|
||||
VersionID: objInfo.VersionID,
|
||||
SrcPoolIdx: idx,
|
||||
DataMovement: true,
|
||||
MTime: objInfo.ModTime,
|
||||
@@ -762,6 +773,7 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
|
||||
var vc *versioning.Versioning
|
||||
var lc *lifecycle.Lifecycle
|
||||
var lifecycleUpdatedAt time.Time
|
||||
var lr objectlock.Retention
|
||||
var rcfg *replication.Config
|
||||
if bi.Name != minioMetaBucket {
|
||||
@@ -771,7 +783,7 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
}
|
||||
|
||||
// Check if the current bucket has a configured lifecycle policy
|
||||
lc, err = globalLifecycleSys.Get(bi.Name)
|
||||
lc, lifecycleUpdatedAt, err = globalBucketMetadataSys.GetLifecycleConfig(bi.Name)
|
||||
if err != nil && !errors.Is(err, BucketLifecycleNotFound{Bucket: bi.Name}) {
|
||||
return err
|
||||
}
|
||||
@@ -789,23 +801,41 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
}
|
||||
|
||||
for setIdx, set := range pool.sets {
|
||||
filterLifecycle := func(bucket, object string, fi FileInfo) bool {
|
||||
// filterLifecycle returns an ILM deletion that migration must handle
|
||||
// before retiring the source copy, or NoneAction.
|
||||
filterLifecycle := func(bucket, object string, fi FileInfo, history *migrationHistory) lifecycle.Action {
|
||||
if lc == nil {
|
||||
return false
|
||||
return lifecycle.NoneAction
|
||||
}
|
||||
history.loadTier(ctx, z, bucket, object)
|
||||
versioned := vc != nil && vc.Versioned(object)
|
||||
objInfo := fi.ToObjectInfo(bucket, object, versioned)
|
||||
|
||||
evt := evalActionFromLifecycle(ctx, *lc, lr, rcfg, objInfo)
|
||||
switch {
|
||||
case evt.Action.DeleteRestored(): // if restored copy has expired, delete it synchronously
|
||||
case evt.Action.DeleteRestored():
|
||||
// A restored copy past its deadline is removed now, before the
|
||||
// version moves, as the scanner would. When that removal is
|
||||
// refused, the version moves like any other, with its tier
|
||||
// metadata and its deadline, and the scanner of the pool that
|
||||
// holds it then removes the copy. A version the walk neither
|
||||
// moves nor expires leaves the key behind, and fails it.
|
||||
applyExpiryOnTransitionedObject(ctx, z, objInfo, evt, lcEventSrc_Decom)
|
||||
return false
|
||||
return lifecycle.NoneAction
|
||||
case evt.Action == lifecycle.DeleteAction && versioned:
|
||||
// Expiring the latest version of a versioned bucket only puts
|
||||
// a delete marker on top of it. The version itself survives as
|
||||
// a noncurrent version and has to be moved like any other. The
|
||||
// scanner applies the expiry once it lives in another pool.
|
||||
return lifecycle.NoneAction
|
||||
case evt.Action.Delete() && !z.migrationExpiryApplies(ctx, objInfo, evt, history):
|
||||
// Another pool holds versions with which the rule does not
|
||||
// apply. The version moves like any other.
|
||||
return lifecycle.NoneAction
|
||||
case evt.Action.Delete():
|
||||
globalExpiryState.enqueueByDays(objInfo, evt, lcEventSrc_Decom)
|
||||
return true
|
||||
return evt.Action
|
||||
default:
|
||||
return false
|
||||
return lifecycle.NoneAction
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,27 +855,32 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
// to create the appropriate stack.
|
||||
versionsSorter(fivs.Versions).reverse()
|
||||
|
||||
var decommissioned, expired int
|
||||
var decommissioned int
|
||||
var expired []migrationExpiredVersion
|
||||
// The key's history in all pools, read once for its versions.
|
||||
var history migrationHistory
|
||||
for _, version := range fivs.Versions {
|
||||
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionObject, idx, bi.Name, version.Name, version.VersionID)
|
||||
// Apply lifecycle rules on the objects that are expired.
|
||||
if filterLifecycle(bi.Name, version.Name, version) {
|
||||
expired++
|
||||
if action := filterLifecycle(bi.Name, version.Name, version, &history); action.Delete() {
|
||||
decommissioned++
|
||||
if ilmDeletesVersionOnly(action) || (action.DeleteAll() && !version.IsRemote()) {
|
||||
// Run the logical expiry before retiring the source.
|
||||
// A full-object expiry is safe here only for hot-tier
|
||||
// metadata; transitioned objects retain their tier cleanup
|
||||
// ownership in the ordinary expiry worker.
|
||||
expired = append(expired, migrationExpiredVersion{
|
||||
version: version,
|
||||
event: lifecycle.Event{Action: action},
|
||||
lifecycleUpdatedAt: lifecycleUpdatedAt,
|
||||
source: lcEventSrc_Decom,
|
||||
})
|
||||
}
|
||||
stopFn(version.Size, errors.New("ILM expired object/version will be skipped"))
|
||||
continue
|
||||
}
|
||||
|
||||
// any object with only single DEL marker we don't need
|
||||
// to decommission, just skip it, this also includes
|
||||
// any other versions that have already expired.
|
||||
remainingVersions := len(fivs.Versions) - expired
|
||||
if version.Deleted && remainingVersions == 1 && rcfg == nil {
|
||||
decommissioned++
|
||||
stopFn(version.Size, errors.New("DELETE marked object with no other non-current versions will be skipped"))
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve every marker: older versions may live in another pool.
|
||||
versionID := version.VersionID
|
||||
if versionID == "" {
|
||||
versionID = nullVersionID
|
||||
@@ -870,6 +905,9 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
SkipDecommissioned: true, // make sure we skip the decommissioned pool
|
||||
NoAuditLog: true,
|
||||
})
|
||||
if isDataMovementSuperseded(err) {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
// This can happen when rebalance stop races with ongoing rebalance workers.
|
||||
// These rebalance failures can be ignored.
|
||||
@@ -898,17 +936,19 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
// gr.Close() is ensured by decommissionObject().
|
||||
for range 3 {
|
||||
if version.IsRemote() {
|
||||
if err := z.DecomTieredObject(ctx, bi.Name, version.Name, version, ObjectOptions{
|
||||
err := z.DecomTieredObject(ctx, bi.Name, version.Name, version, ObjectOptions{
|
||||
VersionID: versionID,
|
||||
MTime: version.ModTime,
|
||||
UserDefined: version.Metadata,
|
||||
SrcPoolIdx: idx,
|
||||
DataMovement: true,
|
||||
}); err != nil {
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
||||
ignore = true
|
||||
stopFn(0, nil)
|
||||
}
|
||||
})
|
||||
if isDataMovementSuperseded(err) {
|
||||
err = nil
|
||||
}
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
||||
ignore = true
|
||||
stopFn(0, nil)
|
||||
}
|
||||
if !ignore {
|
||||
stopFn(version.Size, err)
|
||||
@@ -949,7 +989,7 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
stopFn(version.Size, err)
|
||||
continue
|
||||
}
|
||||
if err = z.decommissionObject(ctx, idx, bi.Name, gr); err != nil {
|
||||
if err = z.decommissionObject(ctx, idx, bi.Name, gr); err != nil && !isDataMovementSuperseded(err) {
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
||||
ignore = true
|
||||
stopFn(0, nil)
|
||||
@@ -979,15 +1019,10 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
// if all versions were decommissioned, then we can delete the object versions.
|
||||
if decommissioned == len(fivs.Versions) {
|
||||
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionRemoveObject, idx, bi.Name, entry.name)
|
||||
_, err := set.DeleteObject(ctx,
|
||||
bi.Name,
|
||||
encodeDirObject(entry.name),
|
||||
ObjectOptions{
|
||||
DeletePrefix: true, // use prefix delete to delete all versions at once.
|
||||
DeletePrefixObject: true, // use prefix delete on exact object (this is an optimization to avoid fan-out calls)
|
||||
NoAuditLog: true,
|
||||
},
|
||||
)
|
||||
err := z.removeMovedVersions(ctx, bi.Name, entry.name, idx, fivs.Versions, expired)
|
||||
if err == nil {
|
||||
err = z.removeFreeVersions(ctx, bi.Name, entry.name, idx, fivs.FreeVersions)
|
||||
}
|
||||
stopFn(0, err)
|
||||
auditLogDecom(ctx, "DecomDeleteObject", bi.Name, entry.name, "", err)
|
||||
if err != nil {
|
||||
@@ -1072,12 +1107,26 @@ func (m *decomMetrics) log(d decomMetric, poolIdx int, paths ...string) func(z i
|
||||
}
|
||||
}
|
||||
|
||||
// decommissionInBackground decommissions the pending buckets of pool idx. A
|
||||
// bucket that cannot be finished stays queued and does not stop the others:
|
||||
// the first such error is returned after the last bucket, so the
|
||||
// decommission fails and a retry resumes with what is left. A canceled
|
||||
// context returns at once.
|
||||
func (z *erasureServerPools) decommissionInBackground(ctx context.Context, idx int) error {
|
||||
pool := z.serverPools[idx]
|
||||
z.poolMetaMutex.RLock()
|
||||
pending := z.poolMeta.PendingBuckets(idx)
|
||||
z.poolMetaMutex.RUnlock()
|
||||
|
||||
var firstErr error
|
||||
failed := func(bucket decomBucketInfo, err error) {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
return
|
||||
}
|
||||
// Only the first error is returned; log the others.
|
||||
decomLogIf(ctx, fmt.Errorf("decommission of bucket %s: %w", bucket, err))
|
||||
}
|
||||
for _, bucket := range pending {
|
||||
z.poolMetaMutex.RLock()
|
||||
isDecommissioned := z.poolMeta.isBucketDecommissioned(idx, bucket.String())
|
||||
@@ -1101,7 +1150,24 @@ func (z *erasureServerPools) decommissionInBackground(ctx context.Context, idx i
|
||||
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionBucket, idx, bucket.Name)
|
||||
if err := z.decommissionPool(ctx, idx, pool, bucket); err != nil {
|
||||
stopFn(0, err)
|
||||
return err
|
||||
if contextCanceled(ctx) {
|
||||
return err
|
||||
}
|
||||
failed(bucket, err)
|
||||
continue
|
||||
}
|
||||
// Do not retire the bucket from the resumable queue until every
|
||||
// source version and free version is gone. In particular, an ILM
|
||||
// action queued during the walk may still be pending; marking the
|
||||
// bucket complete here would allow the source pool to be removed
|
||||
// before that action performs its logical and tier cleanup.
|
||||
if err := z.checkAfterDecomBucket(ctx, idx, bucket); err != nil {
|
||||
stopFn(0, err)
|
||||
if contextCanceled(ctx) {
|
||||
return err
|
||||
}
|
||||
failed(bucket, err)
|
||||
continue
|
||||
}
|
||||
stopFn(0, nil)
|
||||
|
||||
@@ -1111,6 +1177,67 @@ func (z *erasureServerPools) decommissionInBackground(ctx context.Context, idx i
|
||||
}
|
||||
z.poolMetaMutex.Unlock()
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// removeFreeVersions removes the free versions fvs of object from the pool
|
||||
// idx being decommissioned, as the scanner removes free versions: first the
|
||||
// remote object of each, then the free version. A free version is not moved.
|
||||
// In another pool it would be a delete marker on top of the object, and once
|
||||
// this pool is gone nothing would remove its remote object. A remote object
|
||||
// that is already gone is not an error.
|
||||
func (z *erasureServerPools) removeFreeVersions(ctx context.Context, bucket, object string, idx int, fvs []FileInfo) error {
|
||||
if len(fvs) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, fv := range fvs {
|
||||
err := z.deleteTierTuple(ctx, bucket, object, tierRemoteTuple{
|
||||
Tier: fv.TransitionTier,
|
||||
Object: fv.TransitionedObjName,
|
||||
VersionID: fv.TransitionVersionID,
|
||||
})
|
||||
if errors.Is(err, errTierNotFound) {
|
||||
// The free version is the only record of its remote object. It
|
||||
// stays until the tier can be reached again.
|
||||
return fmt.Errorf("unable to remove the free version %s of %s/%s: its remote object %s is in tier %s, which is not configured; "+
|
||||
"configure tier %s again under the same name so that the remote object and the free version can be removed: %w",
|
||||
fv.VersionID, bucket, object, fv.TransitionedObjName, fv.TransitionTier, fv.TransitionTier, err)
|
||||
}
|
||||
if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
return fmt.Errorf("unable to remove the remote object %s of a free version of %s/%s from tier %s: %w",
|
||||
fv.TransitionedObjName, bucket, object, fv.TransitionTier, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAfterDecomBucket verifies that a source bucket is physically empty
|
||||
// before it is removed from the resumable decommission queue. ILM decisions
|
||||
// are deliberately not treated as an exemption: an expired version remains
|
||||
// until the expiry path has performed its logical delete, replication, and
|
||||
// tier cleanup.
|
||||
func (z *erasureServerPools) checkAfterDecomBucket(ctx context.Context, idx int, bi decomBucketInfo) error {
|
||||
versionsFound := 0
|
||||
for _, set := range z.serverPools[idx].sets {
|
||||
if err := set.listObjectsToDecommission(ctx, bi, func(entry metaCacheEntry) {
|
||||
if !entry.isObject() {
|
||||
return
|
||||
}
|
||||
if bi.Name == minioMetaBucket && strings.Contains(entry.name, dataUsageCacheName) {
|
||||
return
|
||||
}
|
||||
fivs, err := entry.fileInfoVersions(bi.Name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
versionsFound += len(fivs.Versions) + len(fivs.FreeVersions)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if versionsFound > 0 {
|
||||
return fmt.Errorf("at least %d object(s)/version(s) were found in bucket `%s` after decommissioning", versionsFound, bi.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1119,100 +1246,11 @@ func (z *erasureServerPools) checkAfterDecom(ctx context.Context, idx int) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pool := z.serverPools[idx]
|
||||
for _, set := range pool.sets {
|
||||
for _, bi := range buckets {
|
||||
var vc *versioning.Versioning
|
||||
var lc *lifecycle.Lifecycle
|
||||
var lr objectlock.Retention
|
||||
var rcfg *replication.Config
|
||||
if bi.Name != minioMetaBucket {
|
||||
vc, err = globalBucketVersioningSys.Get(bi.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the current bucket has a configured lifecycle policy
|
||||
lc, err = globalLifecycleSys.Get(bi.Name)
|
||||
if err != nil && !errors.Is(err, BucketLifecycleNotFound{Bucket: bi.Name}) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if bucket is object locked.
|
||||
lr, err = globalBucketObjectLockSys.Get(bi.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rcfg, err = getReplicationConfig(ctx, bi.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
filterLifecycle := func(bucket, object string, fi FileInfo) bool {
|
||||
if lc == nil {
|
||||
return false
|
||||
}
|
||||
versioned := vc != nil && vc.Versioned(object)
|
||||
objInfo := fi.ToObjectInfo(bucket, object, versioned)
|
||||
|
||||
evt := evalActionFromLifecycle(ctx, *lc, lr, rcfg, objInfo)
|
||||
switch {
|
||||
case evt.Action.DeleteRestored(): // if restored copy has expired,delete it synchronously
|
||||
applyExpiryOnTransitionedObject(ctx, z, objInfo, evt, lcEventSrc_Decom)
|
||||
return false
|
||||
case evt.Action.Delete():
|
||||
globalExpiryState.enqueueByDays(objInfo, evt, lcEventSrc_Decom)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var versionsFound int
|
||||
if err = set.listObjectsToDecommission(ctx, bi, func(entry metaCacheEntry) {
|
||||
if !entry.isObject() {
|
||||
return
|
||||
}
|
||||
|
||||
// `.usage-cache.bin` still exists, must be not readable ignore it.
|
||||
if bi.Name == minioMetaBucket && strings.Contains(entry.name, dataUsageCacheName) {
|
||||
// skipping bucket usage cache name, as its autogenerated.
|
||||
return
|
||||
}
|
||||
|
||||
fivs, err := entry.fileInfoVersions(bi.Name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var ignored int
|
||||
|
||||
for _, version := range fivs.Versions {
|
||||
// Apply lifecycle rules on the objects that are expired.
|
||||
if filterLifecycle(bi.Name, version.Name, version) {
|
||||
ignored++
|
||||
continue
|
||||
}
|
||||
if version.Deleted {
|
||||
ignored++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
versionsFound += len(fivs.Versions) - ignored
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if versionsFound > 0 {
|
||||
return fmt.Errorf("at least %d object(s)/version(s) were found in bucket `%s` after decommissioning", versionsFound, bi.Name)
|
||||
}
|
||||
for _, bi := range buckets {
|
||||
if err := z.checkAfterDecomBucket(ctx, idx, bi); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -197,3 +197,29 @@ func TestPoolMetaValidate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolMetaDecommissionCancellationIsTerminal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
canceled := poolMeta{Pools: []PoolStatus{{Decommission: &PoolDecommissionInfo{}}}}
|
||||
if !canceled.DecommissionCancel(0) {
|
||||
t.Fatal("failed to record cancellation")
|
||||
}
|
||||
if canceled.DecommissionFailed(0) {
|
||||
t.Fatal("a canceled decommission was changed to failed")
|
||||
}
|
||||
if canceled.DecommissionComplete(0) {
|
||||
t.Fatal("a canceled decommission was changed to complete")
|
||||
}
|
||||
if got := canceled.returnResumablePools(); len(got) != 0 {
|
||||
t.Fatalf("canceled decommission is resumable: %+v", got)
|
||||
}
|
||||
|
||||
failed := poolMeta{Pools: []PoolStatus{{Decommission: &PoolDecommissionInfo{}}}}
|
||||
if !failed.DecommissionFailed(0) {
|
||||
t.Fatal("failed to record failure")
|
||||
}
|
||||
if got := failed.returnResumablePools(); len(got) != 1 {
|
||||
t.Fatalf("failed decommission is not resumable: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// The parts of the purge of all versions that need its own options.
|
||||
|
||||
// A purge stops at its budget before the latest version.
|
||||
func TestPurgeAllVersionsStopsAtBudget(t *testing.T) {
|
||||
z := purgeSinglePool(t, 16)
|
||||
p := newPurgeTest(t, z, "purge-budget", MakeBucketOptions{VersioningEnabled: true}, purgeRules)
|
||||
prev := deleteAllBudget
|
||||
deleteAllBudget.versions = 2
|
||||
t.Cleanup(func() { deleteAllBudget = prev })
|
||||
const key = "ea/long"
|
||||
for i, days := range []float64{8, 7, 6, 5} {
|
||||
p.version(0, key, fmt.Sprintf("v%d", i+1), days)
|
||||
}
|
||||
o := observePurges(t)
|
||||
p.expire(0, key)
|
||||
if got := p.held(0, key); !slices.Equal(got, []string{"hot", "hot"}) {
|
||||
t.Errorf("holds %v after a purge stopped at its budget, want the two newest versions", got)
|
||||
}
|
||||
if got := p.get(key); got != "v4" {
|
||||
t.Errorf("reads %s, want v4", got)
|
||||
}
|
||||
events, traces := o.drain()
|
||||
if len(events) != 0 || len(traces) != 1 || traces[0]["ilm-partial"] != "true" || traces[0]["ilm-versions-removed"] != "2" {
|
||||
t.Errorf("stopped purge sent %v and traced %v", events, traces)
|
||||
}
|
||||
p.expire(0, key)
|
||||
if got := p.held(0, key); got != nil {
|
||||
t.Errorf("keeps %v once the purge continued", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The option admits no other delete.
|
||||
func TestPurgeAllVersionsOptions(t *testing.T) {
|
||||
z := purgeSinglePool(t, 16)
|
||||
p := newPurgeTest(t, z, "purge-options", MakeBucketOptions{VersioningEnabled: true}, purgeRules)
|
||||
p.version(0, "ea/key", "v1", 5)
|
||||
match := &deleteAllMatch{}
|
||||
expire := ExpirationOptions{Expire: true}
|
||||
allow := func([]ObjectInfo) error { return nil }
|
||||
for name, opts := range map[string]ObjectOptions{
|
||||
"no approval": {DeleteAllVersions: true},
|
||||
"two approvals": {DeleteAllVersions: true, Expiration: expire, DeleteAllLatest: match, deleteAllAuthorize: allow},
|
||||
// A batch purge deletes as the submitter of its job, an ILM purge
|
||||
// as the server, and no other delete takes an authorization.
|
||||
"batch unauthorized": {DeleteAllVersions: true, DeleteAllLatest: match},
|
||||
"ilm authorized": {DeleteAllVersions: true, Expiration: expire, deleteAllAuthorize: allow},
|
||||
"authorized delete": {deleteAllAuthorize: allow},
|
||||
"prefix": {DeleteAllVersions: true, Expiration: expire, DeletePrefix: true},
|
||||
"prefix object": {DeleteAllVersions: true, Expiration: expire, DeletePrefixObject: true},
|
||||
"version": {DeleteAllVersions: true, Expiration: expire, VersionID: mustGetUUID()},
|
||||
"movement": {DeleteAllVersions: true, Expiration: expire, DataMovement: true},
|
||||
"marker": {DeleteAllVersions: true, Expiration: expire, DeleteMarker: true},
|
||||
"versioned": {DeleteAllVersions: true, Expiration: expire, Versioned: true},
|
||||
"suspended": {DeleteAllVersions: true, Expiration: expire, VersionSuspended: true},
|
||||
} {
|
||||
if _, err := z.DeleteObject(t.Context(), p.bucket, "ea/key", opts); !errors.As(err, new(InvalidArgument)) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
opts := ObjectOptions{DeleteAllVersions: true, Expiration: expire}
|
||||
if _, err := z.serverPools[0].DeleteObject(t.Context(), p.bucket, "ea/key", opts); !errors.As(err, new(NotImplemented)) {
|
||||
t.Errorf("erasure sets: %v", err)
|
||||
}
|
||||
if _, errs := z.DeleteObjects(t.Context(), p.bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: "ea/key"}}}, opts); !errors.As(errs[0], new(NotImplemented)) {
|
||||
t.Errorf("bulk delete: %v", errs[0])
|
||||
}
|
||||
if got := p.held(0, "ea/key"); !slices.Equal(got, []string{"hot"}) {
|
||||
t.Errorf("holds %v after rejected requests", got)
|
||||
}
|
||||
// An absent key is a success that removes nothing.
|
||||
var res deleteAllResult
|
||||
if _, err := z.DeleteObject(t.Context(), p.bucket, "ea/absent", ObjectOptions{DeleteAllVersions: true, Expiration: expire, DeleteAllResult: &res}); err != nil || res.Removed != 0 || !res.Complete {
|
||||
t.Errorf("absent key: %v %+v", err, res)
|
||||
}
|
||||
}
|
||||
|
||||
type purgeSlowDisk struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
}
|
||||
|
||||
func (d purgeSlowDisk) ReadXL(ctx context.Context, bucket, object string, data bool) (RawFileInfo, error) {
|
||||
if bucket == d.bucket && object == d.object {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return d.StorageAPI.ReadXL(ctx, bucket, object, data)
|
||||
}
|
||||
|
||||
// The time budget covers the reads and checks before the first delete.
|
||||
func TestPurgeAllVersionsBudgetCoversReads(t *testing.T) {
|
||||
z := purgeSinglePool(t, 16)
|
||||
p := newPurgeTest(t, z, "purge-budget-reads", MakeBucketOptions{VersioningEnabled: true}, "")
|
||||
const key = "bx/slow"
|
||||
p.version(0, key, "v1", 5)
|
||||
exp := p.matched(key)
|
||||
prev := deleteAllBudget
|
||||
deleteAllBudget.duration = 10 * time.Millisecond
|
||||
t.Cleanup(func() { deleteAllBudget = prev })
|
||||
pool := z.serverPools[0]
|
||||
pool.erasureDisksMu.Lock()
|
||||
prior := pool.erasureDisks
|
||||
wrapped := make([][]StorageAPI, len(prior))
|
||||
for i, disks := range prior {
|
||||
wrapped[i] = make([]StorageAPI, len(disks))
|
||||
for j, disk := range disks {
|
||||
wrapped[i][j] = purgeSlowDisk{StorageAPI: disk, bucket: p.bucket, object: key}
|
||||
}
|
||||
}
|
||||
pool.erasureDisks = wrapped
|
||||
pool.erasureDisksMu.Unlock()
|
||||
t.Cleanup(func() { pool.erasureDisksMu.Lock(); pool.erasureDisks = prior; pool.erasureDisksMu.Unlock() })
|
||||
var res deleteAllResult
|
||||
_, err := z.DeleteObject(t.Context(), p.bucket, key, ObjectOptions{
|
||||
DeleteAllVersions: true,
|
||||
DeleteAllLatest: &deleteAllMatch{VersionID: exp.VersionID, ModTime: exp.ModTime},
|
||||
DeleteAllResult: &res,
|
||||
deleteAllAuthorize: func([]ObjectInfo) error { return nil },
|
||||
})
|
||||
if !errors.Is(err, errDeleteAllBudget) || res.Started || res.Removed != 0 {
|
||||
t.Errorf("purge after its budget ran out on reads: err=%v %+v", err, res)
|
||||
}
|
||||
if got := p.held(0, key); !slices.Equal(got, []string{"hot"}) {
|
||||
t.Errorf("holds %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A marker that an expiration job failed to remove fails the job.
|
||||
func TestPurgeAllVersionsMarkerFailureFailsJob(t *testing.T) {
|
||||
ri := &batchJobInfo{}
|
||||
var res batchCounts
|
||||
res.expireAllFailed(expireObjInfo{ObjectInfo: ObjectInfo{Name: "key", NumVersions: 2}, DeleteMarkerCount: 1}, 1, 0)
|
||||
progress := newBatchProgress(ri, "bucket")
|
||||
progress.done(progress.begin(), "key", res)
|
||||
progress.finish()
|
||||
if ri.ObjectsFailed != 0 || ri.DeleteMarkersFailed != 1 || !ri.expireFailures() {
|
||||
t.Errorf("failed=%d/%d failures=%t", ri.ObjectsFailed, ri.DeleteMarkersFailed, ri.expireFailures())
|
||||
}
|
||||
}
|
||||
|
||||
// purgeSwapDisk replaces itself in its slot once the purge has read it
|
||||
// twice, for the history and for the drive check, as a reconnect does.
|
||||
type purgeSwapDisk struct {
|
||||
StorageAPI
|
||||
bucket, object string
|
||||
reads *atomic.Int32
|
||||
swap func()
|
||||
}
|
||||
|
||||
func (d purgeSwapDisk) ReadXL(ctx context.Context, bucket, object string, data bool) (RawFileInfo, error) {
|
||||
rf, err := d.StorageAPI.ReadXL(ctx, bucket, object, data)
|
||||
if bucket == d.bucket && object == d.object && d.reads.Add(1) == 2 {
|
||||
d.swap()
|
||||
}
|
||||
return rf, err
|
||||
}
|
||||
|
||||
// purgeCountDisk counts the version deletes it receives.
|
||||
type purgeCountDisk struct {
|
||||
StorageAPI
|
||||
deletes *atomic.Int32
|
||||
}
|
||||
|
||||
func (d purgeCountDisk) DeleteVersion(ctx context.Context, volume, path string, fi FileInfo, forceDelMarker bool, opts DeleteOptions) error {
|
||||
d.deletes.Add(1)
|
||||
return d.StorageAPI.DeleteVersion(ctx, volume, path, fi, forceDelMarker, opts)
|
||||
}
|
||||
|
||||
// The purge deletes on the drive handles it checked, not on a handle that
|
||||
// replaced one of them since.
|
||||
func TestPurgeAllVersionsDeletesOnCheckedDrives(t *testing.T) {
|
||||
z := purgeSinglePool(t, 16)
|
||||
p := newPurgeTest(t, z, "purge-checked-drives", MakeBucketOptions{VersioningEnabled: true}, "")
|
||||
const key = "bx/swap"
|
||||
p.version(0, key, "v1", 5)
|
||||
exp := p.matched(key)
|
||||
sets := z.serverPools[0]
|
||||
index := sets.getHashedSetIndex(key)
|
||||
var reads, deletes atomic.Int32
|
||||
sets.erasureDisksMu.Lock()
|
||||
original := sets.erasureDisks[index][0]
|
||||
sets.erasureDisks[index][0] = purgeSwapDisk{StorageAPI: original, bucket: p.bucket, object: key, reads: &reads, swap: func() {
|
||||
sets.erasureDisksMu.Lock()
|
||||
sets.erasureDisks[index][0] = purgeCountDisk{StorageAPI: original, deletes: &deletes}
|
||||
sets.erasureDisksMu.Unlock()
|
||||
}}
|
||||
sets.erasureDisksMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
sets.erasureDisksMu.Lock()
|
||||
sets.erasureDisks[index][0] = original
|
||||
sets.erasureDisksMu.Unlock()
|
||||
})
|
||||
var res deleteAllResult
|
||||
if _, err := z.DeleteObject(t.Context(), p.bucket, key, ObjectOptions{
|
||||
DeleteAllVersions: true,
|
||||
DeleteAllLatest: &deleteAllMatch{VersionID: exp.VersionID, ModTime: exp.ModTime},
|
||||
DeleteAllResult: &res,
|
||||
deleteAllAuthorize: func([]ObjectInfo) error { return nil },
|
||||
}); err != nil || !res.Complete {
|
||||
t.Fatalf("purge: %v %+v", err, res)
|
||||
}
|
||||
if reads.Load() != 2 || deletes.Load() != 0 {
|
||||
t.Errorf("reads=%d deletes on the replacement handle=%d", reads.Load(), deletes.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// A batch purge has every copy of every version it approved authorized, as
|
||||
// the copy is now, before it deletes any; an authorization error deletes
|
||||
// nothing and is returned as it is.
|
||||
func TestPurgeAllVersionsAuthorizesEveryCopy(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
p := newPurgeTest(t, z, "purge-authorize", MakeBucketOptions{VersioningEnabled: true}, "")
|
||||
const key = "bx/authorize"
|
||||
p.put(0, key, "old", daysAgo(6), ObjectOptions{})
|
||||
p.put(1, key, "new", daysAgo(5), ObjectOptions{UserDefined: map[string]string{xhttp.AmzObjectTagging: "copy=new"}})
|
||||
tagged := p.put(0, key, "v1", daysAgo(4), ObjectOptions{Versioned: true, UserDefined: map[string]string{xhttp.AmzObjectTagging: "keep=yes"}})
|
||||
exp := p.matched(key)
|
||||
denied := errors.New("denied")
|
||||
var seen []string // the distinct versions and tags authorized
|
||||
purge := func(authorize func([]ObjectInfo) error) (deleteAllResult, error) {
|
||||
var res deleteAllResult
|
||||
_, err := z.DeleteObject(t.Context(), p.bucket, key, ObjectOptions{
|
||||
DeleteAllVersions: true,
|
||||
DeleteAllLatest: &deleteAllMatch{VersionID: exp.VersionID, ModTime: exp.ModTime},
|
||||
DeleteAllResult: &res,
|
||||
deleteAllAuthorize: authorize,
|
||||
})
|
||||
return res, err
|
||||
}
|
||||
res, err := purge(func(versions []ObjectInfo) error {
|
||||
for _, v := range versions {
|
||||
if got := v.VersionID + " " + v.UserTags; !slices.Contains(seen, got) {
|
||||
seen = append(seen, got)
|
||||
}
|
||||
}
|
||||
return denied
|
||||
})
|
||||
if !errors.Is(err, denied) || res.Started || res.Removed != 0 {
|
||||
t.Errorf("denied purge: err=%v %+v", err, res)
|
||||
}
|
||||
want := []string{"null ", "null copy=new", tagged.VersionID + " keep=yes"}
|
||||
slices.Sort(seen)
|
||||
slices.Sort(want)
|
||||
if !slices.Equal(seen, want) {
|
||||
t.Errorf("authorized %q, want every copy %q", seen, want)
|
||||
}
|
||||
for pool := range z.serverPools {
|
||||
if got := p.held(pool, key); len(got) == 0 {
|
||||
t.Errorf("pool %d lost its copies to a denied purge", pool)
|
||||
}
|
||||
}
|
||||
if got := p.get(key); got != "v1" {
|
||||
t.Errorf("reads %s after a denied purge, want v1", got)
|
||||
}
|
||||
// Control: an allowed purge deletes them all.
|
||||
if res, err = purge(func([]ObjectInfo) error { return nil }); err != nil || !res.Complete {
|
||||
t.Fatalf("allowed purge: %v %+v", err, res)
|
||||
}
|
||||
for pool := range z.serverPools {
|
||||
if got := p.held(pool, key); got != nil {
|
||||
t.Errorf("pool %d keeps %v", pool, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user