mirror of
https://github.com/pgsty/minio.git
synced 2026-10-19 16:06:10 +03:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 2d57b975d1 | |||
| cb85461c42 | |||
| ba6e8c8d69 | |||
| a54d0c4354 | |||
| 00d4a206f7 | |||
| f480086b6b | |||
| 968c30238c | |||
| 8c784f6133 | |||
| 258b167b1c |
@@ -93,8 +93,16 @@ jobs:
|
|||||||
- name: Run conditional PUT tests under race detector
|
- name: Run conditional PUT tests under race detector
|
||||||
run: go test -race ./cmd -run '^Test(PoolsConditionalPut|SinglePoolConditionalPutHTTP)' -count=1 -timeout=5m
|
run: go test -race ./cmd -run '^Test(PoolsConditionalPut|SinglePoolConditionalPutHTTP)' -count=1 -timeout=5m
|
||||||
|
|
||||||
- name: Run multipart listing and cancellation tests under race detector
|
- name: Run multipart listing, cancellation and cleanup tests under race detector
|
||||||
run: go test -race ./cmd -run '^Test(MultipartListing|MultipartAbort|PaginateMultipartUploads|ListMultipartUploads)' -count=1 -timeout=5m
|
env:
|
||||||
|
MINIO_API_REQUESTS_MAX: "10000"
|
||||||
|
run: go test -race ./cmd -run '^Test(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)' -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
|
- name: Run CPU metrics tests under race detector
|
||||||
run: go test -race ./cmd -run '^TestLoadCPUMetrics' -count=1 -timeout=5m
|
run: go test -race ./cmd -run '^TestLoadCPUMetrics' -count=1 -timeout=5m
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ jobs:
|
|||||||
buildscripts/verify-rebrand.sh
|
buildscripts/verify-rebrand.sh
|
||||||
dockerscripts/docker-entrypoint_test.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
|
- name: Compute release variables
|
||||||
env:
|
env:
|
||||||
# Passed through the environment, never interpolated into the script
|
# Passed through the environment, never interpolated into the script
|
||||||
@@ -134,7 +140,7 @@ jobs:
|
|||||||
- name: Build Draft release with GoReleaser
|
- name: Build Draft release with GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||||
with:
|
with:
|
||||||
version: v2.18.1
|
version: v2.18.3
|
||||||
args: release --clean --skip=validate --config .github/goreleaser.yml
|
args: release --clean --skip=validate --config .github/goreleaser.yml
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -150,7 +156,7 @@ jobs:
|
|||||||
- name: Install nFPM
|
- name: Install nFPM
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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}"
|
echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}"
|
||||||
|
|
||||||
- name: Build nFPM packages
|
- name: Build nFPM packages
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ jobs:
|
|||||||
- name: GoReleaser config check
|
- name: GoReleaser config check
|
||||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||||
with:
|
with:
|
||||||
version: v2.18.1
|
version: v2.18.3
|
||||||
args: check --config .github/goreleaser.yml
|
args: check --config .github/goreleaser.yml
|
||||||
|
|
||||||
- name: Validate Helm chart and legacy upgrade identity
|
- name: Validate Helm chart and legacy upgrade identity
|
||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
- name: Build snapshot artifacts
|
- name: Build snapshot artifacts
|
||||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||||
with:
|
with:
|
||||||
version: v2.18.1
|
version: v2.18.3
|
||||||
# A pull-request snapshot has no trusted release identity. Exercise
|
# A pull-request snapshot has no trusted release identity. Exercise
|
||||||
# the SBOM/checksum pipeline here, and reserve keyless signing for
|
# the SBOM/checksum pipeline here, and reserve keyless signing for
|
||||||
# the tag-triggered release workflow with GitHub OIDC.
|
# the tag-triggered release workflow with GitHub OIDC.
|
||||||
@@ -143,7 +143,7 @@ jobs:
|
|||||||
- name: Install package validation tools
|
- name: Install package validation tools
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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}"
|
echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}"
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --yes rpm binutils
|
sudo apt-get install --yes rpm binutils
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install govulncheck
|
- name: Install govulncheck
|
||||||
run: |
|
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}"
|
echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}"
|
||||||
|
|
||||||
- name: Run govulncheck
|
- name: Run govulncheck
|
||||||
|
|||||||
+48
-35
@@ -2,12 +2,34 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
Preparation target: `RELEASE.2026-09-16T00-00-00Z` (package version
|
Preparation target: `RELEASE.2026-10-18T00-00-00Z` (package version
|
||||||
`20260916000000.0.0`). The entries below describe the candidate changes since
|
`20261018000000.0.0`). **This is a tag and Draft preparation; it is not a public
|
||||||
the latest published Server.
|
Server release.** The latest published Server remains 20260916. Server 20261015
|
||||||
**The latest published Server remains 20260903.** These changes are not in its
|
also remains a Draft. Existing binaries, packages and images do not change.
|
||||||
binaries, packages or images. See the [component matrix](https://silo.pgsty.com/compatibility/versions/)
|
See the [component matrix](https://silo.pgsty.com/compatibility/versions/) and
|
||||||
and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-09-03T13-18-01Z...main).
|
[changes since the public release](https://github.com/pgsty/silo/compare/RELEASE.2026-09-16T00-00-00Z...main).
|
||||||
|
|
||||||
|
### October 18 dependency update
|
||||||
|
|
||||||
|
- Select silo-pkg v3.14.3, upstream minio-go v7.4.1, published mcli 20261018
|
||||||
|
and Console v2.4.2 source (`ef2dd2c41d21`). The SDK includes cancellation cleanup, UTC retention
|
||||||
|
timestamps and preserved STS duration; parallel GET/Compose remain opt-in.
|
||||||
|
- Adopt silo-pkg's faster admin-action classification during policy evaluation,
|
||||||
|
preserving authorization behavior and compatibility with existing clients.
|
||||||
|
Update the standalone `reorder-disks` debugging module to the same package version.
|
||||||
|
- Build with Go 1.27.2 and refresh Go x/* modules. Apply 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 bug/security fixes. OIDC JWK RSA keys must not exceed 4096 bits.
|
||||||
|
- Update the bundled mcli archive hashes, installation helper and Helm client
|
||||||
|
image to the published 20261018 release. Server Helm image defaults retain the
|
||||||
|
public 20260916 version until a Server image is published.
|
||||||
|
- Refresh pinned UBI 9 images and use GoReleaser v2.18.3, nFPM v2.48.0,
|
||||||
|
golangci-lint v2.15.0 and govulncheck v1.9.0. Keep the NetBSD go-systemd
|
||||||
|
v22.6.0 compatibility replacement.
|
||||||
|
|
||||||
|
The sections below retain the cumulative maintenance history, including changes
|
||||||
|
already shipped in 20260916; the source comparison above identifies this
|
||||||
|
candidate's exact delta.
|
||||||
|
|
||||||
### Authorization and security
|
### Authorization and security
|
||||||
|
|
||||||
@@ -54,35 +76,26 @@ and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-0
|
|||||||
|
|
||||||
### Object storage and replication
|
### Object storage and replication
|
||||||
|
|
||||||
- Make `ListMultipartUploads` discover quorum-valid uploads from durable state
|
- Withdraw default persistent `ListMultipartUploads` scanning (#79). Restore
|
||||||
across pools, erasure sets and drives, then apply S3 prefix, delimiter,
|
node-local cache listing for an empty prefix and exact-key lookup otherwise;
|
||||||
marker, ordering and 1,000-entry pagination semantics globally (#198). New uploads
|
restart omissions, true-prefix and cache-pagination limitations remain.
|
||||||
store their canonical bucket and key as reserved fields in the existing
|
Preserve bucket/key identity metadata, path safety and stale-upload cleanup.
|
||||||
quorum-written `xl.meta`; completion removes those upload-only fields. Native
|
`MINIO_API_MULTIPART_LISTING` stays retired and ignored with a startup warning;
|
||||||
markers remain usable after their upload is completed or canceled. Strict
|
obsolete shared `api multipart_listing` keys remain tolerated. **Existing
|
||||||
listing returns a diagnostic 503 for legacy uploads or uncertain coverage;
|
opt-in strict users lose durable listing and strict cancellation.** The
|
||||||
the default remains the released exact-key/cache-based `legacy` behavior.
|
read-only admin `multipart-preflight` reports `mode=legacy`; its scan budgets
|
||||||
Opt into strict mode only through `MINIO_API_MULTIPART_LISTING=strict`, after
|
and readiness do not gate public listing or startup. Durable, complete,
|
||||||
upgrading every writer, draining old uploads, checking the read-only admin
|
scalable enumeration remains deferred. See the
|
||||||
`multipart-preflight` report and validating scan capacity. The process-only
|
[design and limits](https://silo.pgsty.com/blog/design/list-multipart-uploads/#implementation).
|
||||||
setting is not persisted into shared API configuration. Historical
|
Thanks to mr javad seydi (@mrjavadseydi) for the original metadata-and-scan
|
||||||
`multipart_listing` keys are ignored and can be removed with a targeted
|
implementation (#198).
|
||||||
`mcli admin config reset ALIAS api multipart_listing` before rollback. Per-process
|
- Keep the default read-quorum, best-effort multipart cancellation and
|
||||||
admission, directory-entry, worker and time budgets bound scan scheduling;
|
first-matching-pool behavior when retiring the listing switch. The optional
|
||||||
each page still scans durable state. See [issue #79](https://github.com/pgsty/silo/issues/79)
|
strict cancellation branch is removed. Wrong-target protection, upload-ID
|
||||||
and its [design record](https://silo.pgsty.com/blog/design/list-multipart-uploads/).
|
path validation and peer notification with the request context remain.
|
||||||
Thanks to mr javad seydi (@mrjavadseydi) for the original implementation.
|
HTTP 204 for an absent upload remains idempotent and does not prove physical
|
||||||
- Retain released read-quorum and best-effort multipart cancellation in default
|
cleanup. **Known boundary:** delayed creation writes can still restore a
|
||||||
legacy mode. Strict mode requires majority deletion acknowledgements and
|
readable upload after cancellation; this change adds no creation fence.
|
||||||
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
|
- Preserve object tags during multi-pool metadata reconciliation by reading the
|
||||||
resolved tag field together with its revision (#189). Previously, reconciliation
|
resolved tag field together with its revision (#189). Previously, reconciliation
|
||||||
could replace existing tags with an empty value.
|
could replace existing tags with an empty value.
|
||||||
|
|||||||
+58
-30
File diff suppressed because one or more lines are too long
@@ -17,9 +17,9 @@ ENV GOPATH=/go
|
|||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
ARG MC_REPO=pgsty/mc
|
ARG MC_REPO=pgsty/mc
|
||||||
ARG MC_VERSION=RELEASE.2026-09-16T00-00-00Z
|
ARG MC_VERSION=RELEASE.2026-10-18T00-00-00Z
|
||||||
ARG MC_AMD64_SHA256=4ba2814fd5507fbe6b4d237c359750b9119d28d7217495fa5b48002fcbd397ef
|
ARG MC_AMD64_SHA256=b5392a642dbc9685b517ee901f032d1d5e88f41704436f0f08dde6060b577db3
|
||||||
ARG MC_ARM64_SHA256=b7008ca2a1bc5735b6585981c59a3640a0daa152dc789df3d1a0d0438787de82
|
ARG MC_ARM64_SHA256=17f3eb694ed34e155b331b1215832140749656d8d4b60e2e3a4d0573b8f320d7
|
||||||
|
|
||||||
RUN apk add -U --no-cache \
|
RUN apk add -U --no-cache \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
@@ -67,14 +67,14 @@ RUN apk add -U --no-cache \
|
|||||||
chmod +x /go/bin/mcli && \
|
chmod +x /go/bin/mcli && \
|
||||||
ln -sf mcli /go/bin/mc
|
ln -sf mcli /go/bin/mc
|
||||||
|
|
||||||
FROM registry.access.redhat.com/ubi9/ubi:latest@sha256:206b65b8ee0f04b992818c9a51b29081b14974630d4850bc358097d0c44ea156 AS certs
|
FROM registry.access.redhat.com/ubi9/ubi:latest@sha256:16e9e43d99e181463513cc84a514859b1abb9460f1c4d9e208da4b4e51cd5cc3 AS certs
|
||||||
RUN dnf -y install ca-certificates && \
|
RUN dnf -y install ca-certificates && \
|
||||||
update-ca-trust && \
|
update-ca-trust && \
|
||||||
cp /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem /tmp/ca-certificates.crt && \
|
cp /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem /tmp/ca-certificates.crt && \
|
||||||
dnf clean all && \
|
dnf clean all && \
|
||||||
rm -rf /var/cache/dnf
|
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" \
|
LABEL org.opencontainers.image.title="Silo" \
|
||||||
org.opencontainers.image.description="S3-Interface Libre Object Storage" \
|
org.opencontainers.image.description="S3-Interface Libre Object Storage" \
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ LDFLAGS := $(shell go run buildscripts/gen-ldflags.go)
|
|||||||
|
|
||||||
GOOS ?= $(shell go env GOOS)
|
GOOS ?= $(shell go env GOOS)
|
||||||
GOARCH ?= $(shell go env GOARCH)
|
GOARCH ?= $(shell go env GOARCH)
|
||||||
GOLANGCI_VERSION ?= v2.14.0
|
GOLANGCI_VERSION ?= v2.15.0
|
||||||
|
|
||||||
VERSION ?= $(shell git describe --tags)
|
VERSION ?= $(shell git describe --tags)
|
||||||
REPO ?= docker.io/pgsty
|
REPO ?= docker.io/pgsty
|
||||||
|
|||||||
@@ -35,13 +35,13 @@
|
|||||||
> [!NOTE]
|
> [!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).
|
> 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 20261018000000
|
||||||
|
|
||||||
The latest published Server is [20260903](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-09-03T13-18-01Z).
|
[SILO 20261018000000](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-10-18T00-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-18T00-00-00Z`; the package version is `20261018000000.0.0`.
|
||||||
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
|
This release strengthens Object Lock and IAM authorization, makes scoped STS revocation durable across processes and sites, protects concurrent updates during pool migration, and improves LIST recovery, multipart cleanup, and S3 error handling. It includes Console v2.4.2 and silo-pkg v3.14.3, and is built with Go 1.27.2. Use mcli 20261018 for administration.
|
||||||
[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.
|
Read the [release notes](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-10-18T00-00-00Z) before upgrading, especially the removal of strict multipart listing, coordinated server upgrades, and measured performance costs. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the people behind the code, reports, and documentation.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ docker run -d --name silo -p 9000:9000 -p 9001:9001 \
|
|||||||
-e MINIO_ROOT_USER=minioadmin \
|
-e MINIO_ROOT_USER=minioadmin \
|
||||||
-e MINIO_ROOT_PASSWORD=change-me-long-password \
|
-e MINIO_ROOT_PASSWORD=change-me-long-password \
|
||||||
-v "$PWD/data:/data" \
|
-v "$PWD/data:/data" \
|
||||||
docker.io/pgsty/silo:latest server /data --console-address ":9001"
|
docker.io/pgsty/silo:RELEASE.2026-10-18T00-00-00Z server /data --console-address ":9001"
|
||||||
```
|
```
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -101,8 +101,8 @@ Every divergence from upstream is listed in the code-verified [compatibility aud
|
|||||||
|
|
||||||
### TLS and Go upgrades
|
### TLS and Go upgrades
|
||||||
|
|
||||||
The following TLS repair is on main and is not included in Server 20260903.
|
Server 20260916 restored Go's default key exchange; Server 20261018 retains it with Go 1.27.2.
|
||||||
With that repair, TLS key exchange follows Go's defaults across the S3 listener, node links,
|
TLS key exchange follows Go's defaults across the S3 listener, node links,
|
||||||
replication, identity providers, etcd, and external HTTP services. If an endpoint
|
replication, identity providers, etcd, and external HTTP services. If an endpoint
|
||||||
cannot accept ML-KEM, `GODEBUG=tlsmlkem=0` disables the default hybrid exchanges
|
cannot accept ML-KEM, `GODEBUG=tlsmlkem=0` disables the default hybrid exchanges
|
||||||
for the process; certificate verification remains enabled. This option does not
|
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. -->
|
<!-- 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">
|
<a href="CONTRIBUTORS.md">
|
||||||
<picture>
|
<picture>
|
||||||
|
|||||||
+9
-8
@@ -35,12 +35,13 @@
|
|||||||
> [!NOTE]
|
> [!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) 的历次发布中。
|
> 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 20261018000000
|
||||||
|
|
||||||
最新已发布的 Server 仍为 [20260903](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-09-03T13-18-01Z)。
|
[SILO 20261018000000](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-10-18T00-00-00Z) 已正式发布,提供各平台归档包、Linux 软件包,以及 amd64/arm64 标准版和 distroless Docker 镜像。版本标签为 `RELEASE.2026-10-18T00-00-00Z`,包版本为 `20261018000000.0.0`。
|
||||||
截至 2026-09-16,主分支已合入更新的安全、存储、Console 与共享包改动,但尚未发布新 Server。
|
|
||||||
准确的已发布/源码边界见 [CHANGELOG.md](CHANGELOG.md) 与[组件版本矩阵](https://silo.pgsty.com/zh/compatibility/versions/),
|
本版加强 Object Lock 与 IAM 鉴权,实现跨进程和站点的持久 STS 撤销,保护多池迁移中的并发更新,并改进 LIST 恢复、分片清理与 S3 错误处理。内置 Console v2.4.2、silo-pkg v3.14.3,使用 Go 1.27.2 构建,配套管理客户端为 mcli 20261018。
|
||||||
其中包括 SN-2026-011 修复状态与密码权限迁移要求。
|
|
||||||
|
升级前请阅读[发行说明](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-10-18T00-00-00Z),特别是 strict 分片列表的移除、服务端协调升级要求与实测性能成本。代码、问题报告和文档贡献者见 [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_USER=minioadmin \
|
||||||
-e MINIO_ROOT_PASSWORD=change-me-long-password \
|
-e MINIO_ROOT_PASSWORD=change-me-long-password \
|
||||||
-v "$PWD/data:/data" \
|
-v "$PWD/data:/data" \
|
||||||
docker.io/pgsty/silo:latest server /data --console-address ":9001"
|
docker.io/pgsty/silo:RELEASE.2026-10-18T00-00-00Z server /data --console-address ":9001"
|
||||||
```
|
```
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -100,7 +101,7 @@ Silo 保留 S3 与存储格式兼容性,包括既有 `MINIO_*` 环境变量、
|
|||||||
|
|
||||||
### TLS 与 Go 升级
|
### TLS 与 Go 升级
|
||||||
|
|
||||||
Server 恢复 Go 默认密钥交换策略的修复已在 main,尚未包含在 Server 20260903。
|
Server 20260916 已恢复 Go 默认密钥交换策略,20261018 在 Go 1.27.2 下保留该行为。
|
||||||
`GODEBUG=tlsmlkem=0`、`tlssecpmlkem=0` 的适用范围、macOS 根证书来源变化,
|
`GODEBUG=tlsmlkem=0`、`tlssecpmlkem=0` 的适用范围、macOS 根证书来源变化,
|
||||||
以及 OIDC discovery 的诊断方法见 [Go 1.27 TLS 与 OIDC 指南](https://silo.pgsty.com/zh/blog/design/go127-tls-oidc-discovery/)。
|
以及 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. -->
|
<!-- Generated by silo.pgsty.com/bin/contributors.py. -->
|
||||||
|
|
||||||
每位 issue 或 PR 的作者都是 SILO 社区的一员,包括尚未合并的工作。已合并的修复、被采纳的方案和有效报告优先展示,其余参考首次参与时间;金色圆环突出经过审核的显著贡献。
|
每位 issue 或 PR 的作者,以及同意公开署名的安全报告者,都是 SILO 社区的一员,包括尚未合并的工作。已合并的修复、被采纳的方案和有效报告优先展示,其余参考首次参与时间;金色圆环突出经过审核的显著贡献。
|
||||||
|
|
||||||
<a href="CONTRIBUTORS.md">
|
<a href="CONTRIBUTORS.md">
|
||||||
<picture>
|
<picture>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ if [ -n "${MCLI_BIN:-}" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
release=${MCLI_RELEASE:-RELEASE.2026-09-16T00-00-00Z}
|
release=${MCLI_RELEASE:-RELEASE.2026-10-18T00-00-00Z}
|
||||||
version_hyphen=${release#RELEASE.}
|
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/')
|
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
|
if [ "${package_version}" = "${version_hyphen}" ]; then
|
||||||
|
|||||||
@@ -11,6 +11,33 @@ fail() {
|
|||||||
exit 1
|
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() {
|
require_file() {
|
||||||
[ -f "$1" ] || fail "missing required file: $1"
|
[ -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"
|
fail "silo.env must not contain active assignments that shadow /etc/default/minio"
|
||||||
fi
|
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"
|
fail "an active delivery surface still publishes the frozen pgsty/minio image"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -165,10 +196,10 @@ fi
|
|||||||
# So two rules. First, no live URL may resolve to the old repository anywhere,
|
# So two rules. First, no live URL may resolve to the old repository anywhere,
|
||||||
# READMEs and CONTRIBUTORS.md included.
|
# READMEs and CONTRIBUTORS.md included.
|
||||||
old_repo_pattern='pgsty/minio(\.git)?([^[:alnum:]_.-]|$)'
|
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}" \
|
stale_repo_url="$(search_text -e "github\.com/${old_repo_pattern}" -e "hub\.docker\.com/r/${old_repo_pattern}" -- . \
|
||||||
--glob '!.git/**' --glob '!dist/**' \
|
"${source_excludes[@]}" ':(exclude)SILO_REBRANDING_MIGRATION.md' \
|
||||||
--glob '!SILO_REBRANDING_MIGRATION.md' \
|
':(exclude)buildscripts/rebrand-guard/compat-baseline.json')"
|
||||||
--glob '!buildscripts/rebrand-guard/compat-baseline.json' . |
|
stale_repo_url="$(printf '%s\n' "${stale_repo_url}" |
|
||||||
sed 's#^\./##' | grep -v '^buildscripts/verify-rebrand\.sh:' || true)"
|
sed 's#^\./##' | grep -v '^buildscripts/verify-rebrand\.sh:' || true)"
|
||||||
if [ -n "${stale_repo_url}" ]; then
|
if [ -n "${stale_repo_url}" ]; then
|
||||||
printf '%s\n' "${stale_repo_url}" >&2
|
printf '%s\n' "${stale_repo_url}" >&2
|
||||||
@@ -180,32 +211,32 @@ fi
|
|||||||
# legacy image, the two READMEs that document the rename and the archived
|
# legacy image, the two READMEs that document the rename and the archived
|
||||||
# minio branch, and historical issue titles in CONTRIBUTORS.md.
|
# 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):'
|
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/**' \
|
stale_repo="$(search_text "${old_repo_pattern}" -- . "${source_excludes[@]}" \
|
||||||
--glob '!SILO_REBRANDING_MIGRATION.md' \
|
':(exclude)SILO_REBRANDING_MIGRATION.md' ':(exclude)buildscripts/rebrand-guard/compat-baseline.json')"
|
||||||
--glob '!buildscripts/rebrand-guard/compat-baseline.json' . |
|
stale_repo="$(printf '%s\n' "${stale_repo}" |
|
||||||
sed 's#^\./##' | grep -Ev "${repo_guard_allowlist}" || true)"
|
sed 's#^\./##' | grep -Ev "${repo_guard_allowlist}" || true)"
|
||||||
if [ -n "${stale_repo}" ]; then
|
if [ -n "${stale_repo}" ]; then
|
||||||
printf '%s\n' "${stale_repo}" >&2
|
printf '%s\n' "${stale_repo}" >&2
|
||||||
fail "a source reference still names the pre-rename pgsty/minio repository"
|
fail "a source reference still names the pre-rename pgsty/minio repository"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
stale_branch="$(rg -n 'pgsty/silo/(blob/|tree/|raw/)?master' \
|
stale_branch="$(search_text 'pgsty/silo/(blob/|tree/|raw/)?master' -- . "${source_excludes[@]}")"
|
||||||
--glob '!.git/**' --glob '!dist/**' . || true)"
|
|
||||||
if [ -n "${stale_branch}" ]; then
|
if [ -n "${stale_branch}" ]; then
|
||||||
printf '%s\n' "${stale_branch}" >&2
|
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"
|
fail "a link still targets the retired master branch; raw and Actions URLs do not follow a branch rename"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for workflow in .github/workflows/go.yml .github/workflows/vulncheck.yml; do
|
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"
|
fail "${workflow} still filters on master and would go silently dormant on main"
|
||||||
fi
|
fi
|
||||||
require_text "${workflow}" " - main"
|
require_text "${workflow}" " - main"
|
||||||
done
|
done
|
||||||
|
|
||||||
network_hits="$(rg -n --glob '*.go' --glob '!**/*_test.go' \
|
network_hits="$(search_text \
|
||||||
'https?://[^"`[:space:]]*(dl\.min\.io|subnet\.min\.io|api\.min\.io|slack\.min\.io|play\.min\.io)' \
|
'https?://[^"`[:space:]]*(dl\.min\.io|subnet\.min\.io|api\.min\.io|slack\.min\.io|play\.min\.io)' -- \
|
||||||
cmd internal || true)"
|
':(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)"
|
network_hits="$(printf '%s\n' "${network_hits}" | grep -Ev '^[^:]+:[0-9]+:[[:space:]]*//' || true)"
|
||||||
if [ -n "${network_hits}" ]; then
|
if [ -n "${network_hits}" ]; then
|
||||||
printf '%s\n' "${network_hits}" >&2
|
printf '%s\n' "${network_hits}" >&2
|
||||||
|
|||||||
@@ -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
|
// In case of LDAP/OIDC we need to set `opts.claims` to ensure
|
||||||
// it is associated with the LDAP/OIDC user properly.
|
// 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 {
|
for k, v := range cred.Claims {
|
||||||
if k == expClaim {
|
switch k {
|
||||||
|
case expClaim, policy.SessionPolicyName, sessionPolicyNameExtracted, iamPolicyClaimNameSA():
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
opts.claims[k] = v
|
opts.claims[k] = v
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
-68
@@ -714,8 +714,10 @@ func (a adminAPIHandlers) AddServiceAccount(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
// In case of LDAP/OIDC we need to set `opts.claims` to ensure
|
// In case of LDAP/OIDC we need to set `opts.claims` to ensure
|
||||||
// it is associated with the LDAP/OIDC user properly.
|
// 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 {
|
for k, v := range cred.Claims {
|
||||||
if k == expClaim {
|
switch k {
|
||||||
|
case expClaim, policy.SessionPolicyName, sessionPolicyNameExtracted, iamPolicyClaimNameSA():
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
opts.claims[k] = v
|
opts.claims[k] = v
|
||||||
@@ -855,10 +857,9 @@ func (a adminAPIHandlers) UpdateServiceAccount(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
// Permission checks:
|
// Permission checks:
|
||||||
//
|
//
|
||||||
// 1. Any type of account (i.e. access keys (previously/still called service
|
// 1. Updating requires policy.UpdateServiceAccountAdminAction. Under built-in
|
||||||
// accounts), STS accounts, internal IDP accounts, etc) with the
|
// IAM, session-limited callers cannot update credentials, and only unrestricted
|
||||||
// policy.UpdateServiceAccountAdminAction permission can update any service
|
// root credentials can manage root's service accounts.
|
||||||
// account.
|
|
||||||
//
|
//
|
||||||
// 2. We would like to let a user update their own access keys, however it
|
// 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
|
// is currently blocked pending a re-design. Users are still able to delete
|
||||||
@@ -874,6 +875,11 @@ func (a adminAPIHandlers) UpdateServiceAccount(w http.ResponseWriter, r *http.Re
|
|||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||||
return
|
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
|
var sp *policy.Policy
|
||||||
if len(updateReq.NewPolicy) > 0 {
|
if len(updateReq.NewPolicy) > 0 {
|
||||||
@@ -951,24 +957,22 @@ func (a adminAPIHandlers) InfoServiceAccount(w http.ResponseWriter, r *http.Requ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !globalIAMSys.IsAllowed(policy.Args{
|
requestUser := cred.AccessKey
|
||||||
|
if cred.ParentUser != "" {
|
||||||
|
requestUser = cred.ParentUser
|
||||||
|
}
|
||||||
|
if !isAllowedServiceAccountAction(cred, policy.Args{
|
||||||
AccountName: cred.AccessKey,
|
AccountName: cred.AccessKey,
|
||||||
Groups: cred.Groups,
|
Groups: cred.Groups,
|
||||||
Action: policy.ListServiceAccountsAdminAction,
|
Action: policy.ListServiceAccountsAdminAction,
|
||||||
ConditionValues: getConditionValues(r, "", cred),
|
ConditionValues: getConditionValues(r, "", cred),
|
||||||
IsOwner: owner,
|
IsOwner: owner,
|
||||||
Claims: cred.Claims,
|
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)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// if session policy is nil or empty, then it is implied policy
|
// if session policy is nil or empty, then it is implied policy
|
||||||
impliedPolicy := sessionPolicy == nil || (sessionPolicy.Version == "" && len(sessionPolicy.Statements) == 0)
|
impliedPolicy := sessionPolicy == nil || (sessionPolicy.Version == "" && len(sessionPolicy.Statements) == 0)
|
||||||
@@ -1039,29 +1043,27 @@ func (a adminAPIHandlers) ListServiceAccounts(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetAccount string
|
selfUser := cred.AccessKey
|
||||||
|
if cred.ParentUser != "" {
|
||||||
// If listing is requested for a specific user (who is not the request
|
selfUser = cred.ParentUser
|
||||||
// sender), check that the user has permissions.
|
}
|
||||||
user := r.Form.Get("user")
|
user := r.Form.Get("user")
|
||||||
if user != "" && user != cred.AccessKey {
|
selfOnly := user == "" || user == cred.AccessKey || user == selfUser
|
||||||
if !globalIAMSys.IsAllowed(policy.Args{
|
if !isAllowedServiceAccountAction(cred, policy.Args{
|
||||||
AccountName: cred.AccessKey,
|
AccountName: cred.AccessKey,
|
||||||
Groups: cred.Groups,
|
Groups: cred.Groups,
|
||||||
Action: policy.ListServiceAccountsAdminAction,
|
Action: policy.ListServiceAccountsAdminAction,
|
||||||
ConditionValues: getConditionValues(r, "", cred),
|
ConditionValues: getConditionValues(r, "", cred),
|
||||||
IsOwner: owner,
|
IsOwner: owner,
|
||||||
Claims: cred.Claims,
|
Claims: cred.Claims,
|
||||||
|
DenyOnly: selfOnly,
|
||||||
}) {
|
}) {
|
||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
targetAccount = user
|
targetAccount := user
|
||||||
} else {
|
if selfOnly {
|
||||||
targetAccount = cred.AccessKey
|
targetAccount = selfUser
|
||||||
if cred.ParentUser != "" {
|
|
||||||
targetAccount = cred.ParentUser
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceAccounts, err := globalIAMSys.ListServiceAccounts(ctx, targetAccount)
|
serviceAccounts, err := globalIAMSys.ListServiceAccounts(ctx, targetAccount)
|
||||||
@@ -1110,7 +1112,7 @@ func (a adminAPIHandlers) DeleteServiceAccount(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
// Get current object layer instance.
|
// Get current object layer instance.
|
||||||
objectAPI := newObjectLayerFn()
|
objectAPI := newObjectLayerFn()
|
||||||
if objectAPI == nil || globalNotificationSys == nil {
|
if objectAPI == nil || globalNotificationSys == nil || !globalIAMSys.Initialized() {
|
||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1131,35 +1133,41 @@ func (a adminAPIHandlers) DeleteServiceAccount(w http.ResponseWriter, r *http.Re
|
|||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrInvalidArgument), r.URL)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrInvalidArgument), r.URL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// We do not care if service account is readable or not at this point,
|
// Deletion needs the stored identity, not a valid token or session policy.
|
||||||
// since this is a delete call we shall allow it to be deleted if possible.
|
// Keep disabled, expired and damaged service accounts removable, and retain
|
||||||
svcAccount, _, err := globalIAMSys.GetServiceAccount(ctx, serviceAccount)
|
// their parent for authorization and the site replication hook below.
|
||||||
if errors.Is(err, errNoSuchServiceAccount) {
|
u, ok := globalIAMSys.store.GetUser(serviceAccount)
|
||||||
|
if !ok || !u.Credentials.IsServiceAccount() {
|
||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminServiceAccountNotFound), r.URL)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminServiceAccountNotFound), r.URL)
|
||||||
return
|
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,
|
AccountName: cred.AccessKey,
|
||||||
Groups: cred.Groups,
|
Groups: cred.Groups,
|
||||||
Action: policy.RemoveServiceAccountAdminAction,
|
Action: policy.RemoveServiceAccountAdminAction,
|
||||||
ConditionValues: getConditionValues(r, "", cred),
|
ConditionValues: getConditionValues(r, "", cred),
|
||||||
IsOwner: owner,
|
IsOwner: owner,
|
||||||
Claims: cred.Claims,
|
Claims: cred.Claims,
|
||||||
})
|
DenyOnly: isSelf,
|
||||||
|
}) {
|
||||||
if !adminPrivilege {
|
if !isSelf {
|
||||||
parentUser := cred.AccessKey
|
// Hide the existence of another user's service account.
|
||||||
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.
|
|
||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminServiceAccountNotFound), r.URL)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminServiceAccountNotFound), r.URL)
|
||||||
return
|
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 {
|
if err := globalIAMSys.DeleteServiceAccount(ctx, serviceAccount, true); err != nil {
|
||||||
@@ -1229,7 +1237,7 @@ func (a adminAPIHandlers) ListAccessKeysBulk(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !globalIAMSys.IsAllowed(policy.Args{
|
if !isAllowedServiceAccountAction(cred, policy.Args{
|
||||||
AccountName: cred.AccessKey,
|
AccountName: cred.AccessKey,
|
||||||
Groups: cred.Groups,
|
Groups: cred.Groups,
|
||||||
Action: policy.ListServiceAccountsAdminAction,
|
Action: policy.ListServiceAccountsAdminAction,
|
||||||
@@ -1242,12 +1250,12 @@ func (a adminAPIHandlers) ListAccessKeysBulk(w http.ResponseWriter, r *http.Requ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if selfOnly && len(users) == 0 {
|
if selfOnly {
|
||||||
selfUser := cred.AccessKey
|
selfUser := cred.AccessKey
|
||||||
if cred.ParentUser != "" {
|
if cred.ParentUser != "" {
|
||||||
selfUser = cred.ParentUser
|
selfUser = cred.ParentUser
|
||||||
}
|
}
|
||||||
users = append(users, selfUser)
|
users = []string{selfUser}
|
||||||
}
|
}
|
||||||
|
|
||||||
var checkedUserList []string
|
var checkedUserList []string
|
||||||
@@ -2046,26 +2054,30 @@ func (a adminAPIHandlers) RevokeTokens(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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,
|
AccountName: cred.AccessKey,
|
||||||
Groups: cred.Groups,
|
Groups: cred.Groups,
|
||||||
Action: policy.RemoveServiceAccountAdminAction,
|
Action: policy.RemoveServiceAccountAdminAction,
|
||||||
ConditionValues: getConditionValues(r, "", cred),
|
ConditionValues: getConditionValues(r, "", cred),
|
||||||
IsOwner: owner,
|
IsOwner: owner,
|
||||||
Claims: cred.Claims,
|
Claims: cred.Claims,
|
||||||
})
|
DenyOnly: isSelfTarget,
|
||||||
|
}) {
|
||||||
if !adminPrivilege || isTokenSelfRevoke {
|
|
||||||
parentUser := cred.AccessKey
|
|
||||||
if cred.ParentUser != "" {
|
|
||||||
parentUser = cred.ParentUser
|
|
||||||
}
|
|
||||||
if !isTokenSelfRevoke && user != parentUser {
|
|
||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if isSelfTarget {
|
||||||
user = parentUser
|
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.
|
// Infer token revoke type from the request if requestor is STS.
|
||||||
if isTokenSelfRevoke && tokenRevokeType == "" && !fullRevoke {
|
if isTokenSelfRevoke && tokenRevokeType == "" && !fullRevoke {
|
||||||
@@ -2112,31 +2124,24 @@ func (a adminAPIHandlers) InfoAccessKey(w http.ResponseWriter, r *http.Request)
|
|||||||
u, ok := globalIAMSys.GetUser(ctx, accessKey)
|
u, ok := globalIAMSys.GetUser(ctx, accessKey)
|
||||||
targetCred := u.Credentials
|
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,
|
AccountName: cred.AccessKey,
|
||||||
Groups: cred.Groups,
|
Groups: cred.Groups,
|
||||||
Action: policy.ListServiceAccountsAdminAction,
|
Action: policy.ListServiceAccountsAdminAction,
|
||||||
ConditionValues: getConditionValues(r, "", cred),
|
ConditionValues: getConditionValues(r, "", cred),
|
||||||
IsOwner: owner,
|
IsOwner: owner,
|
||||||
Claims: cred.Claims,
|
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)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
requestUser := cred.AccessKey
|
|
||||||
if cred.ParentUser != "" {
|
|
||||||
requestUser = cred.ParentUser
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestUser != targetCred.ParentUser {
|
|
||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminNoSuchAccessKey), r.URL)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminNoSuchAccessKey), r.URL)
|
||||||
return
|
return
|
||||||
@@ -2908,6 +2913,44 @@ func addExpirationToCondValues(exp *time.Time, condValues map[string][]string) e
|
|||||||
return nil
|
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...)
|
||||||
|
return err == nil && globalIAMSys.GetCombinedPolicy(policies...).IsAllowed(args)
|
||||||
|
}
|
||||||
|
return globalIAMSys.IsAllowed(args)
|
||||||
|
}
|
||||||
|
|
||||||
func commonAddServiceAccount(r *http.Request, ldap bool) (context.Context, auth.Credentials, newServiceAccountOpts, madmin.AddServiceAccountReq, string, APIError) {
|
func commonAddServiceAccount(r *http.Request, ldap bool) (context.Context, auth.Credentials, newServiceAccountOpts, madmin.AddServiceAccountReq, string, APIError) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
@@ -2996,6 +3039,14 @@ func commonAddServiceAccount(r *http.Request, ldap bool) (context.Context, auth.
|
|||||||
}) {
|
}) {
|
||||||
return ctx, auth.Credentials{}, newServiceAccountOpts{}, madmin.AddServiceAccountReq{}, "", errorCodes.ToAPIErr(ErrAccessDenied)
|
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
|
var sp *policy.Policy
|
||||||
if len(createReq.Policy) > 0 {
|
if len(createReq.Policy) > 0 {
|
||||||
@@ -3003,6 +3054,12 @@ func commonAddServiceAccount(r *http.Request, ldap bool) (context.Context, auth.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ctx, auth.Credentials{}, newServiceAccountOpts{}, madmin.AddServiceAccountReq{}, "", toAdminAPIErr(ctx, err)
|
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-3
@@ -450,7 +450,7 @@ const (
|
|||||||
ErrAdminNoSecretKey
|
ErrAdminNoSecretKey
|
||||||
|
|
||||||
ErrIAMNotInitialized
|
ErrIAMNotInitialized
|
||||||
ErrMultipartListingLegacy
|
ErrMultipartListingLegacy // Reserved: legacy uploads no longer block listing.
|
||||||
ErrMultipartListingIdentity
|
ErrMultipartListingIdentity
|
||||||
ErrSlowDown
|
ErrSlowDown
|
||||||
|
|
||||||
@@ -2191,8 +2191,6 @@ func toAPIErrorCode(ctx context.Context, err error) (apiErr APIErrorCode) {
|
|||||||
err = unwrapAll(err)
|
err = unwrapAll(err)
|
||||||
|
|
||||||
switch err {
|
switch err {
|
||||||
case errMultipartListingLegacy:
|
|
||||||
apiErr = ErrMultipartListingLegacy
|
|
||||||
case errMultipartListingIdentity:
|
case errMultipartListingIdentity:
|
||||||
apiErr = ErrMultipartListingIdentity
|
apiErr = ErrMultipartListingIdentity
|
||||||
case errCompleteMultipartChecksumMismatch, errCompleteMultipartChecksumTypeMismatch:
|
case errCompleteMultipartChecksumMismatch, errCompleteMultipartChecksumTypeMismatch:
|
||||||
@@ -2505,6 +2503,14 @@ func toAPIError(ctx context.Context, err error) APIError {
|
|||||||
case errors.Is(err, errMissingPartChecksum):
|
case errors.Is(err, errMissingPartChecksum):
|
||||||
apiErr.Description = strings.TrimPrefix(err.Error(), errMissingPartChecksum.Error()+": ")
|
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 {
|
switch apiErr.Code {
|
||||||
case "NotImplemented":
|
case "NotImplemented":
|
||||||
apiErr = APIError{
|
apiErr = APIError{
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import (
|
|||||||
"github.com/minio/minio/internal/auth"
|
"github.com/minio/minio/internal/auth"
|
||||||
"github.com/minio/minio/internal/color"
|
"github.com/minio/minio/internal/color"
|
||||||
"github.com/minio/minio/internal/config"
|
"github.com/minio/minio/internal/config"
|
||||||
|
"github.com/minio/minio/internal/config/api"
|
||||||
"github.com/minio/minio/internal/handlers"
|
"github.com/minio/minio/internal/handlers"
|
||||||
"github.com/minio/minio/internal/kms"
|
"github.com/minio/minio/internal/kms"
|
||||||
"github.com/minio/minio/internal/logger"
|
"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)
|
logger.Warning("%s=%s is ignored: Silo in-place updates are permanently disabled", config.EnvUpdate, updateSetting)
|
||||||
}
|
}
|
||||||
globalInplaceUpdateDisabled = true
|
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,
|
// Check if the supported credential env vars,
|
||||||
// "MINIO_ROOT_USER" and "MINIO_ROOT_PASSWORD" are provided
|
// "MINIO_ROOT_USER" and "MINIO_ROOT_PASSWORD" are provided
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"reflect"
|
"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
|
// TestHashOrder - test order of ints in array
|
||||||
func TestHashOrder(t *testing.T) {
|
func TestHashOrder(t *testing.T) {
|
||||||
testCases := []struct {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
var (
|
||||||
multipartScanSlots = make(chan struct{}, 2)
|
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")
|
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
|
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) {
|
func (er erasureObjects) readMultipartUploadCandidate(s *multipartScan, bucket, candidate string, source StorageAPI) (MultipartInfo, bool, bool, error) {
|
||||||
if s.ctx.Err() != nil {
|
if s.ctx.Err() != nil {
|
||||||
return MultipartInfo{}, false, false, SlowDown{}
|
return MultipartInfo{}, false, false, SlowDown{}
|
||||||
@@ -162,8 +169,8 @@ func (er erasureObjects) readMultipartUploadCandidate(s *multipartScan, bucket,
|
|||||||
disks := er.getDisks()
|
disks := er.getDisks()
|
||||||
metadata, errs := readAllFileInfo(s.ctx, disks, bucket, minioMetaMultipartBucket, candidate, "", false, false)
|
metadata, errs := readAllFileInfo(s.ctx, disks, bucket, minioMetaMultipartBucket, candidate, "", false, false)
|
||||||
if s.preflight {
|
if s.preflight {
|
||||||
// Readiness is stronger than listing liveness: even one readable legacy
|
// The diagnostic counts even a single readable legacy copy. It does not
|
||||||
// copy must be drained, and an unreadable copy cannot certify readiness.
|
// gate listing; unreadable copies still prevent a complete diagnosis.
|
||||||
var oldest MultipartInfo
|
var oldest MultipartInfo
|
||||||
legacy := false
|
legacy := false
|
||||||
_, nativeID := multipartUploadTime(uploadUUID)
|
_, nativeID := multipartUploadTime(uploadUUID)
|
||||||
@@ -176,10 +183,11 @@ func (er erasureObjects) readMultipartUploadCandidate(s *multipartScan, bucket,
|
|||||||
}
|
}
|
||||||
fi := metadata[i]
|
fi := metadata[i]
|
||||||
storedBucket, storedObject, valid := er.multipartIdentity(fi, shaDir)
|
storedBucket, storedObject, valid := er.multipartIdentity(fi, shaDir)
|
||||||
if storedBucket != "" && storedObject != "" && !valid {
|
isLegacy := multipartLegacyIdentity(fi)
|
||||||
|
if !isLegacy && (!valid || !nativeID) {
|
||||||
return MultipartInfo{}, false, false, errMultipartListingIdentity
|
return MultipartInfo{}, false, false, errMultipartListingIdentity
|
||||||
}
|
}
|
||||||
if !valid || !nativeID {
|
if isLegacy {
|
||||||
info := multipartUploadInfo(storedBucket, storedObject, uploadUUID, fi.ModTime)
|
info := multipartUploadInfo(storedBucket, storedObject, uploadUUID, fi.ModTime)
|
||||||
if !legacy || info.Initiated.Before(oldest.Initiated) {
|
if !legacy || info.Initiated.Before(oldest.Initiated) {
|
||||||
oldest = info
|
oldest = info
|
||||||
@@ -203,16 +211,29 @@ func (er erasureObjects) readMultipartUploadCandidate(s *multipartScan, bucket,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return MultipartInfo{}, false, false, err
|
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)
|
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)
|
info := multipartUploadInfo(storedBucket, storedObject, uploadUUID, fi.ModTime)
|
||||||
if storedBucket == "" || storedObject == "" {
|
if legacy {
|
||||||
return info, false, true, nil
|
return info, false, true, nil
|
||||||
}
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
return MultipartInfo{}, false, false, fmt.Errorf("%w: %s", errMultipartListingIdentity, candidate)
|
return MultipartInfo{}, false, false, fmt.Errorf("%w: %s", errMultipartListingIdentity, candidate)
|
||||||
}
|
}
|
||||||
if _, ok := multipartUploadTime(uploadUUID); !ok {
|
if _, ok := multipartUploadTime(uploadUUID); !ok {
|
||||||
return info, false, true, nil
|
return MultipartInfo{}, false, false, fmt.Errorf("%w: %s", errMultipartListingIdentity, candidate)
|
||||||
}
|
}
|
||||||
if bucket != "" && bucket != storedBucket {
|
if bucket != "" && bucket != storedBucket {
|
||||||
return MultipartInfo{}, false, false, nil
|
return MultipartInfo{}, false, false, nil
|
||||||
@@ -220,7 +241,7 @@ func (er erasureObjects) readMultipartUploadCandidate(s *multipartScan, bucket,
|
|||||||
return info, true, false, nil
|
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()
|
disks := er.getDisks()
|
||||||
report := multipartScanSet{Pool: poolIdx, Set: setIdx, Drives: er.setDriveCount}
|
report := multipartScanSet{Pool: poolIdx, Set: setIdx, Drives: er.setDriveCount}
|
||||||
var resultErr error
|
var resultErr error
|
||||||
@@ -272,7 +293,7 @@ func (er erasureObjects) scanMultipartUploads(s *multipartScan, bucket string, p
|
|||||||
resultErr = toObjectErr(errErasureReadQuorum, bucket)
|
resultErr = toObjectErr(errErasureReadQuorum, bucket)
|
||||||
}
|
}
|
||||||
if resultErr != nil {
|
if resultErr != nil {
|
||||||
return nil, false, resultErr
|
return nil, resultErr
|
||||||
}
|
}
|
||||||
type candidate struct {
|
type candidate struct {
|
||||||
path string
|
path string
|
||||||
@@ -325,25 +346,23 @@ dispatch:
|
|||||||
if s.ctx.Err() != nil {
|
if s.ctx.Err() != nil {
|
||||||
resultErr = SlowDown{}
|
resultErr = SlowDown{}
|
||||||
}
|
}
|
||||||
return uploads, report.LegacyUploads != 0, resultErr
|
return uploads, resultErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// multipartPreflight scans all pools, sets and drives, independent of caches.
|
// multipartPreflight scans all pools, sets and drives, independent of caches.
|
||||||
// A majority suffices for normal listing; upgrade readiness requires every
|
// A majority suffices for normal listing; a complete diagnosis requires every
|
||||||
// drive to have been inspected. The operator must also upgrade all writers.
|
// 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) {
|
func (z *erasureServerPools) multipartPreflight(ctx context.Context) (multipartPreflightReport, error) {
|
||||||
s, err := startMultipartScan(ctx, true)
|
s, err := startMultipartScan(ctx, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return multipartPreflightReport{}, err
|
return multipartPreflightReport{}, err
|
||||||
}
|
}
|
||||||
defer s.close()
|
defer s.close()
|
||||||
report := multipartPreflightReport{Complete: true, Mode: "strict"}
|
report := multipartPreflightReport{Complete: true, Mode: "legacy"}
|
||||||
if globalAPIConfig.getMultipartListingLegacy() {
|
|
||||||
report.Mode = "legacy"
|
|
||||||
}
|
|
||||||
for p, pool := range z.serverPools {
|
for p, pool := range z.serverPools {
|
||||||
for i, set := range pool.sets {
|
for i, set := range pool.sets {
|
||||||
_, _, _ = set.scanMultipartUploads(s, "", p, i)
|
_, _ = set.scanMultipartUploads(s, "", p, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
report.Sets = s.sets
|
report.Sets = s.sets
|
||||||
|
|||||||
@@ -22,16 +22,59 @@ import (
|
|||||||
"github.com/minio/minio/internal/config/storageclass"
|
"github.com/minio/minio/internal/config/storageclass"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check the real handler and storage path, including a successful abort between
|
// The persistent scanner is retained for diagnostics. This test-only wrapper
|
||||||
// pages. Choose IDs increasing in both initiation time and lexical order so the
|
// preserves its pagination coverage without exposing it through public listing.
|
||||||
// failure does not depend on differing interpretations of S3 marker ordering.
|
func (z *erasureServerPools) scanMultipartUploadsForTest(ctx context.Context, bucket, prefix, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (ListMultipartsInfo, error) {
|
||||||
func TestMultipartListingAbortBetweenHTTPPages(t *testing.T) {
|
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)
|
z, _ := consistencyPools(t)
|
||||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads", "AbortMultipart"}, MakeBucketOptions{})
|
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads", "AbortMultipart"}, MakeBucketOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
setMultipartListingTestMode(t, false)
|
|
||||||
var firstID, secondID string
|
var firstID, secondID string
|
||||||
for attempt := 0; attempt < 32; attempt++ {
|
for attempt := 0; attempt < 32; attempt++ {
|
||||||
one, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
one, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||||
@@ -81,8 +124,8 @@ func TestMultipartListingAbortBetweenHTTPPages(t *testing.T) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
first := list("", "", 1)
|
first := list("", "", 1)
|
||||||
if len(first.Uploads) != 1 || first.Uploads[0].UploadID != firstID || !first.IsTruncated {
|
if len(first.Uploads) != 3 || first.Uploads[0].UploadID != firstID || first.IsTruncated {
|
||||||
t.Fatalf("unexpected first page: %+v", first)
|
t.Fatalf("unexpected legacy cache view: %+v", first)
|
||||||
}
|
}
|
||||||
rec := request(http.MethodDelete, getAbortMultipartUploadURL("", bucket, "a", firstID))
|
rec := request(http.MethodDelete, getAbortMultipartUploadURL("", bucket, "a", firstID))
|
||||||
if rec.Code != http.StatusNoContent {
|
if rec.Code != http.StatusNoContent {
|
||||||
@@ -93,12 +136,12 @@ func TestMultipartListingAbortBetweenHTTPPages(t *testing.T) {
|
|||||||
for _, upload := range rest.Uploads {
|
for _, upload := range rest.Uploads {
|
||||||
keys = append(keys, upload.Key)
|
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 {
|
if _, err := z.GetMultipartInfo(t.Context(), bucket, "a", secondID, ObjectOptions{}); err != nil {
|
||||||
t.Fatalf("remaining upload is not valid: %v", err)
|
t.Fatalf("remaining upload is not valid: %v", err)
|
||||||
}
|
}
|
||||||
if len(rest.Uploads) != 2 || rest.Uploads[0].UploadID != secondID {
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
z.mpCache.Clear()
|
z.mpCache.Clear()
|
||||||
t.Run("legacy-upgrade", func(t *testing.T) {
|
for _, target := range []string{bucket, other} {
|
||||||
setMultipartListingTestMode(t, true)
|
got, err := z.scanMultipartUploadsForTest(t.Context(), target, "", "", "", "", 10)
|
||||||
exact, err := z.ListMultipartUploads(t.Context(), other, "old", "", "", "", 10)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
requireMultipartUploadKeys(t, exact, "old")
|
if target == bucket {
|
||||||
const empty = "multipart-legacy-empty"
|
requireMultipartUploadKeys(t, got, "a")
|
||||||
if err := z.MakeBucket(t.Context(), empty, MakeBucketOptions{}); err != nil {
|
} else {
|
||||||
|
requireMultipartUploadKeys(t, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Diagnostic discovery cannot recover an unidentified historical key.
|
||||||
|
exact, err := z.scanMultipartUploadsForTest(t.Context(), other, "old", "", "", "", 10)
|
||||||
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got, err := z.ListMultipartUploads(t.Context(), empty, "", "", "", "", 10)
|
requireMultipartUploadKeys(t, exact)
|
||||||
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)
|
|
||||||
}
|
|
||||||
report, err := z.multipartPreflight(t.Context())
|
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)
|
t.Fatalf("legacy preflight: %+v %v", report, err)
|
||||||
}
|
}
|
||||||
if err = z.AbortMultipartUpload(t.Context(), other, "old", old.UploadID, ObjectOptions{}); err != nil {
|
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 {
|
if err != nil || !report.Ready || report.LegacyUploads != 0 {
|
||||||
t.Fatalf("drained preflight: %+v %v", report, err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
requireMultipartUploadKeys(t, got, "a")
|
requireMultipartUploadKeys(t, got, "a")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMultipartListingIdentityFallback(t *testing.T) {
|
func TestMultipartDiagnosticIdentityFallback(t *testing.T) {
|
||||||
for _, kind := range []string{"old", "corrupt", "read-failure", "wrong-bucket"} {
|
for _, kind := range []string{"old", "partial", "corrupt", "read-failure", "wrong-bucket"} {
|
||||||
t.Run(kind, func(t *testing.T) {
|
t.Run(kind, func(t *testing.T) {
|
||||||
z, set, bucket := multipartListingFixture(t)
|
z, set, bucket := multipartListingFixture(t)
|
||||||
if _, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{}); err != nil {
|
if _, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{}); err != nil {
|
||||||
@@ -209,8 +249,11 @@ func TestMultipartListingIdentityFallback(t *testing.T) {
|
|||||||
return FileInfo{}, errFileCorrupt
|
return FileInfo{}, errFileCorrupt
|
||||||
}
|
}
|
||||||
fi.Metadata = cloneMSS(fi.Metadata)
|
fi.Metadata = cloneMSS(fi.Metadata)
|
||||||
if kind == "old" {
|
if kind == "old" || kind == "partial" {
|
||||||
delete(fi.Metadata, multipartMetaBucket)
|
delete(fi.Metadata, multipartMetaBucket)
|
||||||
|
if kind == "old" {
|
||||||
|
delete(fi.Metadata, multipartMetaObject)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fi.Metadata[multipartMetaBucket] = "another-bucket"
|
fi.Metadata[multipartMetaBucket] = "another-bucket"
|
||||||
}
|
}
|
||||||
@@ -220,7 +263,7 @@ func TestMultipartListingIdentityFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return disks
|
return disks
|
||||||
}
|
}
|
||||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -231,7 +274,7 @@ func TestMultipartListingIdentityFallback(t *testing.T) {
|
|||||||
|
|
||||||
func TestMultipartAbortPoolsAndRetry(t *testing.T) {
|
func TestMultipartAbortPoolsAndRetry(t *testing.T) {
|
||||||
z, bucket := consistencyPools(t)
|
z, bucket := consistencyPools(t)
|
||||||
setMultipartListingTestMode(t, false)
|
|
||||||
mp, err := z.serverPools[1].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
mp, err := z.serverPools[1].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -252,10 +295,12 @@ func TestMultipartAbortPoolsAndRetry(t *testing.T) {
|
|||||||
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
||||||
t.Fatalf("unknown pool must not acknowledge cancellation: %v", err)
|
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
|
emptySet.getDisks = original
|
||||||
err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{})
|
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||||
if _, ok := err.(InvalidUploadID); !ok {
|
t.Fatalf("retry after pool recovery: %v", err)
|
||||||
t.Fatalf("retry after all pools confirm absence: %v", err)
|
|
||||||
}
|
}
|
||||||
mp, err = z.serverPools[1].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
mp, err = z.serverPools[1].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -289,12 +334,13 @@ func TestMultipartAbortRetryBelowReadQuorum(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
return disks
|
return disks
|
||||||
}
|
}
|
||||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err == nil {
|
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||||
t.Fatal("lost acknowledgement must fail")
|
t.Fatalf("best-effort cleanup must retain read-quorum availability: %v", err)
|
||||||
}
|
}
|
||||||
set.getDisks = original
|
set.getDisks = original
|
||||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
var invalid InvalidUploadID
|
||||||
t.Fatalf("one remaining metadata copy prevented retry: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
setMultipartListingTestMode(t, false)
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
key, marker string
|
key, marker string
|
||||||
status int
|
status int
|
||||||
@@ -388,7 +434,7 @@ func TestMultipartAbortLateCreateBoundary(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
z := obj.(*erasureServerPools)
|
z := obj.(*erasureServerPools)
|
||||||
setMultipartListingTestMode(t, false)
|
|
||||||
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||||
saved := globalStorageClass
|
saved := globalStorageClass
|
||||||
globalStorageClass.Update(storageclass.Config{Standard: storageclass.StorageClass{Parity: 8}})
|
globalStorageClass.Update(storageclass.Config{Standard: storageclass.StorageClass{Parity: 8}})
|
||||||
@@ -417,9 +463,7 @@ func TestMultipartAbortLateCreateBoundary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
set.getDisks = func() []StorageAPI {
|
set.getDisks = func() []StorageAPI {
|
||||||
disks := append([]StorageAPI(nil), original()...)
|
disks := append([]StorageAPI(nil), original()...)
|
||||||
for i := 2; i < 9; i++ {
|
disks[8] = nil
|
||||||
disks[i] = nil
|
|
||||||
}
|
|
||||||
return disks
|
return disks
|
||||||
}
|
}
|
||||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
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
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("late creation boundary changed; review and remove the documented limitation: %v", err)
|
t.Fatalf("late creation boundary changed; review and remove the documented limitation: %v", err)
|
||||||
}
|
}
|
||||||
@@ -441,10 +485,10 @@ func TestMultipartAbortLateCreateBoundary(t *testing.T) {
|
|||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if count != 14 {
|
if count != 8 {
|
||||||
t.Fatalf("expected fourteen resurrected copies, got %d", count)
|
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 {
|
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.
|
// 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)
|
obj, dirs, err := prepareErasure(t.Context(), 4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
z := obj.(*erasureServerPools)
|
z := obj.(*erasureServerPools)
|
||||||
setMultipartListingTestMode(t, false)
|
|
||||||
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||||
const bucket, otherBucket = "r9-scan-target", "r9-scan-unrelated"
|
const bucket, otherBucket = "r9-scan-target", "r9-scan-unrelated"
|
||||||
for _, name := range []string{bucket, otherBucket} {
|
for _, name := range []string{bucket, otherBucket} {
|
||||||
@@ -506,7 +550,7 @@ func TestMultipartListingScanCosts(t *testing.T) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
directoryCalls.Store(0)
|
directoryCalls.Store(0)
|
||||||
metadataCalls.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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -544,7 +588,7 @@ func multipartListingFixture(t *testing.T) (*erasureServerPools, *erasureObjects
|
|||||||
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
|
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
setMultipartListingTestMode(t, false)
|
|
||||||
return z, z.serverPools[0].getHashedSet("a"), bucket
|
return z, z.serverPools[0].getHashedSet("a"), bucket
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,46 +632,7 @@ func TestMultipartListingMissingMarkers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMultipartListingAbortRecovery(t *testing.T) {
|
func TestMultipartDiagnosticCoverage(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) {
|
|
||||||
z, set, bucket := multipartListingFixture(t)
|
z, set, bucket := multipartListingFixture(t)
|
||||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -643,12 +648,12 @@ func TestMultipartListingCoverage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
set.getDisks = func() []StorageAPI { return []StorageAPI{disks[0], disks[1], nil, nil} }
|
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 {
|
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
||||||
t.Fatalf("incomplete discovery returned success: %v", err)
|
t.Fatalf("incomplete discovery returned success: %v", err)
|
||||||
}
|
}
|
||||||
set.getDisks = func() []StorageAPI { return []StorageAPI{nil, disks[1], disks[2], disks[3]} }
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
_, set, bucket := multipartListingFixture(t)
|
||||||
if _, err := set.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{}); err != nil {
|
if _, err := set.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -670,7 +675,7 @@ func TestMultipartListingBudgetAndAdmission(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer scan.close()
|
defer scan.close()
|
||||||
scan.remaining.Store(1)
|
scan.remaining.Store(1)
|
||||||
_, _, err = set.scanMultipartUploads(scan, bucket, 0, 0)
|
_, err = set.scanMultipartUploads(scan, bucket, 0, 0)
|
||||||
var limited SlowDown
|
var limited SlowDown
|
||||||
if !errors.As(err, &limited) {
|
if !errors.As(err, &limited) {
|
||||||
t.Fatalf("budget: %v", err)
|
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)
|
z, _, _ := multipartListingFixture(t)
|
||||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads"}, MakeBucketOptions{})
|
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads"}, MakeBucketOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
setMultipartListingTestMode(t, false)
|
|
||||||
for range cap(multipartScanSlots) {
|
for range cap(multipartScanSlots) {
|
||||||
scan, err := startMultipartScan(t.Context(), false)
|
scan, err := startMultipartScan(t.Context(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -711,12 +716,12 @@ func TestMultipartListingAdmissionHTTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
var response APIErrorResponse
|
var response ListMultipartUploadsResponse
|
||||||
if err := xml.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
if err := xml.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if rec.Code != http.StatusServiceUnavailable || response.Code != "SlowDown" {
|
if rec.Code != http.StatusOK || len(response.Uploads) != 0 {
|
||||||
t.Fatalf("admission returned %d %s", rec.Code, rec.Body.String())
|
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())
|
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)
|
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)
|
z, set, bucket := multipartListingFixture(t)
|
||||||
for i := range 40 {
|
for i := range 40 {
|
||||||
if _, err := z.NewMultipartUpload(t.Context(), bucket, fmt.Sprintf("key-%02d", i), ObjectOptions{}); err != nil {
|
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()
|
defer cancel()
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
_, err := z.ListMultipartUploads(ctx, bucket, "", "", "", "", 10)
|
_, err := z.scanMultipartUploadsForTest(ctx, bucket, "", "", "", "", 10)
|
||||||
done <- err
|
done <- err
|
||||||
}()
|
}()
|
||||||
for range 16 {
|
for range 16 {
|
||||||
|
|||||||
@@ -12,41 +12,13 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/minio/minio/internal/config/api"
|
|
||||||
"github.com/minio/minio/internal/config/storageclass"
|
"github.com/minio/minio/internal/config/storageclass"
|
||||||
"github.com/minio/minio/internal/dsync"
|
"github.com/minio/minio/internal/dsync"
|
||||||
"github.com/minio/minio/internal/grid"
|
"github.com/minio/minio/internal/grid"
|
||||||
xnet "github.com/pgsty/silo-pkg/v3/net"
|
xnet "github.com/pgsty/silo-pkg/v3/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setMultipartListingTestMode(t *testing.T, legacy bool) {
|
func TestMultipartAbortDefaultAvailability(t *testing.T) {
|
||||||
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) {
|
|
||||||
for _, drives := range []int{4, 16} {
|
for _, drives := range []int{4, 16} {
|
||||||
for _, offline := range []int{0, 1, drives / 2} {
|
for _, offline := range []int{0, 1, drives / 2} {
|
||||||
t.Run(fmt.Sprintf("drives=%d/offline=%d", drives, offline), func(t *testing.T) {
|
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)
|
z := obj.(*erasureServerPools)
|
||||||
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||||
setMultipartListingTestMode(t, true)
|
|
||||||
previous := globalStorageClass
|
previous := globalStorageClass
|
||||||
globalStorageClass.Update(storageclass.Config{Standard: storageclass.StorageClass{Parity: drives / 2}})
|
globalStorageClass.Update(storageclass.Config{Standard: storageclass.StorageClass{Parity: drives / 2}})
|
||||||
t.Cleanup(func() { globalStorageClass.Update(previous) })
|
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} {
|
for _, owner := range []int{0, 1} {
|
||||||
t.Run(fmt.Sprintf("owner=%d", owner), func(t *testing.T) {
|
t.Run(fmt.Sprintf("owner=%d", owner), func(t *testing.T) {
|
||||||
z, bucket := consistencyPools(t)
|
z, bucket := consistencyPools(t)
|
||||||
setMultipartListingTestMode(t, true)
|
|
||||||
mp, err := z.serverPools[owner].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
mp, err := z.serverPools[owner].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -106,7 +78,7 @@ func TestMultipartAbortLegacyPoolOrder(t *testing.T) {
|
|||||||
emptySet.getDisks = func() []StorageAPI { return make([]StorageAPI, emptySet.setDriveCount) }
|
emptySet.getDisks = func() []StorageAPI { return make([]StorageAPI, emptySet.setDriveCount) }
|
||||||
err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{})
|
err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{})
|
||||||
if owner == 0 && err != nil {
|
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 owner == 1 {
|
||||||
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
||||||
@@ -124,70 +96,9 @@ 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) {
|
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)
|
z, _, bucket := multipartListingFixture(t)
|
||||||
setMultipartListingTestMode(t, legacy)
|
|
||||||
const other = "multipart-other-bucket"
|
const other = "multipart-other-bucket"
|
||||||
if err := z.MakeBucket(t.Context(), other, MakeBucketOptions{}); err != nil {
|
if err := z.MakeBucket(t.Context(), other, MakeBucketOptions{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -216,15 +127,11 @@ func TestMultipartAbortWrongTargetKeepsCache(t *testing.T) {
|
|||||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "valid-key", mp.UploadID, ObjectOptions{}); !errors.As(err, &invalid) {
|
if err := z.AbortMultipartUpload(t.Context(), bucket, "valid-key", mp.UploadID, ObjectOptions{}); !errors.As(err, &invalid) {
|
||||||
t.Fatalf("repeat abort: %v", err)
|
t.Fatalf("repeat abort: %v", err)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMultipartAbortRejectsUnsafeID(t *testing.T) {
|
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)
|
z, _, bucket := multipartListingFixture(t)
|
||||||
setMultipartListingTestMode(t, legacy)
|
|
||||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "keep", ObjectOptions{})
|
mp, err := z.NewMultipartUpload(t.Context(), bucket, "keep", ObjectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -240,8 +147,6 @@ func TestMultipartAbortRejectsUnsafeID(t *testing.T) {
|
|||||||
t.Fatalf("valid upload harmed by rejected ID: %v", err)
|
t.Fatalf("valid upload harmed by rejected ID: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMultipartAbortPeerNotification(t *testing.T) {
|
func TestMultipartAbortPeerNotification(t *testing.T) {
|
||||||
@@ -275,9 +180,7 @@ func TestMultipartAbortPeerNotification(t *testing.T) {
|
|||||||
set.nsMutex = newNSLock(true)
|
set.nsMutex = newNSLock(true)
|
||||||
set.getLockers = func() ([]dsync.NetLocker, string) { return lockers, "multipart-test" }
|
set.getLockers = func() ([]dsync.NetLocker, string) { return lockers, "multipart-test" }
|
||||||
t.Cleanup(func() { set.nsMutex, set.getLockers = oldMutex, oldLockers })
|
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 {
|
for range 4 {
|
||||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "valid", ObjectOptions{})
|
mp, err := z.NewMultipartUpload(t.Context(), bucket, "valid", ObjectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -298,11 +201,9 @@ func TestMultipartAbortPeerNotification(t *testing.T) {
|
|||||||
t.Fatal("successful abort lost peer notification after unlocking")
|
t.Fatal("successful abort lost peer notification after unlocking")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMultipartAbortStrictMajority(t *testing.T) {
|
func TestMultipartAbortBestEffortCleanup(t *testing.T) {
|
||||||
z, set, bucket := multipartListingFixture(t)
|
z, set, bucket := multipartListingFixture(t)
|
||||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -317,15 +218,19 @@ func TestMultipartAbortStrictMajority(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { set.getDisks = original })
|
t.Cleanup(func() { set.getDisks = original })
|
||||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
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
|
// Best-effort deletion may leave a remnant. Retrying after recovery cleans
|
||||||
// after recovery must also clean the now-minority remnant.
|
// 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 {
|
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)
|
t.Fatalf("fault fixture did not leave a remnant: %v", err)
|
||||||
}
|
}
|
||||||
set.getDisks = original
|
set.getDisks = original
|
||||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
var invalid InvalidUploadID
|
||||||
t.Fatal(err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-97
@@ -174,34 +174,46 @@ func (er erasureObjects) deleteAll(ctx context.Context, bucket, prefix string) {
|
|||||||
wg.Wait()
|
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.
|
// Remove the old multipart uploads on the given disk.
|
||||||
func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk StorageAPI) {
|
func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk StorageAPI) {
|
||||||
drivePath := disk.Endpoint().Path
|
drivePath := disk.Endpoint().Path
|
||||||
|
|
||||||
readDirFn(pathJoin(drivePath, minioMetaMultipartBucket), func(shaDir string, typ os.FileMode) error {
|
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 {
|
readDirFn(pathJoin(drivePath, minioMetaMultipartBucket, shaDir), func(uploadIDDir string, typ os.FileMode) error {
|
||||||
uploadIDPath := pathJoin(shaDir, uploadIDDir)
|
uploadIDPath := pathJoin(shaDir, uploadIDDir)
|
||||||
var modTime time.Time
|
modTime := extractModTimeFromUploadIDDir(uploadIDDir)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback for older uploads without time in the ID.
|
// Fallback for older uploads without time in the ID.
|
||||||
if modTime.IsZero() {
|
if modTime.IsZero() {
|
||||||
wait := deleteMultipartCleanupSleeper.Timer(ctx)
|
wait := deleteMultipartCleanupSleeper.Timer(ctx)
|
||||||
fi, err := disk.ReadVersion(ctx, "", minioMetaMultipartBucket, uploadIDPath, "", ReadOptions{})
|
fi, err := disk.ReadVersion(ctx, "", minioMetaMultipartBucket, uploadIDPath, "", ReadOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
hasSkippedUploads = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
modTime = fi.ModTime
|
modTime = fi.ModTime
|
||||||
wait()
|
wait()
|
||||||
}
|
}
|
||||||
if time.Since(modTime) < globalAPIConfig.getStaleUploadsExpiry() {
|
if time.Since(modTime) < globalAPIConfig.getStaleUploadsExpiry() {
|
||||||
|
hasSkippedUploads = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
w := xioutil.NewDeadlineWorker(globalDriveConfig.GetMaxTimeout())
|
w := xioutil.NewDeadlineWorker(globalDriveConfig.GetMaxTimeout())
|
||||||
@@ -214,24 +226,15 @@ func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk Sto
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Get the modtime of the shaDir.
|
if hasSkippedUploads {
|
||||||
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() {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
w := xioutil.NewDeadlineWorker(globalDriveConfig.GetMaxTimeout())
|
w := xioutil.NewDeadlineWorker(globalDriveConfig.GetMaxTimeout())
|
||||||
return w.Run(func() error {
|
return w.Run(func() error {
|
||||||
wait := deleteMultipartCleanupSleeper.Timer(ctx)
|
wait := deleteMultipartCleanupSleeper.Timer(ctx)
|
||||||
pathUUID := mustGetUUID()
|
// Removing an upload updates the parent's mtime. Remove the parent
|
||||||
targetPath := pathJoin(drivePath, minioMetaTmpDeletedBucket, pathUUID)
|
// immediately if it is empty; other uploads keep it in place.
|
||||||
|
_ = Remove(pathJoin(drivePath, minioMetaMultipartBucket, shaDir))
|
||||||
// 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)
|
|
||||||
wait()
|
wait()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -405,7 +408,7 @@ func paginateMultipartUploads(uploads []MultipartInfo, prefix, keyMarker, upload
|
|||||||
}
|
}
|
||||||
|
|
||||||
// listMultipartUploadsExact preserves the hashed exact-object lookup used by
|
// 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) {
|
func (er erasureObjects) listMultipartUploadsExact(ctx context.Context, bucket, object, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (result ListMultipartsInfo, err error) {
|
||||||
auditObjectErasureSet(ctx, "ListMultipartUploads", object, &er)
|
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 {
|
if err := checkListMultipartArgs(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter); err != nil {
|
||||||
return ListMultipartsInfo{}, err
|
return ListMultipartsInfo{}, err
|
||||||
}
|
}
|
||||||
scan, err := startMultipartScan(ctx, false)
|
return er.listMultipartUploadsExact(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newMultipartUpload - wrapper for initializing a new multipart
|
// newMultipartUpload - wrapper for initializing a new multipart
|
||||||
@@ -1779,11 +1770,9 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
|||||||
return fi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended), nil
|
return fi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// abortMultipartUpload retains read-quorum validation and best-effort cleanup
|
// abortMultipartUpload retains read-quorum validation and best-effort cleanup.
|
||||||
// in legacy mode. Strict mode requires majority deletion acknowledgements and
|
// It does not fence creation writes still executing after a storage timeout.
|
||||||
// permits retrying remnants below read quorum. Neither mode fences creation
|
func (er erasureObjects) abortMultipartUpload(ctx context.Context, bucket, object, uploadID string, opts ObjectOptions) (bool, error) {
|
||||||
// writes still executing after a storage timeout.
|
|
||||||
func (er erasureObjects) abortMultipartUpload(ctx context.Context, bucket, object, uploadID string, opts ObjectOptions, legacy bool) (bool, error) {
|
|
||||||
if !opts.NoAuditLog {
|
if !opts.NoAuditLog {
|
||||||
auditObjectErasureSet(ctx, "AbortMultipartUpload", object, &er)
|
auditObjectErasureSet(ctx, "AbortMultipartUpload", object, &er)
|
||||||
}
|
}
|
||||||
@@ -1795,70 +1784,19 @@ func (er erasureObjects) abortMultipartUpload(ctx context.Context, bucket, objec
|
|||||||
if !ok || internalID == "" || internalID == "." || internalID == ".." || strings.ContainsAny(internalID, "/\\") {
|
if !ok || internalID == "" || internalID == "." || internalID == ".." || strings.ContainsAny(internalID, "/\\") {
|
||||||
return false, InvalidUploadID{Bucket: bucket, Object: object, UploadID: uploadID}
|
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))
|
defer er.deleteAll(ctx, minioMetaMultipartBucket, er.getUploadIDDir(bucket, object, uploadID))
|
||||||
_, _, err := er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
_, _, err = er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
||||||
err = toObjectErr(err, bucket, object, uploadID)
|
err = toObjectErr(err, bucket, object, uploadID)
|
||||||
if _, absent := err.(InvalidUploadID); absent {
|
if _, absent := err.(InvalidUploadID); absent {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return err == nil, err
|
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 {
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AbortMultipartUpload cancels an upload using the configured mode. Offline
|
// AbortMultipartUpload cancels an upload using read-quorum validation.
|
||||||
// part data may still need stale-upload cleanup after its drives return.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -729,6 +729,57 @@ func readAllXL(ctx context.Context, disks []StorageAPI, bucket, object string, r
|
|||||||
return pickLatestQuorumFilesInfo(ctx, rawFileInfos, errs, bucket, object, readData, inclFreeVers)
|
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) {
|
func (er erasureObjects) getObjectFileInfo(ctx context.Context, bucket, object string, opts ObjectOptions, readData bool) (FileInfo, []FileInfo, []StorageAPI, error) {
|
||||||
rawArr := make([]RawFileInfo, er.setDriveCount)
|
rawArr := make([]RawFileInfo, er.setDriveCount)
|
||||||
metaArr := make([]FileInfo, er.setDriveCount)
|
metaArr := make([]FileInfo, er.setDriveCount)
|
||||||
@@ -1083,6 +1134,12 @@ func renameData(ctx context.Context, disks []StorageAPI, srcBucket, srcEntry str
|
|||||||
errs := g.Wait()
|
errs := g.Wait()
|
||||||
|
|
||||||
err := reduceWriteQuorumErrs(ctx, errs, objectOpIgnoredErrs, writeQuorum)
|
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 {
|
if err != nil {
|
||||||
dg := errgroup.WithNErrs(len(disks))
|
dg := errgroup.WithNErrs(len(disks))
|
||||||
for index, nerr := range errs {
|
for index, nerr := range errs {
|
||||||
@@ -2138,6 +2195,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()
|
purge := opts.isVersionPurge()
|
||||||
if purge {
|
if purge {
|
||||||
// A delete marker describes the version being removed; it is not an
|
// A delete marker describes the version being removed; it is not an
|
||||||
|
|||||||
@@ -62,6 +62,125 @@ func (z *erasureServerPools) objectPoolInfos(ctx context.Context, bucket, object
|
|||||||
return copies, nil
|
return copies, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (z *erasureServerPools) removeMovedVersions(ctx context.Context, bucket, object string, source int, versions []FileInfo) error {
|
||||||
|
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{}
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// Healing also commits object metadata. Both queued healing and drive healing
|
// Healing also commits object metadata. Both queued healing and drive healing
|
||||||
// must use the same namespace as pooled writes, rather than racing them under
|
// must use the same namespace as pooled writes, rather than racing them under
|
||||||
// a destination set's independent namespace.
|
// a destination set's independent namespace.
|
||||||
@@ -137,11 +256,15 @@ func (z *erasureServerPools) replicaObjectInfo(ctx context.Context, bucket, obje
|
|||||||
opts.VersionID = nullVersionID
|
opts.VersionID = nullVersionID
|
||||||
}
|
}
|
||||||
copies, err := z.objectPoolInfos(ctx, bucket, object, opts)
|
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 {
|
if err != nil {
|
||||||
return ObjectInfo{}, err
|
return ObjectInfo{}, err
|
||||||
}
|
}
|
||||||
if copies[0].ObjInfo.DeleteMarker {
|
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
|
return mergedPoolObjectInfo(copies), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -616,6 +616,12 @@ func (z *erasureServerPools) decommissionObject(ctx context.Context, idx int, bu
|
|||||||
}
|
}
|
||||||
|
|
||||||
if objInfo.isMultipart() {
|
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{
|
res, err := z.NewMultipartUpload(ctx, bucket, objInfo.Name, ObjectOptions{
|
||||||
VersionID: objInfo.VersionID,
|
VersionID: objInfo.VersionID,
|
||||||
UserDefined: migrationObjectMetadata(objInfo),
|
UserDefined: migrationObjectMetadata(objInfo),
|
||||||
@@ -656,6 +662,7 @@ func (z *erasureServerPools) decommissionObject(ctx context.Context, idx int, bu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, err = z.CompleteMultipartUpload(ctx, bucket, objInfo.Name, res.UploadID, parts, ObjectOptions{
|
_, err = z.CompleteMultipartUpload(ctx, bucket, objInfo.Name, res.UploadID, parts, ObjectOptions{
|
||||||
|
VersionID: objInfo.VersionID,
|
||||||
SrcPoolIdx: idx,
|
SrcPoolIdx: idx,
|
||||||
DataMovement: true,
|
DataMovement: true,
|
||||||
MTime: objInfo.ModTime,
|
MTime: objInfo.ModTime,
|
||||||
@@ -825,27 +832,17 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
|||||||
// to create the appropriate stack.
|
// to create the appropriate stack.
|
||||||
versionsSorter(fivs.Versions).reverse()
|
versionsSorter(fivs.Versions).reverse()
|
||||||
|
|
||||||
var decommissioned, expired int
|
var decommissioned int
|
||||||
for _, version := range fivs.Versions {
|
for _, version := range fivs.Versions {
|
||||||
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionObject, idx, bi.Name, version.Name, version.VersionID)
|
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionObject, idx, bi.Name, version.Name, version.VersionID)
|
||||||
// Apply lifecycle rules on the objects that are expired.
|
// Apply lifecycle rules on the objects that are expired.
|
||||||
if filterLifecycle(bi.Name, version.Name, version) {
|
if filterLifecycle(bi.Name, version.Name, version) {
|
||||||
expired++
|
|
||||||
decommissioned++
|
decommissioned++
|
||||||
stopFn(version.Size, errors.New("ILM expired object/version will be skipped"))
|
stopFn(version.Size, errors.New("ILM expired object/version will be skipped"))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// any object with only single DEL marker we don't need
|
// Preserve every marker: older versions may live in another pool.
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
versionID := version.VersionID
|
versionID := version.VersionID
|
||||||
if versionID == "" {
|
if versionID == "" {
|
||||||
versionID = nullVersionID
|
versionID = nullVersionID
|
||||||
@@ -870,6 +867,9 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
|||||||
SkipDecommissioned: true, // make sure we skip the decommissioned pool
|
SkipDecommissioned: true, // make sure we skip the decommissioned pool
|
||||||
NoAuditLog: true,
|
NoAuditLog: true,
|
||||||
})
|
})
|
||||||
|
if isDataMovementSuperseded(err) {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// This can happen when rebalance stop races with ongoing rebalance workers.
|
// This can happen when rebalance stop races with ongoing rebalance workers.
|
||||||
// These rebalance failures can be ignored.
|
// These rebalance failures can be ignored.
|
||||||
@@ -898,18 +898,20 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
|||||||
// gr.Close() is ensured by decommissionObject().
|
// gr.Close() is ensured by decommissionObject().
|
||||||
for range 3 {
|
for range 3 {
|
||||||
if version.IsRemote() {
|
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,
|
VersionID: versionID,
|
||||||
MTime: version.ModTime,
|
MTime: version.ModTime,
|
||||||
UserDefined: version.Metadata,
|
UserDefined: version.Metadata,
|
||||||
SrcPoolIdx: idx,
|
SrcPoolIdx: idx,
|
||||||
DataMovement: true,
|
DataMovement: true,
|
||||||
}); err != nil {
|
})
|
||||||
|
if isDataMovementSuperseded(err) {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
||||||
ignore = true
|
ignore = true
|
||||||
stopFn(0, nil)
|
stopFn(0, nil)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if !ignore {
|
if !ignore {
|
||||||
stopFn(version.Size, err)
|
stopFn(version.Size, err)
|
||||||
failure = err != nil
|
failure = err != nil
|
||||||
@@ -949,7 +951,7 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
|||||||
stopFn(version.Size, err)
|
stopFn(version.Size, err)
|
||||||
continue
|
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) {
|
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
||||||
ignore = true
|
ignore = true
|
||||||
stopFn(0, nil)
|
stopFn(0, nil)
|
||||||
@@ -979,15 +981,7 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
|||||||
// if all versions were decommissioned, then we can delete the object versions.
|
// if all versions were decommissioned, then we can delete the object versions.
|
||||||
if decommissioned == len(fivs.Versions) {
|
if decommissioned == len(fivs.Versions) {
|
||||||
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionRemoveObject, idx, bi.Name, entry.name)
|
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionRemoveObject, idx, bi.Name, entry.name)
|
||||||
_, err := set.DeleteObject(ctx,
|
err := z.removeMovedVersions(ctx, bi.Name, entry.name, idx, fivs.Versions)
|
||||||
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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
stopFn(0, err)
|
stopFn(0, err)
|
||||||
auditLogDecom(ctx, "DecomDeleteObject", bi.Name, entry.name, "", err)
|
auditLogDecom(ctx, "DecomDeleteObject", bi.Name, entry.name, "", err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1196,10 +1190,6 @@ func (z *erasureServerPools) checkAfterDecom(ctx context.Context, idx int) error
|
|||||||
ignored++
|
ignored++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if version.Deleted {
|
|
||||||
ignored++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
versionsFound += len(fivs.Versions) - ignored
|
versionsFound += len(fivs.Versions) - ignored
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// 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 (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Both walkers must preserve markers even when all older versions reside in
|
||||||
|
// another pool. Local version counts cannot prove that a marker is redundant.
|
||||||
|
func TestPoolsMigrationDeleteMarkers(t *testing.T) {
|
||||||
|
for _, kind := range []string{"rebalance", "decommission"} {
|
||||||
|
for _, pools := range []int{2, 3} {
|
||||||
|
for _, null := range []bool{false, true} {
|
||||||
|
for _, history := range []string{"none", "source", "destination"} {
|
||||||
|
t.Run(fmt.Sprintf("%s/pools=%d/null=%t/history=%s", kind, pools, null, history), func(t *testing.T) {
|
||||||
|
var z *erasureServerPools
|
||||||
|
var bucket string
|
||||||
|
if pools == 2 {
|
||||||
|
z, bucket = migrationTestPools(t)
|
||||||
|
} else {
|
||||||
|
z, bucket = migrationTestThreePools(t)
|
||||||
|
}
|
||||||
|
const object = "deleted"
|
||||||
|
old, stamp := UTCNow().Add(-time.Hour), UTCNow().Add(-time.Minute)
|
||||||
|
var prior ObjectInfo
|
||||||
|
if history != "none" {
|
||||||
|
pool := 0
|
||||||
|
if history == "destination" {
|
||||||
|
pool = 1
|
||||||
|
}
|
||||||
|
prior = putConsistencyObject(t, z, bucket, object, pool, "history", ObjectOptions{Versioned: true, MTime: old})
|
||||||
|
}
|
||||||
|
// A stale null copy is a normal consequence of cross-pool overwrites.
|
||||||
|
putConsistencyObject(t, z, bucket, object, 1, "stale", ObjectOptions{MTime: old})
|
||||||
|
vid := mustGetUUID()
|
||||||
|
if null {
|
||||||
|
vid = nullVersionID
|
||||||
|
}
|
||||||
|
opts := ObjectOptions{Versioned: true, VersionID: vid, DeleteMarker: true, MTime: stamp}
|
||||||
|
marker, err := z.serverPools[0].DeleteObject(t.Context(), bucket, object, opts)
|
||||||
|
mustIAM(t, err)
|
||||||
|
if !marker.DeleteMarker {
|
||||||
|
t.Fatal("fixture did not create marker")
|
||||||
|
}
|
||||||
|
if oi, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); !isErrObjectNotFound(err) || !oi.DeleteMarker {
|
||||||
|
t.Fatalf("fixture visible: %+v %v", oi, err)
|
||||||
|
}
|
||||||
|
if failed := migrationTestWalk(t, z, bucket, 0, kind); failed != 0 {
|
||||||
|
t.Fatalf("failed moves: %d", failed)
|
||||||
|
}
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
if oi, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); !isErrObjectNotFound(err) || !oi.DeleteMarker || !oi.ModTime.Equal(stamp) {
|
||||||
|
t.Fatalf("marker lost: %+v %v", oi, err)
|
||||||
|
}
|
||||||
|
if _, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: migrationTestVersion(marker)}); !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||||
|
t.Fatalf("source marker retained: %v", err)
|
||||||
|
}
|
||||||
|
if prior.VersionID != "" && migrationTestBody(t, z.GetObjectNInfo, bucket, object, prior.VersionID) != "history" {
|
||||||
|
t.Fatal("lost historical version")
|
||||||
|
}
|
||||||
|
// Repeating a completed walk must preserve the same state.
|
||||||
|
migrationTestWalk(t, z, bucket, 0, kind)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reader opened before a public purge must not recreate the purged version.
|
||||||
|
func TestPoolsMigrationDoesNotResurrectPurgedSource(t *testing.T) {
|
||||||
|
for _, kind := range []string{"rebalance", "decommission"} {
|
||||||
|
for _, multipart := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("%s/multipart=%t", kind, multipart), func(t *testing.T) {
|
||||||
|
z, bucket := migrationTestPools(t)
|
||||||
|
move := migrationTestMover(t, z, 0, kind)
|
||||||
|
oi := migrationTestObject(t, z, bucket, "purged", 0, multipart, []string{"payload"}, ObjectOptions{Versioned: true})
|
||||||
|
gr := migrationTestReader(t, z, 0, oi)
|
||||||
|
_, err := z.DeleteObject(t.Context(), bucket, oi.Name, ObjectOptions{VersionID: oi.VersionID, Versioned: true})
|
||||||
|
mustIAM(t, err)
|
||||||
|
err = move(t.Context(), 0, bucket, gr)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("move accepted a purged source")
|
||||||
|
}
|
||||||
|
if oi, err := z.GetObjectInfo(t.Context(), bucket, oi.Name, ObjectOptions{}); !isErrObjectNotFound(err) {
|
||||||
|
t.Fatalf("purged version resurrected: %+v %v", oi, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup must not erase a marker created after the data was copied. It must
|
||||||
|
// also fail closed if the destination becomes unreadable before source removal.
|
||||||
|
func TestPoolsMigrationCleanupRechecksSource(t *testing.T) {
|
||||||
|
for _, change := range []string{"delete-null", "delete-uuid", "unreadable-destination", "missing-destination"} {
|
||||||
|
t.Run(change, func(t *testing.T) {
|
||||||
|
z, bucket := migrationTestPools(t)
|
||||||
|
move := migrationTestMover(t, z, 0, "decommission")
|
||||||
|
oi := migrationTestObject(t, z, bucket, "cleanup", 0, false, []string{"payload"}, ObjectOptions{})
|
||||||
|
snapshot := []FileInfo{{VersionID: oi.VersionID, ModTime: oi.ModTime}}
|
||||||
|
mustIAM(t, move(t.Context(), 0, bucket, migrationTestReader(t, z, 0, oi)))
|
||||||
|
switch change {
|
||||||
|
case "delete-null", "delete-uuid":
|
||||||
|
_, err := z.DeleteObject(t.Context(), bucket, oi.Name, ObjectOptions{VersionSuspended: change == "delete-null", Versioned: change == "delete-uuid"})
|
||||||
|
mustIAM(t, err)
|
||||||
|
case "unreadable-destination":
|
||||||
|
restore := conditionalPutSwapDisks(z.serverPools[1], oi.Name, func(d StorageAPI) StorageAPI {
|
||||||
|
return consistencyReadFaultDisk{StorageAPI: d, bucket: bucket, object: oi.Name}
|
||||||
|
})
|
||||||
|
defer restore()
|
||||||
|
case "missing-destination":
|
||||||
|
_, err := z.serverPools[1].DeleteObject(t.Context(), bucket, oi.Name, ObjectOptions{VersionID: nullVersionID})
|
||||||
|
mustIAM(t, err)
|
||||||
|
}
|
||||||
|
err := z.removeMovedVersions(t.Context(), bucket, oi.Name, 0, snapshot)
|
||||||
|
if change != "delete-uuid" && err == nil {
|
||||||
|
t.Fatal("cleanup accepted changed or uncertain state")
|
||||||
|
}
|
||||||
|
if change == "delete-null" || change == "delete-uuid" {
|
||||||
|
if got, err := z.GetObjectInfo(t.Context(), bucket, oi.Name, ObjectOptions{}); !isErrObjectNotFound(err) || !got.DeleteMarker {
|
||||||
|
t.Fatalf("cleanup resurrected data: %v", err)
|
||||||
|
}
|
||||||
|
} else if _, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, oi.Name, ObjectOptions{}); err != nil {
|
||||||
|
t.Fatalf("cleanup lost source: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolsMigrationConflictingTimestampTie(t *testing.T) {
|
||||||
|
for _, kind := range []string{"rebalance", "decommission"} {
|
||||||
|
for source := range 2 {
|
||||||
|
t.Run(fmt.Sprintf("%s/source=%d", kind, source), func(t *testing.T) {
|
||||||
|
z, bucket := migrationTestPools(t)
|
||||||
|
stamp := UTCNow().Add(-time.Minute)
|
||||||
|
for i := range 2 {
|
||||||
|
putConsistencyObject(t, z, bucket, "tie", i, fmt.Sprintf("pool-%d", i), ObjectOptions{MTime: stamp})
|
||||||
|
}
|
||||||
|
if got := migrationTestBody(t, z.GetObjectNInfo, bucket, "tie", ""); got != "pool-0" {
|
||||||
|
t.Fatalf("fixture winner: %s", got)
|
||||||
|
}
|
||||||
|
migrationTestWalk(t, z, bucket, source, kind)
|
||||||
|
if got := migrationTestBody(t, z.GetObjectNInfo, bucket, "tie", ""); got != "pool-0" {
|
||||||
|
t.Fatalf("changed tie winner: %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,11 +24,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||||
xhttp "github.com/minio/minio/internal/http"
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,15 +116,18 @@ func migrationTestPools(t *testing.T) (*erasureServerPools, string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
z, bucket := consistencyPools(t)
|
z, bucket := consistencyPools(t)
|
||||||
for _, pool := range z.serverPools {
|
for _, pool := range z.serverPools {
|
||||||
for _, set := range pool.sets {
|
pool.erasureDisksMu.Lock()
|
||||||
original := set.getDisks
|
previous := pool.erasureDisks
|
||||||
disks := append([]StorageAPI(nil), original()...)
|
wrapped := make([][]StorageAPI, len(previous))
|
||||||
for i := range disks {
|
for i, disks := range previous {
|
||||||
disks[i] = tagTestCapacityDisk{StorageAPI: disks[i]}
|
wrapped[i] = append([]StorageAPI(nil), disks...)
|
||||||
|
for j := range wrapped[i] {
|
||||||
|
wrapped[i][j] = tagTestCapacityDisk{StorageAPI: wrapped[i][j]}
|
||||||
}
|
}
|
||||||
set.getDisks = func() []StorageAPI { return disks }
|
|
||||||
t.Cleanup(func() { set.getDisks = original })
|
|
||||||
}
|
}
|
||||||
|
pool.erasureDisks = wrapped
|
||||||
|
pool.erasureDisksMu.Unlock()
|
||||||
|
t.Cleanup(func() { pool.erasureDisksMu.Lock(); pool.erasureDisks = previous; pool.erasureDisksMu.Unlock() })
|
||||||
}
|
}
|
||||||
return z, bucket
|
return z, bucket
|
||||||
}
|
}
|
||||||
@@ -131,7 +136,10 @@ func migrationTestMover(t *testing.T, z *erasureServerPools, source int, kind st
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
if kind == "rebalance" {
|
if kind == "rebalance" {
|
||||||
z.rebalMu.Lock()
|
z.rebalMu.Lock()
|
||||||
z.rebalMeta = &rebalanceMeta{PoolStats: []*rebalanceStats{{}, {}}}
|
z.rebalMeta = &rebalanceMeta{PoolStats: make([]*rebalanceStats, len(z.serverPools))}
|
||||||
|
for i := range z.rebalMeta.PoolStats {
|
||||||
|
z.rebalMeta.PoolStats[i] = &rebalanceStats{}
|
||||||
|
}
|
||||||
z.rebalMeta.PoolStats[source] = &rebalanceStats{Participating: true, Info: rebalanceInfo{Status: rebalStarted}}
|
z.rebalMeta.PoolStats[source] = &rebalanceStats{Participating: true, Info: rebalanceInfo{Status: rebalStarted}}
|
||||||
z.rebalMu.Unlock()
|
z.rebalMu.Unlock()
|
||||||
t.Cleanup(func() { z.rebalMu.Lock(); z.rebalMeta = nil; z.rebalMu.Unlock() })
|
t.Cleanup(func() { z.rebalMu.Lock(); z.rebalMeta = nil; z.rebalMu.Unlock() })
|
||||||
@@ -590,3 +598,331 @@ func TestPoolsMigrationUnreadablePool(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Three pools give a move two candidate destinations.
|
||||||
|
func migrationTestThreePools(t *testing.T) (*erasureServerPools, string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
dirs, err := getRandomDisks(12)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var endpoints EndpointServerPools
|
||||||
|
for i := range 3 {
|
||||||
|
endpoints = append(endpoints, mustGetPoolEndpoints(i, dirs[4*i:4*i+4]...)...)
|
||||||
|
}
|
||||||
|
obj, _, err := initObjectLayer(ctx, endpoints)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
removeRoots(dirs)
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
z := obj.(*erasureServerPools)
|
||||||
|
previous := newObjectLayerFn()
|
||||||
|
setObjectLayer(z)
|
||||||
|
t.Cleanup(func() { cancel(); z.Shutdown(context.Background()); removeRoots(dirs); setObjectLayer(previous) })
|
||||||
|
bucket := "pool-migration"
|
||||||
|
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return z, bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
type migrationTestGetter func(context.Context, string, string, *HTTPRangeSpec, http.Header, ObjectOptions) (*GetObjectReader, error)
|
||||||
|
|
||||||
|
// migrationTestBody returns the content read for a version, or the read error.
|
||||||
|
func migrationTestBody(t *testing.T, get migrationTestGetter, bucket, object, versionID string) string {
|
||||||
|
t.Helper()
|
||||||
|
gr, err := get(t.Context(), bucket, object, nil, nil, ObjectOptions{VersionID: versionID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("<%v>", err)
|
||||||
|
}
|
||||||
|
defer gr.Close()
|
||||||
|
data, err := io.ReadAll(gr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrationTestWalk runs one bucket of a decommission or rebalance of source,
|
||||||
|
// including the walker's accounting and source cleanup. It returns the number
|
||||||
|
// of versions decommission counted as failed.
|
||||||
|
func migrationTestWalk(t *testing.T, z *erasureServerPools, bucket string, source int, kind string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
migrationTestMover(t, z, source, kind)
|
||||||
|
if kind == "rebalance" {
|
||||||
|
if err := z.rebalanceBucket(t.Context(), bucket, source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
z.poolMetaMutex.Lock()
|
||||||
|
z.poolMeta.Pools[source].LastUpdate = UTCNow() // keep pool.bin and peers out of the walk
|
||||||
|
z.poolMetaMutex.Unlock()
|
||||||
|
if err := z.decommissionPool(t.Context(), source, z.serverPools[source], decomBucketInfo{Name: bucket}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
z.poolMetaMutex.RLock()
|
||||||
|
defer z.poolMetaMutex.RUnlock()
|
||||||
|
return z.poolMeta.Pools[source].Decommission.ItemsDecommissionFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrationTestTiered stores a null version whose data is in a remote tier, in
|
||||||
|
// the form decommission moves it.
|
||||||
|
func migrationTestTiered(t *testing.T, z *erasureServerPools, bucket, object string, pool int, mtime time.Time) ObjectInfo {
|
||||||
|
t.Helper()
|
||||||
|
oi := putConsistencyObject(t, z, bucket, object, pool, "old", ObjectOptions{MTime: mtime})
|
||||||
|
infos, errs := readAllFileInfo(t.Context(), z.serverPools[pool].getHashedSet(oi.Name).getDisks(), "", bucket, oi.Name, nullVersionID, false, false)
|
||||||
|
if errs[0] != nil {
|
||||||
|
t.Fatal(errs[0])
|
||||||
|
}
|
||||||
|
fi := infos[0]
|
||||||
|
fi.TransitionStatus, fi.TransitionTier, fi.TransitionedObjName = lifecycle.TransitionComplete, "WARM", "remote"
|
||||||
|
if err := z.serverPools[pool].DecomTieredObject(t.Context(), bucket, oi.Name, fi, ObjectOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return oi
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cross-pool overwrite can leave copies of one version in several pools.
|
||||||
|
// Reads return the newest copy, and moving an older one must not replace it.
|
||||||
|
// An equal ModTime marks the same write, as an interrupted move leaves it.
|
||||||
|
func TestPoolsMigrationKeepsCurrentCopy(t *testing.T) {
|
||||||
|
z, bucket := migrationTestPools(t)
|
||||||
|
base := time.Date(2026, 9, 16, 8, 0, 0, 0, time.UTC)
|
||||||
|
for _, kind := range []string{"rebalance", "decommission"} {
|
||||||
|
for _, method := range []string{"put", "multipart"} {
|
||||||
|
for _, versioned := range []bool{false, true} {
|
||||||
|
for source := range 2 {
|
||||||
|
for _, offset := range []time.Duration{-time.Minute, 0, time.Minute} {
|
||||||
|
t.Run(fmt.Sprintf("%s/%s/versioned=%t/source=%d/offset=%s", kind, method, versioned, source, offset), func(t *testing.T) {
|
||||||
|
move := migrationTestMover(t, z, source, kind)
|
||||||
|
opts := ObjectOptions{Versioned: versioned, MTime: base.Add(offset)}
|
||||||
|
if versioned {
|
||||||
|
opts.VersionID = mustGetUUID()
|
||||||
|
}
|
||||||
|
oi := migrationTestObject(t, z, bucket, t.Name(), source, method == "multipart", []string{"source"}, opts)
|
||||||
|
opts.MTime = base
|
||||||
|
want, other := "source", "other"
|
||||||
|
if offset == 0 {
|
||||||
|
other = "source"
|
||||||
|
opts.PreserveETag = oi.ETag // interrupted copies preserve the original multipart ETag
|
||||||
|
} else if offset < 0 {
|
||||||
|
want = "other"
|
||||||
|
}
|
||||||
|
putConsistencyObject(t, z, bucket, t.Name(), 1-source, other, opts)
|
||||||
|
version := migrationTestVersion(oi)
|
||||||
|
if got := migrationTestBody(t, z.GetObjectNInfo, bucket, t.Name(), version); got != want {
|
||||||
|
t.Fatalf("fixture reads %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
err := move(t.Context(), source, bucket, migrationTestReader(t, z, source, oi))
|
||||||
|
if want == "other" && !isDataMovementSuperseded(err) || want == "source" && err != nil {
|
||||||
|
t.Fatalf("move: %v", err)
|
||||||
|
}
|
||||||
|
if err != nil && strings.Contains(err.Error(), "CompleteMultipartUpload") {
|
||||||
|
t.Error("copied the parts of a superseded version")
|
||||||
|
}
|
||||||
|
if got := migrationTestBody(t, z.serverPools[1-source].GetObjectNInfo, bucket, t.Name(), version); got != want {
|
||||||
|
t.Errorf("destination holds %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := migrationTestBody(t, z.GetObjectNInfo, bucket, t.Name(), version); got != want {
|
||||||
|
t.Errorf("move changed the read result to %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client overwrite, or a replica of the same version, committed while a move
|
||||||
|
// copies the old version is newer than the moved copy. The move must yield to
|
||||||
|
// it, also at multipart completion.
|
||||||
|
func TestPoolsMigrationYieldsToNewerWrite(t *testing.T) {
|
||||||
|
z, bucket := migrationTestPools(t)
|
||||||
|
for _, kind := range []string{"rebalance", "decommission"} {
|
||||||
|
for _, method := range []string{"put", "multipart"} {
|
||||||
|
for _, versioned := range []bool{false, true} {
|
||||||
|
for source := range 2 {
|
||||||
|
t.Run(fmt.Sprintf("%s/%s/versioned=%t/source=%d", kind, method, versioned, source), func(t *testing.T) {
|
||||||
|
migrationTestNewerWrite(t, z, bucket, kind, method, versioned, source)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrationTestNewerWrite(t *testing.T, z *erasureServerPools, bucket, kind, method string, versioned bool, source int) {
|
||||||
|
move := migrationTestMover(t, z, source, kind)
|
||||||
|
oi := migrationTestObject(t, z, bucket, t.Name(), source, method == "multipart", []string{"moved"}, ObjectOptions{Versioned: versioned, MTime: UTCNow().Add(-time.Hour)})
|
||||||
|
gr := migrationTestReader(t, z, source, oi)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
gate := newMigrationTestGate()
|
||||||
|
done, finished := make(chan error, 1), make(chan struct{})
|
||||||
|
if method == "multipart" {
|
||||||
|
// Hold the move after its initial check, while it copies parts.
|
||||||
|
gr.Reader = migrationTestGateReader{Reader: gr.Reader, ctx: ctx, gate: gate}
|
||||||
|
go func() { defer close(finished); done <- move(ctx, source, bucket, gr) }()
|
||||||
|
defer func() { gate.unblock(); cancel(); <-finished }()
|
||||||
|
select {
|
||||||
|
case <-gate.entered:
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("migration finished before the part barrier: %v", err)
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal(ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An ordinary PUT copies under the object lock, so this write
|
||||||
|
// lands between the mover's source read and its write.
|
||||||
|
if versioned {
|
||||||
|
putConsistencyObject(t, z, bucket, t.Name(), 1-source, "newer", ObjectOptions{Versioned: true, VersionID: oi.VersionID, MTime: UTCNow()})
|
||||||
|
} else if _, err := z.PutObject(ctx, bucket, t.Name(), mustGetPutObjReader(t, strings.NewReader("newer"), 5, "", ""), ObjectOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if method == "multipart" {
|
||||||
|
gate.unblock()
|
||||||
|
err = <-done
|
||||||
|
} else {
|
||||||
|
err = move(ctx, source, bucket, gr)
|
||||||
|
}
|
||||||
|
if !isDataMovementSuperseded(err) {
|
||||||
|
t.Fatalf("move over a newer write: %v", err)
|
||||||
|
}
|
||||||
|
if got := migrationTestBody(t, z.GetObjectNInfo, bucket, t.Name(), migrationTestVersion(oi)); got != "newer" {
|
||||||
|
t.Fatalf("newer write replaced by %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With two candidate destinations a superseded copy must neither replace the
|
||||||
|
// newer copy nor become a new duplicate in the third pool.
|
||||||
|
func TestPoolsMigrationThreePools(t *testing.T) {
|
||||||
|
z, bucket := migrationTestThreePools(t)
|
||||||
|
stale, recent := UTCNow().Add(-time.Hour), UTCNow().Add(-time.Minute)
|
||||||
|
for _, kind := range []string{"rebalance", "decommission"} {
|
||||||
|
for _, newest := range []int{0, 1} {
|
||||||
|
t.Run(fmt.Sprintf("%s/newest=%d", kind, newest), func(t *testing.T) {
|
||||||
|
move := migrationTestMover(t, z, 0, kind)
|
||||||
|
mtime := func(pool int) time.Time {
|
||||||
|
if pool == newest {
|
||||||
|
return recent
|
||||||
|
}
|
||||||
|
return stale
|
||||||
|
}
|
||||||
|
oi := migrationTestObject(t, z, bucket, t.Name(), 0, false, []string{"pool-0"}, ObjectOptions{MTime: mtime(0)})
|
||||||
|
putConsistencyObject(t, z, bucket, t.Name(), 1, "pool-1", ObjectOptions{MTime: mtime(1)})
|
||||||
|
if newest == 0 {
|
||||||
|
putConsistencyObject(t, z, bucket, t.Name(), 2, "pool-2", ObjectOptions{MTime: stale})
|
||||||
|
}
|
||||||
|
want := fmt.Sprintf("pool-%d", newest)
|
||||||
|
err := move(t.Context(), 0, bucket, migrationTestReader(t, z, 0, oi))
|
||||||
|
if newest == 1 && !isDataMovementSuperseded(err) || newest == 0 && err != nil {
|
||||||
|
t.Fatalf("move: %v", err)
|
||||||
|
}
|
||||||
|
if got := migrationTestBody(t, z.GetObjectNInfo, bucket, t.Name(), ""); got != want {
|
||||||
|
t.Errorf("reads return %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if newest == 1 {
|
||||||
|
if _, err := z.serverPools[2].GetObjectInfo(t.Context(), bucket, t.Name(), ObjectOptions{}); !isErrObjectNotFound(err) {
|
||||||
|
t.Errorf("superseded copy duplicated into pool 2: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The walkers count a superseded version as moved, so its stale source copy is
|
||||||
|
// removed with the object and the source pool drains.
|
||||||
|
func TestPoolsMigrationRetiresSupersededCopy(t *testing.T) {
|
||||||
|
for _, kind := range []string{"rebalance", "decommission"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
z, bucket := migrationTestPools(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
stale, recent := UTCNow().Add(-time.Hour), UTCNow().Add(-time.Minute)
|
||||||
|
put := func(object string, pool int, body string, multipart bool, opts ObjectOptions) ObjectInfo {
|
||||||
|
return migrationTestObject(t, z, bucket, object, pool, multipart, []string{body}, opts)
|
||||||
|
}
|
||||||
|
put("stale", 0, "old", false, ObjectOptions{MTime: stale})
|
||||||
|
put("stale", 1, "new", false, ObjectOptions{MTime: recent})
|
||||||
|
put("stale-multipart", 0, "old", true, ObjectOptions{MTime: stale})
|
||||||
|
put("stale-multipart", 1, "new", false, ObjectOptions{MTime: recent})
|
||||||
|
put("newer", 0, "new", false, ObjectOptions{MTime: recent})
|
||||||
|
put("newer", 1, "old", false, ObjectOptions{MTime: stale})
|
||||||
|
oldest := put("history", 0, "old", false, ObjectOptions{Versioned: true, MTime: stale})
|
||||||
|
put("history", 1, "new", false, ObjectOptions{Versioned: true, MTime: recent})
|
||||||
|
// A suspended bucket's older null delete marker must not hide a newer
|
||||||
|
// null version.
|
||||||
|
hidden := put("stale-marker", 0, "old", false, ObjectOptions{Versioned: true, MTime: stale.Add(-time.Minute)})
|
||||||
|
if _, err := z.serverPools[0].DeleteObject(ctx, bucket, "stale-marker", ObjectOptions{VersionSuspended: true, MTime: stale}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
put("stale-marker", 1, "new", false, ObjectOptions{VersionSuspended: true, MTime: recent})
|
||||||
|
objects := []string{"stale", "stale-multipart", "newer", "history", "stale-marker"}
|
||||||
|
if kind == "decommission" {
|
||||||
|
// Rebalance leaves tiered versions in place; decommission moves
|
||||||
|
// their metadata.
|
||||||
|
migrationTestTiered(t, z, bucket, "stale-tiered", 0, stale)
|
||||||
|
put("stale-tiered", 1, "new", false, ObjectOptions{MTime: recent})
|
||||||
|
objects = append(objects, "stale-tiered")
|
||||||
|
}
|
||||||
|
want := map[string]string{}
|
||||||
|
for _, object := range objects {
|
||||||
|
want[object] = migrationTestBody(t, z.GetObjectNInfo, bucket, object, "")
|
||||||
|
}
|
||||||
|
if want["stale"] != "new" || want["stale-marker"] != "new" {
|
||||||
|
t.Fatalf("fixture reads %v", want)
|
||||||
|
}
|
||||||
|
if failed := migrationTestWalk(t, z, bucket, 0, kind); failed != 0 {
|
||||||
|
t.Fatalf("%d versions failed to move", failed)
|
||||||
|
}
|
||||||
|
for _, object := range objects {
|
||||||
|
if got := migrationTestBody(t, z.GetObjectNInfo, bucket, object, ""); got != want[object] {
|
||||||
|
t.Errorf("%s: reads %q after the move, want %q", object, got, want[object])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var retained []string
|
||||||
|
for _, set := range z.serverPools[0].sets {
|
||||||
|
if err := set.listObjectsToDecommission(ctx, decomBucketInfo{Name: bucket}, func(entry metaCacheEntry) {
|
||||||
|
retained = append(retained, entry.name)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(retained) > 0 {
|
||||||
|
t.Errorf("source pool retained %v", retained)
|
||||||
|
}
|
||||||
|
for _, oi := range []ObjectInfo{oldest, hidden} {
|
||||||
|
if got := migrationTestBody(t, z.GetObjectNInfo, bucket, oi.Name, oi.VersionID); got != "old" {
|
||||||
|
t.Errorf("%s: lost version %s: %q", oi.Name, oi.VersionID, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tiered move that fails, here because another pool cannot be read, must keep
|
||||||
|
// its source like any other failed version.
|
||||||
|
func TestPoolsMigrationTieredFailureKeepsSource(t *testing.T) {
|
||||||
|
z, bucket := migrationTestPools(t)
|
||||||
|
tiered := migrationTestTiered(t, z, bucket, t.Name(), 0, UTCNow().Add(-time.Hour))
|
||||||
|
set := z.serverPools[1].getHashedSet(tiered.Name)
|
||||||
|
original := set.getDisks
|
||||||
|
disks := append([]StorageAPI(nil), original()...)
|
||||||
|
for i := range disks {
|
||||||
|
disks[i] = consistencyReadFaultDisk{StorageAPI: disks[i], bucket: bucket, object: tiered.Name}
|
||||||
|
}
|
||||||
|
set.getDisks = func() []StorageAPI { return disks }
|
||||||
|
defer func() { set.getDisks = original }()
|
||||||
|
if failed := migrationTestWalk(t, z, bucket, 0, "decommission"); failed != 1 {
|
||||||
|
t.Fatalf("%d failed versions, want 1", failed)
|
||||||
|
}
|
||||||
|
if _, err := z.serverPools[0].GetObjectInfo(t.Context(), bucket, tiered.Name, ObjectOptions{VersionID: nullVersionID}); err != nil {
|
||||||
|
t.Fatalf("failed move removed its source: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ func TestPoolsConditionalPutVersionSelection(t *testing.T) {
|
|||||||
opts.ReplicaLockReconcile, opts.ReplicationRequest = true, true
|
opts.ReplicaLockReconcile, opts.ReplicationRequest = true, true
|
||||||
want = addressed
|
want = addressed
|
||||||
case "movement":
|
case "movement":
|
||||||
opts.DataMovement, opts.SrcPoolIdx = true, 1
|
opts.DataMovement, opts.SrcPoolIdx, opts.MTime = true, 1, addressed.ModTime
|
||||||
want = addressed
|
want = addressed
|
||||||
case "tie":
|
case "tie":
|
||||||
want = putConsistencyObject(t, z, bucket, object, 0, "tie-winner", ObjectOptions{Versioned: true, MTime: current.ModTime})
|
want = putConsistencyObject(t, z, bucket, object, 0, "tie-winner", ObjectOptions{Versioned: true, MTime: current.ModTime})
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
|||||||
// to create the appropriate stack.
|
// to create the appropriate stack.
|
||||||
versionsSorter(fivs.Versions).reverse()
|
versionsSorter(fivs.Versions).reverse()
|
||||||
|
|
||||||
var rebalanced, expired int
|
var rebalanced int
|
||||||
for _, version := range fivs.Versions {
|
for _, version := range fivs.Versions {
|
||||||
stopFn := globalRebalanceMetrics.log(rebalanceMetricRebalanceObject, poolIdx, bucket, version.Name, version.VersionID)
|
stopFn := globalRebalanceMetrics.log(rebalanceMetricRebalanceObject, poolIdx, bucket, version.Name, version.VersionID)
|
||||||
|
|
||||||
@@ -635,21 +635,11 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
|||||||
|
|
||||||
// Apply lifecycle rules on the objects that are expired.
|
// Apply lifecycle rules on the objects that are expired.
|
||||||
if filterLifecycle(bucket, version.Name, version) {
|
if filterLifecycle(bucket, version.Name, version) {
|
||||||
expired++
|
|
||||||
stopFn(version.Size, errors.New("ILM expired object/version will be skipped"))
|
stopFn(version.Size, errors.New("ILM expired object/version will be skipped"))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// any object with only single DEL marker we don't need
|
// Preserve every marker: older versions may live in another pool.
|
||||||
// to rebalance, just skip it, this also includes
|
|
||||||
// any other versions that have already expired.
|
|
||||||
remainingVersions := len(fivs.Versions) - expired
|
|
||||||
if version.Deleted && remainingVersions == 1 {
|
|
||||||
rebalanced++
|
|
||||||
stopFn(version.Size, errors.New("DELETE marked object with no other non-current versions will be skipped"))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
versionID := version.VersionID
|
versionID := version.VersionID
|
||||||
if versionID == "" {
|
if versionID == "" {
|
||||||
versionID = nullVersionID
|
versionID = nullVersionID
|
||||||
@@ -671,6 +661,9 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
|||||||
SkipRebalancing: true, // make sure we skip the decommissioned pool
|
SkipRebalancing: true, // make sure we skip the decommissioned pool
|
||||||
NoAuditLog: true,
|
NoAuditLog: true,
|
||||||
})
|
})
|
||||||
|
if isDataMovementSuperseded(err) {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
// This can happen when rebalance stop races with ongoing rebalance workers.
|
// This can happen when rebalance stop races with ongoing rebalance workers.
|
||||||
// These rebalance failures can be ignored.
|
// These rebalance failures can be ignored.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -719,7 +712,7 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = z.rebalanceObject(ctx, poolIdx, bucket, gr); err != nil {
|
if err = z.rebalanceObject(ctx, poolIdx, bucket, gr); err != nil && !isDataMovementSuperseded(err) {
|
||||||
// This can happen when rebalance stop races with ongoing rebalance workers.
|
// This can happen when rebalance stop races with ongoing rebalance workers.
|
||||||
// These rebalance failures can be ignored.
|
// These rebalance failures can be ignored.
|
||||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
||||||
@@ -750,15 +743,7 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
|||||||
// if all versions were rebalanced, we can delete the object versions.
|
// if all versions were rebalanced, we can delete the object versions.
|
||||||
if rebalanced == len(fivs.Versions) {
|
if rebalanced == len(fivs.Versions) {
|
||||||
stopFn := globalRebalanceMetrics.log(rebalanceMetricRebalanceRemoveObject, poolIdx, bucket, entry.name)
|
stopFn := globalRebalanceMetrics.log(rebalanceMetricRebalanceRemoveObject, poolIdx, bucket, entry.name)
|
||||||
_, err := set.DeleteObject(ctx,
|
err := z.removeMovedVersions(ctx, bucket, entry.name, poolIdx, fivs.Versions)
|
||||||
bucket,
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
stopFn(0, err)
|
stopFn(0, err)
|
||||||
auditLogRebalance(ctx, "Rebalance:DeleteObject", bucket, entry.name, "", err)
|
auditLogRebalance(ctx, "Rebalance:DeleteObject", bucket, entry.name, "", err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -863,6 +848,12 @@ func (z *erasureServerPools) rebalanceObject(ctx context.Context, poolIdx int, b
|
|||||||
}
|
}
|
||||||
|
|
||||||
if oi.isMultipart() {
|
if oi.isMultipart() {
|
||||||
|
// Completion repeats this check under the object write lock.
|
||||||
|
if _, err := z.checkDataMovementCopies(ctx, bucket, encodeDirObject(oi.Name), ObjectOptions{
|
||||||
|
VersionID: oi.VersionID, MTime: oi.ModTime, SrcPoolIdx: poolIdx,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
res, err := z.NewMultipartUpload(ctx, bucket, oi.Name, ObjectOptions{
|
res, err := z.NewMultipartUpload(ctx, bucket, oi.Name, ObjectOptions{
|
||||||
VersionID: oi.VersionID,
|
VersionID: oi.VersionID,
|
||||||
UserDefined: migrationObjectMetadata(oi),
|
UserDefined: migrationObjectMetadata(oi),
|
||||||
@@ -900,6 +891,8 @@ func (z *erasureServerPools) rebalanceObject(ctx context.Context, poolIdx int, b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, err = z.CompleteMultipartUpload(ctx, bucket, oi.Name, res.UploadID, parts, ObjectOptions{
|
_, err = z.CompleteMultipartUpload(ctx, bucket, oi.Name, res.UploadID, parts, ObjectOptions{
|
||||||
|
VersionID: oi.VersionID,
|
||||||
|
SrcPoolIdx: poolIdx,
|
||||||
DataMovement: true,
|
DataMovement: true,
|
||||||
MTime: oi.ModTime,
|
MTime: oi.ModTime,
|
||||||
NoAuditLog: true,
|
NoAuditLog: true,
|
||||||
@@ -1010,7 +1003,10 @@ const (
|
|||||||
rebalanceMetricSaveMetadata
|
rebalanceMetricSaveMetadata
|
||||||
)
|
)
|
||||||
|
|
||||||
var errDataMovementSrcDstPoolSame = errors.New("source and destination pool are the same")
|
var (
|
||||||
|
errDataMovementSrcDstPoolSame = errors.New("source and destination pool are the same")
|
||||||
|
errDataMovementSuperseded = errors.New("another pool holds a newer copy")
|
||||||
|
)
|
||||||
|
|
||||||
func rebalanceTrace(r rebalanceMetric, poolIdx int, startTime time.Time, duration time.Duration, err error, path string, sz int64) madmin.TraceInfo {
|
func rebalanceTrace(r rebalanceMetric, poolIdx int, startTime time.Time, duration time.Duration, err error, path string, sz int64) madmin.TraceInfo {
|
||||||
var errStr string
|
var errStr string
|
||||||
|
|||||||
+55
-74
@@ -1183,6 +1183,14 @@ func (z *erasureServerPools) PutObject(ctx context.Context, bucket string, objec
|
|||||||
opts.CheckPrecondFn = nil
|
opts.CheckPrecondFn = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.DataMovement {
|
||||||
|
resolve, err := z.checkDataMovementCopies(ctx, bucket, object, opts)
|
||||||
|
if err != nil {
|
||||||
|
return ObjectInfo{}, err
|
||||||
|
}
|
||||||
|
opts.replicaObjectInfo = resolve
|
||||||
|
}
|
||||||
|
|
||||||
idx, err := z.getWritePoolIdx(ctx, bucket, object, data.Size(), true)
|
idx, err := z.getWritePoolIdx(ctx, bucket, object, data.Size(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ObjectInfo{}, err
|
return ObjectInfo{}, err
|
||||||
@@ -1202,7 +1210,7 @@ func (z *erasureServerPools) PutObject(ctx context.Context, bucket string, objec
|
|||||||
}
|
}
|
||||||
// Public overwrites need all pools for Object Lock. Unaddressed replica
|
// Public overwrites need all pools for Object Lock. Unaddressed replica
|
||||||
// callbacks retain their set-local reads unless reconciliation is needed.
|
// callbacks retain their set-local reads unless reconciliation is needed.
|
||||||
if opts.ReplicaLockReconcile || !opts.ReplicationRequest {
|
if opts.replicaObjectInfo == nil && (opts.ReplicaLockReconcile || !opts.ReplicationRequest) {
|
||||||
opts.replicaObjectInfo = z.replicaObjectInfo
|
opts.replicaObjectInfo = z.replicaObjectInfo
|
||||||
}
|
}
|
||||||
oi, err := z.serverPools[idx].PutObject(ctx, bucket, object, data, opts)
|
oi, err := z.serverPools[idx].PutObject(ctx, bucket, object, data, opts)
|
||||||
@@ -1243,6 +1251,25 @@ func (z *erasureServerPools) DeleteObject(ctx context.Context, bucket string, ob
|
|||||||
return ObjectInfo{}, z.deletePrefix(ctx, bucket, object)
|
return ObjectInfo{}, z.deletePrefix(ctx, bucket, object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.DataMovement {
|
||||||
|
opts.NoLock = true
|
||||||
|
if _, err := z.checkDataMovementCopies(ctx, bucket, object, opts); err != nil {
|
||||||
|
return objInfo, err
|
||||||
|
}
|
||||||
|
// A marker still needs a destination when it is the only version of
|
||||||
|
// this key outside the draining pool.
|
||||||
|
idx, err := z.getPoolIdxNoLock(ctx, bucket, object, 0)
|
||||||
|
if err != nil {
|
||||||
|
return objInfo, err
|
||||||
|
}
|
||||||
|
if idx == opts.SrcPoolIdx {
|
||||||
|
return objInfo, DataMovementOverwriteErr{Bucket: bucket, Object: object, VersionID: opts.VersionID, Err: errDataMovementSrcDstPoolSame}
|
||||||
|
}
|
||||||
|
objInfo, err = z.serverPools[idx].DeleteObject(ctx, bucket, object, opts)
|
||||||
|
objInfo.Name = decodeDirObject(object)
|
||||||
|
return objInfo, err
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve a physical purge by its addressed version even on a replica
|
// Resolve a physical purge by its addressed version even on a replica
|
||||||
// receiver: latest-key routing can select a different pool or miss copies.
|
// receiver: latest-key routing can select a different pool or miss copies.
|
||||||
// Marker creation and specialized movement/scanner operations retain their
|
// Marker creation and specialized movement/scanner operations retain their
|
||||||
@@ -1328,22 +1355,6 @@ func (z *erasureServerPools) DeleteObject(ctx context.Context, bucket string, ob
|
|||||||
return pinfo.ObjInfo, nil
|
return pinfo.ObjInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datamovement must never be allowed on the same pool.
|
|
||||||
if opts.DataMovement && opts.SrcPoolIdx == pinfo.Index {
|
|
||||||
return pinfo.ObjInfo, DataMovementOverwriteErr{
|
|
||||||
Bucket: bucket,
|
|
||||||
Object: object,
|
|
||||||
VersionID: opts.VersionID,
|
|
||||||
Err: errDataMovementSrcDstPoolSame,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.DataMovement {
|
|
||||||
objInfo, err = z.serverPools[pinfo.Index].DeleteObject(ctx, bucket, object, opts)
|
|
||||||
objInfo.Name = decodeDirObject(object)
|
|
||||||
return objInfo, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete concurrently in all server pools with read quorum error for unversioned objects.
|
// Delete concurrently in all server pools with read quorum error for unversioned objects.
|
||||||
if len(noReadQuorumPools) > 0 && !opts.Versioned && !opts.VersionSuspended {
|
if len(noReadQuorumPools) > 0 && !opts.Versioned && !opts.VersionSuspended {
|
||||||
return z.deleteObjectFromAllPools(ctx, bucket, object, opts, noReadQuorumPools)
|
return z.deleteObjectFromAllPools(ctx, bucket, object, opts, noReadQuorumPools)
|
||||||
@@ -1916,50 +1927,17 @@ func (z *erasureServerPools) ListMultipartUploads(ctx context.Context, bucket, p
|
|||||||
if _, err := z.GetBucketInfo(ctx, bucket, BucketOptions{}); err != nil {
|
if _, err := z.GetBucketInfo(ctx, bucket, BucketOptions{}); err != nil {
|
||||||
return ListMultipartsInfo{}, toObjectErr(err, bucket)
|
return ListMultipartsInfo{}, toObjectErr(err, bucket)
|
||||||
}
|
}
|
||||||
if globalAPIConfig.getMultipartListingLegacy() {
|
|
||||||
return z.listMultipartUploadsLegacy(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
|
||||||
}
|
|
||||||
scan, err := startMultipartScan(ctx, false)
|
|
||||||
if err != nil {
|
|
||||||
return ListMultipartsInfo{}, err
|
|
||||||
}
|
|
||||||
defer scan.close()
|
|
||||||
|
|
||||||
var uploads []MultipartInfo
|
|
||||||
var keyless bool
|
|
||||||
for idx, pool := range z.serverPools {
|
|
||||||
if z.IsSuspended(idx) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
poolUploads, poolKeyless, err := pool.scanMultipartUploads(scan, bucket, idx)
|
|
||||||
if err != nil {
|
|
||||||
return ListMultipartsInfo{}, err
|
|
||||||
}
|
|
||||||
uploads = append(uploads, poolUploads...)
|
|
||||||
keyless = keyless || poolKeyless
|
|
||||||
}
|
|
||||||
|
|
||||||
// The old format cannot be enumerated authoritatively. Migration mode is
|
|
||||||
// explicit: another bucket must never silently change this API's semantics.
|
|
||||||
if keyless {
|
|
||||||
return ListMultipartsInfo{}, errMultipartListingLegacy
|
|
||||||
}
|
|
||||||
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (z *erasureServerPools) listMultipartUploadsLegacy(ctx context.Context, bucket, prefix, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (ListMultipartsInfo, error) {
|
|
||||||
poolResult := ListMultipartsInfo{}
|
poolResult := ListMultipartsInfo{}
|
||||||
poolResult.MaxUploads = maxUploads
|
poolResult.MaxUploads = maxUploads
|
||||||
poolResult.KeyMarker = keyMarker
|
poolResult.KeyMarker = keyMarker
|
||||||
poolResult.Prefix = prefix
|
poolResult.Prefix = prefix
|
||||||
poolResult.Delimiter = delimiter
|
poolResult.Delimiter = delimiter
|
||||||
|
|
||||||
// if no prefix provided, return the list from cache
|
// Preserve the legacy cache view, including its incomplete pagination.
|
||||||
|
// Persistent discovery remains an explicit admin diagnostic, not a
|
||||||
|
// prerequisite for public listing or known-upload operations.
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
if _, err := z.GetBucketInfo(ctx, bucket, BucketOptions{}); err != nil {
|
|
||||||
return ListMultipartsInfo{}, toObjectErr(err, bucket)
|
|
||||||
}
|
|
||||||
|
|
||||||
z.mpCache.Range(func(_ string, mp MultipartInfo) bool {
|
z.mpCache.Range(func(_ string, mp MultipartInfo) bool {
|
||||||
if mp.Bucket == bucket {
|
if mp.Bucket == bucket {
|
||||||
poolResult.Uploads = append(poolResult.Uploads, mp)
|
poolResult.Uploads = append(poolResult.Uploads, mp)
|
||||||
@@ -2199,28 +2177,15 @@ func (z *erasureServerPools) AbortMultipartUpload(ctx context.Context, bucket, o
|
|||||||
ctx = lkctx.Context()
|
ctx = lkctx.Context()
|
||||||
defer lk.Unlock(lkctx)
|
defer lk.Unlock(lkctx)
|
||||||
|
|
||||||
legacy := globalAPIConfig.getMultipartListingLegacy()
|
|
||||||
found := false
|
|
||||||
var firstErr error
|
|
||||||
for idx, pool := range z.serverPools {
|
for idx, pool := range z.serverPools {
|
||||||
if z.IsSuspended(idx) {
|
if z.IsSuspended(idx) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
poolFound, err := pool.getHashedSet(object).abortMultipartUpload(ctx, bucket, object, uploadID, opts, legacy)
|
found, err := pool.getHashedSet(object).abortMultipartUpload(ctx, bucket, object, uploadID, opts)
|
||||||
if legacy && (poolFound || err != nil) {
|
if found || err != nil {
|
||||||
// Match the released first-matching-pool behavior.
|
// Preserve the default first-matching-pool behavior.
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
found = found || poolFound
|
|
||||||
if err != nil && firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if firstErr != nil {
|
|
||||||
return firstErr
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return InvalidUploadID{
|
return InvalidUploadID{
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@@ -2294,6 +2259,13 @@ func (z *erasureServerPools) CompleteMultipartUpload(ctx context.Context, bucket
|
|||||||
}
|
}
|
||||||
opts.CheckPrecondFn = nil
|
opts.CheckPrecondFn = nil
|
||||||
}
|
}
|
||||||
|
if opts.DataMovement {
|
||||||
|
resolve, err := z.checkDataMovementCopies(ctx, bucket, encodeDirObject(object), opts)
|
||||||
|
if err != nil {
|
||||||
|
return ObjectInfo{}, err
|
||||||
|
}
|
||||||
|
opts.replicaObjectInfo = resolve
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hold write locks to verify uploaded parts, also disallows any
|
// Hold write locks to verify uploaded parts, also disallows any
|
||||||
@@ -2313,7 +2285,7 @@ func (z *erasureServerPools) CompleteMultipartUpload(ctx context.Context, bucket
|
|||||||
if opts.ReplicaLockReconcile || opts.DataMovement {
|
if opts.ReplicaLockReconcile || opts.DataMovement {
|
||||||
opts.ReplicaLockReconcile = true
|
opts.ReplicaLockReconcile = true
|
||||||
}
|
}
|
||||||
if opts.ReplicaLockReconcile || !opts.ReplicationRequest {
|
if opts.replicaObjectInfo == nil && (opts.ReplicaLockReconcile || !opts.ReplicationRequest) {
|
||||||
opts.replicaObjectInfo = z.replicaObjectInfo
|
opts.replicaObjectInfo = z.replicaObjectInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2651,9 +2623,9 @@ func (z *erasureServerPools) Walk(ctx context.Context, bucket, prefix string, re
|
|||||||
minDisks: listingQuorum,
|
minDisks: listingQuorum,
|
||||||
reportNotFound: false,
|
reportNotFound: false,
|
||||||
agreed: send,
|
agreed: send,
|
||||||
partial: func(entries metaCacheEntries, _ []error) {
|
partial: func(entries metaCacheEntries, errs []error) {
|
||||||
entry, ok := entries.resolve(&resolver)
|
entry := set.resolveListEntry(entries, errs, &resolver)
|
||||||
if ok {
|
if entry != nil {
|
||||||
send(*entry)
|
send(*entry)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2755,7 +2727,11 @@ func (z *erasureServerPools) Walk(ctx context.Context, bucket, prefix string, re
|
|||||||
// Merge all entries from all disks.
|
// Merge all entries from all disks.
|
||||||
// We leave quorum at 1, since entries are already resolved to have the desired quorum.
|
// We leave quorum at 1, since entries are already resolved to have the desired quorum.
|
||||||
// mergeEntryChannels will close 'merged' channel upon completion or cancellation.
|
// mergeEntryChannels will close 'merged' channel upon completion or cancellation.
|
||||||
errCh <- mergeEntryChannels(ctx, entries, merged, 1)
|
errCh <- mergeEntryChannelsWithResolver(ctx, entries, merged, 1, func(int) error {
|
||||||
|
return context.Cause(ctx)
|
||||||
|
}, func(group []*metaCacheEntry) (*metaCacheEntry, error) {
|
||||||
|
return z.resolveListingGroup(ctx, bucket, group)
|
||||||
|
})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -3300,6 +3276,11 @@ func (z *erasureServerPools) DecomTieredObject(ctx context.Context, bucket, obje
|
|||||||
defer ns.Unlock(lkctx)
|
defer ns.Unlock(lkctx)
|
||||||
opts.NoLock = true
|
opts.NoLock = true
|
||||||
}
|
}
|
||||||
|
if opts.DataMovement {
|
||||||
|
if _, err := z.checkDataMovementCopies(ctx, bucket, object, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
idx, err := z.getPoolIdxNoLock(ctx, bucket, object, fi.Size)
|
idx, err := z.getPoolIdxNoLock(ctx, bucket, object, fi.Size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+1
-30
@@ -886,36 +886,7 @@ func (s *erasureSets) CopyObject(ctx context.Context, srcBucket, srcObject, dstB
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *erasureSets) ListMultipartUploads(ctx context.Context, bucket, prefix, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (result ListMultipartsInfo, err error) {
|
func (s *erasureSets) ListMultipartUploads(ctx context.Context, bucket, prefix, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (result ListMultipartsInfo, err error) {
|
||||||
if err := checkListMultipartArgs(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter); err != nil {
|
return s.getHashedSet(prefix).ListMultipartUploads(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
||||||
return ListMultipartsInfo{}, err
|
|
||||||
}
|
|
||||||
scan, err := startMultipartScan(ctx, false)
|
|
||||||
if err != nil {
|
|
||||||
return ListMultipartsInfo{}, err
|
|
||||||
}
|
|
||||||
defer scan.close()
|
|
||||||
uploads, legacy, err := s.scanMultipartUploads(scan, bucket, 0)
|
|
||||||
if err != nil {
|
|
||||||
return ListMultipartsInfo{}, err
|
|
||||||
}
|
|
||||||
if legacy {
|
|
||||||
return ListMultipartsInfo{}, errMultipartListingLegacy
|
|
||||||
}
|
|
||||||
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *erasureSets) scanMultipartUploads(scan *multipartScan, bucket string, poolIdx int) ([]MultipartInfo, bool, error) {
|
|
||||||
var uploads []MultipartInfo
|
|
||||||
var keyless bool
|
|
||||||
for i, set := range s.sets {
|
|
||||||
setUploads, setKeyless, err := set.scanMultipartUploads(scan, bucket, poolIdx, i)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
uploads = append(uploads, setUploads...)
|
|
||||||
keyless = keyless || setKeyless
|
|
||||||
}
|
|
||||||
return uploads, keyless, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *erasureSets) listMultipartUploadsExact(ctx context.Context, bucket, object string) (ListMultipartsInfo, error) {
|
func (s *erasureSets) listMultipartUploadsExact(ctx context.Context, bucket, object string) (ListMultipartsInfo, error) {
|
||||||
|
|||||||
@@ -319,6 +319,8 @@ var (
|
|||||||
|
|
||||||
globalOperationTimeout = newDynamicTimeout(10*time.Minute, 5*time.Minute) // default timeout for general ops
|
globalOperationTimeout = newDynamicTimeout(10*time.Minute, 5*time.Minute) // default timeout for general ops
|
||||||
globalDeleteOperationTimeout = newDynamicTimeout(5*time.Minute, 1*time.Minute) // default time for delete ops
|
globalDeleteOperationTimeout = newDynamicTimeout(5*time.Minute, 1*time.Minute) // default time for delete ops
|
||||||
|
// Listing recovery has its own failure history and a five-second caller budget.
|
||||||
|
globalListingOperationTimeout = newDynamicTimeout(5*time.Second, 5*time.Second)
|
||||||
|
|
||||||
globalBucketObjectLockSys *BucketObjectLockSys
|
globalBucketObjectLockSys *BucketObjectLockSys
|
||||||
globalBucketQuotaSys *BucketQuotaSys
|
globalBucketQuotaSys *BucketQuotaSys
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ type apiConfig struct {
|
|||||||
transitionWorkers int
|
transitionWorkers int
|
||||||
|
|
||||||
staleUploadsExpiry time.Duration
|
staleUploadsExpiry time.Duration
|
||||||
multipartListingStrict bool
|
|
||||||
staleUploadsCleanupInterval time.Duration
|
staleUploadsCleanupInterval time.Duration
|
||||||
deleteCleanupInterval time.Duration
|
deleteCleanupInterval time.Duration
|
||||||
enableODirect bool
|
enableODirect bool
|
||||||
@@ -182,7 +181,6 @@ func (t *apiConfig) init(cfg api.Config, setDriveCounts []int, legacy bool) {
|
|||||||
t.transitionWorkers = cfg.TransitionWorkers
|
t.transitionWorkers = cfg.TransitionWorkers
|
||||||
|
|
||||||
t.staleUploadsExpiry = cfg.StaleUploadsExpiry
|
t.staleUploadsExpiry = cfg.StaleUploadsExpiry
|
||||||
t.multipartListingStrict = cfg.MultipartListing == "strict"
|
|
||||||
t.deleteCleanupInterval = cfg.DeleteCleanupInterval
|
t.deleteCleanupInterval = cfg.DeleteCleanupInterval
|
||||||
t.enableODirect = cfg.EnableODirect
|
t.enableODirect = cfg.EnableODirect
|
||||||
t.gzipObjects = cfg.GzipObjects
|
t.gzipObjects = cfg.GzipObjects
|
||||||
@@ -208,12 +206,6 @@ func (t *apiConfig) odirectEnabled() bool {
|
|||||||
return t.enableODirect
|
return t.enableODirect
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *apiConfig) getMultipartListingLegacy() bool {
|
|
||||||
t.mu.RLock()
|
|
||||||
defer t.mu.RUnlock()
|
|
||||||
return !t.multipartListingStrict
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *apiConfig) shouldGzipObjects() bool {
|
func (t *apiConfig) shouldGzipObjects() bool {
|
||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ func TestIAMPeerDeleteNotificationReloadsCommittedState(t *testing.T) {
|
|||||||
resetTestGlobals()
|
resetTestGlobals()
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
// Both caches use object storage, independent of earlier etcd suites.
|
||||||
|
globalEtcdClient = nil
|
||||||
obj, disk, err := prepareFS(ctx)
|
obj, disk, err := prepareFS(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const (
|
|||||||
iamRevisionPeerPath = "/v3/site-replication/peer/iam-revisions"
|
iamRevisionPeerPath = "/v3/site-replication/peer/iam-revisions"
|
||||||
iamUserBoundaryType = "silo-user-revocation"
|
iamUserBoundaryType = "silo-user-revocation"
|
||||||
iamGroupBoundaryType = "silo-group-revocation"
|
iamGroupBoundaryType = "silo-group-revocation"
|
||||||
|
iamSTSDeleteType = "silo-sts-revocation"
|
||||||
maxIAMRevisionBatch = 128
|
maxIAMRevisionBatch = 128
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ type iamGroupBoundary struct {
|
|||||||
// before applying any change that would lose revocation or member metadata.
|
// before applying any change that would lose revocation or member metadata.
|
||||||
type iamReplicationItem struct {
|
type iamReplicationItem struct {
|
||||||
madmin.SRIAMItem
|
madmin.SRIAMItem
|
||||||
|
STSExpiresAt time.Time `json:"stsExpiresAt,omitempty"`
|
||||||
GroupGrants map[string]time.Time `json:"groupGrants,omitempty"`
|
GroupGrants map[string]time.Time `json:"groupGrants,omitempty"`
|
||||||
GroupSnapshot bool `json:"groupSnapshot,omitempty"`
|
GroupSnapshot bool `json:"groupSnapshot,omitempty"`
|
||||||
UserRevocation *iamUserBoundary `json:"userRevocation,omitempty"`
|
UserRevocation *iamUserBoundary `json:"userRevocation,omitempty"`
|
||||||
@@ -155,6 +157,13 @@ type (
|
|||||||
|
|
||||||
func (c *SiteReplicationSys) replicationItem(ctx context.Context, item madmin.SRIAMItem) (iamReplicationItem, error) {
|
func (c *SiteReplicationSys) replicationItem(ctx context.Context, item madmin.SRIAMItem) (iamReplicationItem, error) {
|
||||||
out := iamReplicationItem{SRIAMItem: item}
|
out := iamReplicationItem{SRIAMItem: item}
|
||||||
|
if item.Type == iamSTSDeleteType {
|
||||||
|
r, err := loadIAMRevision(ctx, globalIAMSys.store, getUserIdentityPath(item.Name, stsUser))
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.STSExpiresAt = r.ExpiresAt
|
||||||
|
}
|
||||||
if item.Type == madmin.SRIAMItemSvcAcc && item.SvcAccChange != nil {
|
if item.Type == madmin.SRIAMItemSvcAcc && item.SvcAccChange != nil {
|
||||||
var key string
|
var key string
|
||||||
if item.SvcAccChange.Create != nil {
|
if item.SvcAccChange.Create != nil {
|
||||||
@@ -242,6 +251,15 @@ func applyIAMReplicationItem(ctx context.Context, item iamReplicationItem) error
|
|||||||
ctx = context.WithValue(ctx, iamRecordBoundaryKey{}, item.RevokedBefore)
|
ctx = context.WithValue(ctx, iamRecordBoundaryKey{}, item.RevokedBefore)
|
||||||
}
|
}
|
||||||
switch item.Type {
|
switch item.Type {
|
||||||
|
case iamSTSDeleteType:
|
||||||
|
if item.Name == "" || strings.ContainsAny(item.Name, "/\\") || item.UpdatedAt.IsZero() {
|
||||||
|
return errSRInvalidRequest(errInvalidArgument)
|
||||||
|
}
|
||||||
|
if err := globalIAMSys.store.revokeTempUser(withIAMReplicationTime(ctx, item.UpdatedAt), item.Name, item.STSExpiresAt); err != nil {
|
||||||
|
return iamReplicationError(err)
|
||||||
|
}
|
||||||
|
globalIAMSys.notifyForUser(ctx, item.Name, true)
|
||||||
|
return nil
|
||||||
case iamUserBoundaryType:
|
case iamUserBoundaryType:
|
||||||
if item.UserRevocation == nil || item.UserRevocation.User == "" || item.UserRevocation.Before.IsZero() {
|
if item.UserRevocation == nil || item.UserRevocation.User == "" || item.UserRevocation.Before.IsZero() {
|
||||||
return errSRInvalidRequest(errInvalidArgument)
|
return errSRInvalidRequest(errInvalidArgument)
|
||||||
|
|||||||
+31
-7
@@ -348,6 +348,13 @@ func saveIAMRevision(ctx context.Context, store IAMStorageAPI, path string, item
|
|||||||
// STS access keys identify immutable tokens, not reusable user names.
|
// STS access keys identify immutable tokens, not reusable user names.
|
||||||
return errIAMStaleUpdate
|
return errIAMStaleUpdate
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(path, iamConfigSTSPrefix) && deleted {
|
||||||
|
// STS keys are immutable. A delayed revocation must also defeat a
|
||||||
|
// replayed creation that was recorded with a newer event timestamp.
|
||||||
|
if origin, replicated := iamReplicationTime(ctx); replicated && previous.timestamp().After(origin) {
|
||||||
|
ctx = withIAMReplicationTime(ctx, previous.timestamp())
|
||||||
|
}
|
||||||
|
}
|
||||||
if origin, replicated := iamReplicationTime(ctx); replicated {
|
if origin, replicated := iamReplicationTime(ctx); replicated {
|
||||||
*at = origin
|
*at = origin
|
||||||
if previous.timestamp().After(origin) || (previous.Deleted && !deleted && !origin.After(previous.timestamp())) {
|
if previous.timestamp().After(origin) || (previous.Deleted && !deleted && !origin.After(previous.timestamp())) {
|
||||||
@@ -394,12 +401,19 @@ func saveIAMRevision(ctx context.Context, store IAMStorageAPI, path string, item
|
|||||||
// Retain only the parent name for root-account exclusion during heal.
|
// Retain only the parent name for root-account exclusion during heal.
|
||||||
v.Credentials = auth.Credentials{ParentUser: previous.Credentials.ParentUser}
|
v.Credentials = auth.Credentials{ParentUser: previous.Credentials.ParentUser}
|
||||||
v.RevokedBefore = *at
|
v.RevokedBefore = *at
|
||||||
if strings.HasPrefix(path, iamConfigSTSPrefix) && !previous.Credentials.Expiration.IsZero() && !previous.Credentials.Expiration.Equal(timeSentinel) {
|
if strings.HasPrefix(path, iamConfigSTSPrefix) {
|
||||||
// The signed STS token cannot authorize beyond this time, even
|
// Carry expiry when a revocation arrives before the token itself.
|
||||||
// if an offline site replays it with a newer event timestamp.
|
// It is safe to forget this key only after its signed lifetime.
|
||||||
v.ExpiresAt = previous.Credentials.Expiration.Add(globalMaxSkewTime)
|
if expiration := previous.Credentials.Expiration; !expiration.IsZero() && !expiration.Equal(timeSentinel) && expiration.Add(globalMaxSkewTime).After(v.ExpiresAt) {
|
||||||
|
v.ExpiresAt = expiration.Add(globalMaxSkewTime)
|
||||||
|
}
|
||||||
|
if previous.ExpiresAt.After(v.ExpiresAt) {
|
||||||
|
v.ExpiresAt = previous.ExpiresAt
|
||||||
|
}
|
||||||
|
if !v.ExpiresAt.IsZero() {
|
||||||
opts = []options{{ttl: max(1, int64(time.Until(v.ExpiresAt).Seconds())+1)}}
|
opts = []options{{ttl: max(1, int64(time.Until(v.ExpiresAt).Seconds())+1)}}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if v.Credentials.SessionToken != "" && v.Credentials.Claims == nil {
|
if v.Credentials.SessionToken != "" && v.Credentials.Claims == nil {
|
||||||
claims, err := extractJWTClaims(*v)
|
claims, err := extractJWTClaims(*v)
|
||||||
@@ -489,6 +503,12 @@ func iamDeletionItem(path string, r iamRevision) (item madmin.SRIAMItem, ok bool
|
|||||||
name := strings.TrimSuffix(strings.TrimPrefix(path, iamConfigUsersPrefix), "/"+iamIdentityFile)
|
name := strings.TrimSuffix(strings.TrimPrefix(path, iamConfigUsersPrefix), "/"+iamIdentityFile)
|
||||||
item.Type = madmin.SRIAMItemIAMUser
|
item.Type = madmin.SRIAMItemIAMUser
|
||||||
item.IAMUser = &madmin.SRIAMUser{AccessKey: name, IsDeleteReq: true}
|
item.IAMUser = &madmin.SRIAMUser{AccessKey: name, IsDeleteReq: true}
|
||||||
|
case strings.HasPrefix(path, iamConfigSTSPrefix):
|
||||||
|
if !r.ExpiresAt.IsZero() && UTCNow().After(r.ExpiresAt) {
|
||||||
|
return item, false
|
||||||
|
}
|
||||||
|
item.Type = iamSTSDeleteType
|
||||||
|
item.Name = strings.TrimSuffix(strings.TrimPrefix(path, iamConfigSTSPrefix), "/"+iamIdentityFile)
|
||||||
case strings.HasPrefix(path, iamConfigServiceAccountsPrefix):
|
case strings.HasPrefix(path, iamConfigServiceAccountsPrefix):
|
||||||
name := strings.TrimSuffix(strings.TrimPrefix(path, iamConfigServiceAccountsPrefix), "/"+iamIdentityFile)
|
name := strings.TrimSuffix(strings.TrimPrefix(path, iamConfigServiceAccountsPrefix), "/"+iamIdentityFile)
|
||||||
if name == siteReplicatorSvcAcc || r.Credentials.ParentUser == globalActiveCred.AccessKey {
|
if name == siteReplicatorSvcAcc || r.Credentials.ParentUser == globalActiveCred.AccessKey {
|
||||||
@@ -518,8 +538,7 @@ func iamDeletionItem(path string, r iamRevision) (item madmin.SRIAMItem, ok bool
|
|||||||
item.Type = madmin.SRIAMItemPolicyMapping
|
item.Type = madmin.SRIAMItemPolicyMapping
|
||||||
item.PolicyMapping = &madmin.SRPolicyMapping{UserOrGroup: strings.TrimSuffix(name, ".json"), UserType: int(typ), IsGroup: prefix == "groups"}
|
item.PolicyMapping = &madmin.SRPolicyMapping{UserOrGroup: strings.TrimSuffix(name, ".json"), UserType: int(typ), IsGroup: prefix == "groups"}
|
||||||
default:
|
default:
|
||||||
// Expired STS credentials are not replayed. Parent revocations and
|
// Other config paths do not represent replicated IAM deletions.
|
||||||
// their retained timestamp reject delayed copies of derived tokens.
|
|
||||||
return item, false
|
return item, false
|
||||||
}
|
}
|
||||||
return item, true
|
return item, true
|
||||||
@@ -527,6 +546,8 @@ func iamDeletionItem(path string, r iamRevision) (item madmin.SRIAMItem, ok bool
|
|||||||
|
|
||||||
func iamDeletionPath(item madmin.SRIAMItem) string {
|
func iamDeletionPath(item madmin.SRIAMItem) string {
|
||||||
switch item.Type {
|
switch item.Type {
|
||||||
|
case iamSTSDeleteType:
|
||||||
|
return getUserIdentityPath(item.Name, stsUser)
|
||||||
case madmin.SRIAMItemIAMUser:
|
case madmin.SRIAMItemIAMUser:
|
||||||
if item.IAMUser != nil && item.IAMUser.IsDeleteReq {
|
if item.IAMUser != nil && item.IAMUser.IsDeleteReq {
|
||||||
return getUserIdentityPath(item.IAMUser.AccessKey, regUser)
|
return getUserIdentityPath(item.IAMUser.AccessKey, regUser)
|
||||||
@@ -582,6 +603,9 @@ func (c *SiteReplicationSys) healIAMDeletions(ctx context.Context) (err error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out := iamReplicationItem{SRIAMItem: item}
|
out := iamReplicationItem{SRIAMItem: item}
|
||||||
|
if item.Type == iamSTSDeleteType {
|
||||||
|
out.STSExpiresAt = r.ExpiresAt
|
||||||
|
}
|
||||||
if item.Type == madmin.SRIAMItemIAMUser && !r.Deleted {
|
if item.Type == madmin.SRIAMItemIAMUser && !r.Deleted {
|
||||||
out.Type, out.IAMUser = iamUserBoundaryType, nil
|
out.Type, out.IAMUser = iamUserBoundaryType, nil
|
||||||
out.UserRevocation = &iamUserBoundary{User: item.IAMUser.AccessKey, Before: r.RevokedBefore}
|
out.UserRevocation = &iamUserBoundary{User: item.IAMUser.AccessKey, Before: r.RevokedBefore}
|
||||||
@@ -593,7 +617,7 @@ func (c *SiteReplicationSys) healIAMDeletions(ctx context.Context) (err error) {
|
|||||||
byType[item.Type] = append(byType[item.Type], out)
|
byType[item.Type] = append(byType[item.Type], out)
|
||||||
}
|
}
|
||||||
var items []iamReplicationItem
|
var items []iamReplicationItem
|
||||||
for _, typ := range []string{madmin.SRIAMItemPolicyMapping, madmin.SRIAMItemIAMUser, madmin.SRIAMItemSvcAcc, madmin.SRIAMItemGroupInfo, madmin.SRIAMItemPolicy} {
|
for _, typ := range []string{madmin.SRIAMItemPolicyMapping, madmin.SRIAMItemIAMUser, madmin.SRIAMItemSvcAcc, iamSTSDeleteType, madmin.SRIAMItemGroupInfo, madmin.SRIAMItemPolicy} {
|
||||||
items = append(items, byType[typ]...)
|
items = append(items, byType[typ]...)
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
|
|||||||
+53
-17
@@ -711,6 +711,11 @@ func (store *IAMStoreSys) LoadIAMCache(ctx context.Context, firstTime bool) erro
|
|||||||
if err := store.loadUsers(ctx, svcUser, newCache.iamUsersMap); err != nil {
|
if err := store.loadUsers(ctx, svcUser, newCache.iamUsersMap); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Rebuild STS revisions as well: a missed etcd watch event must not
|
||||||
|
// leave a cached token valid or hide its tombstone after a restart.
|
||||||
|
if err := store.loadUsers(ctx, stsUser, newCache.iamSTSAccountsMap); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
newCache.buildUserGroupMemberships()
|
newCache.buildUserGroupMemberships()
|
||||||
}
|
}
|
||||||
@@ -754,6 +759,14 @@ func (store *IAMStoreSys) LoadIAMCache(ctx context.Context, firstTime bool) erro
|
|||||||
cache.updatedAt = time.Now()
|
cache.updatedAt = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Revision loading is independent of cache replacement. Even if ongoing
|
||||||
|
// IAM writes defer the full swap, a missed STS notification must converge.
|
||||||
|
for key := range cache.iamSTSAccountsMap {
|
||||||
|
if store.revisionIndex().get(getUserIdentityPath(key, stsUser)).Deleted {
|
||||||
|
delete(cache.iamSTSAccountsMap, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2184,45 +2197,68 @@ func (store *IAMStoreSys) SetTempUser(ctx context.Context, accessKey string, cre
|
|||||||
|
|
||||||
// RevokeTokens - revokes all temporary credentials, or those with matching type,
|
// RevokeTokens - revokes all temporary credentials, or those with matching type,
|
||||||
// associated with the parent user.
|
// associated with the parent user.
|
||||||
func (store *IAMStoreSys) RevokeTokens(ctx context.Context, parentUser string, tokenRevokeType string) error {
|
func (store *IAMStoreSys) RevokeTokens(ctx context.Context, parentUser string, tokenRevokeType string) ([]string, error) {
|
||||||
if parentUser == "" {
|
if parentUser == "" {
|
||||||
return errInvalidArgument
|
return nil, errInvalidArgument
|
||||||
}
|
}
|
||||||
|
before := UTCNow()
|
||||||
cache := store.lock()
|
cache := store.lock()
|
||||||
defer store.unlock()
|
defer store.unlock()
|
||||||
|
|
||||||
secret, err := getTokenSigningKey()
|
secret, err := getTokenSigningKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Another node may have issued a token whose notification was lost.
|
||||||
var revoked bool
|
// Enumerate durable identities, retaining cached entries for retrying a
|
||||||
for _, ui := range cache.iamSTSAccountsMap {
|
// partially propagated revocation. Do not hold up cached authentication.
|
||||||
if ui.Credentials.ParentUser != parentUser {
|
accounts := maps.Clone(cache.iamSTSAccountsMap)
|
||||||
|
if err := store.withIAMStorage(ctx, func(ctx context.Context) error {
|
||||||
|
return store.loadUsers(ctx, stsUser, accounts)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var revoked []string
|
||||||
|
var failures []error
|
||||||
|
for key, ui := range accounts {
|
||||||
|
if ui.Credentials.ParentUser != parentUser || ui.UpdatedAt.After(before) || ui.Credentials.IsExpired() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if tokenRevokeType != "" {
|
if tokenRevokeType != "" {
|
||||||
claims, err := getClaimsFromTokenWithSecret(ui.Credentials.SessionToken, secret)
|
claims, err := getClaimsFromTokenWithSecret(ui.Credentials.SessionToken, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // skip if token is invalid
|
continue
|
||||||
}
|
}
|
||||||
// skip if token type is given and does not match
|
|
||||||
if v, _ := claims.Lookup(tokenRevokeTypeClaim); v != tokenRevokeType {
|
if v, _ := claims.Lookup(tokenRevokeTypeClaim); v != tokenRevokeType {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := store.deleteUserIdentity(ctx, ui.Credentials.AccessKey, stsUser); err != nil {
|
if err := store.deleteUserIdentity(ctx, key, stsUser); err != nil {
|
||||||
return err
|
failures = append(failures, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
delete(cache.iamSTSAccountsMap, ui.Credentials.AccessKey)
|
delete(cache.iamSTSAccountsMap, key)
|
||||||
revoked = true
|
revoked = append(revoked, key)
|
||||||
}
|
}
|
||||||
|
if len(revoked) != 0 {
|
||||||
if revoked {
|
|
||||||
cache.updatedAt = time.Now()
|
cache.updatedAt = time.Now()
|
||||||
}
|
}
|
||||||
|
return revoked, errors.Join(failures...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeTempUser applies an immutable token's replicated tombstone without
|
||||||
|
// touching the parent, its policies, or other (including newer) tokens.
|
||||||
|
func (store *IAMStoreSys) revokeTempUser(ctx context.Context, key string, expiresAt time.Time) error {
|
||||||
|
cache := store.lock()
|
||||||
|
defer store.unlock()
|
||||||
|
u := UserIdentity{Version: 1, Deleted: true, ExpiresAt: expiresAt}
|
||||||
|
if err := store.saveUserIdentity(ctx, key, stsUser, &u); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(cache.iamSTSAccountsMap, key)
|
||||||
|
cache.updatedAt = time.Now()
|
||||||
|
if store.group != nil {
|
||||||
|
store.group.Forget(key)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// Copyright (c) 2026 Feng Ruohang
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/madmin-go/v3"
|
||||||
|
"github.com/minio/minio/internal/auth"
|
||||||
|
"github.com/minio/minio/internal/grid"
|
||||||
|
xnet "github.com/pgsty/silo-pkg/v3/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIAMSTSRevocationDurableScope(t *testing.T) {
|
||||||
|
for _, backend := range []string{"object", "etcd"} {
|
||||||
|
for _, notify := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("%s/notify=%t", backend, notify), func(t *testing.T) {
|
||||||
|
ctx, sys, obj := prepareIAMRevisionFixture(t, backend)
|
||||||
|
for _, parent := range []string{"sts-parent", "other-parent"} {
|
||||||
|
_, err := sys.CreateUser(ctx, parent, madmin.AddOrUpdateUserReq{SecretKey: "synthetic-parent-password", Status: madmin.AccountEnabled})
|
||||||
|
mustIAM(t, err)
|
||||||
|
}
|
||||||
|
sibling := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(obj, MinIOUsersSysType)}
|
||||||
|
if backend == "etcd" {
|
||||||
|
sibling.IAMStorageAPI = newIAMEtcdStore(sys.store.IAMStorageAPI.(*IAMEtcdStore).client, MinIOUsersSysType)
|
||||||
|
}
|
||||||
|
mustIAM(t, sibling.LoadIAMCache(ctx, false))
|
||||||
|
secret, err := getTokenSigningKey()
|
||||||
|
mustIAM(t, err)
|
||||||
|
issue := func(store *IAMStoreSys, parent, typ string) auth.Credentials {
|
||||||
|
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: parent, tokenRevokeTypeClaim: typ}, secret)
|
||||||
|
mustIAM(t, err)
|
||||||
|
cred.ParentUser = parent
|
||||||
|
_, err = store.SetTempUser(ctx, cred.AccessKey, cred, "")
|
||||||
|
mustIAM(t, err)
|
||||||
|
return cred
|
||||||
|
}
|
||||||
|
first := issue(sys.store, "sts-parent", "selected")
|
||||||
|
uncached := issue(sibling, "sts-parent", "selected")
|
||||||
|
otherType := issue(sys.store, "sts-parent", "other-type")
|
||||||
|
otherUser := issue(sys.store, "other-parent", "selected")
|
||||||
|
if _, ok := sys.store.GetUser(uncached.AccessKey); ok {
|
||||||
|
t.Fatal("fixture cached the remote token")
|
||||||
|
}
|
||||||
|
for _, c := range []auth.Credentials{first, otherType, otherUser} {
|
||||||
|
mustIAM(t, sibling.UserNotificationHandler(ctx, c.AccessKey, stsUser))
|
||||||
|
}
|
||||||
|
prior := globalNotificationSys
|
||||||
|
globalNotificationSys = &NotificationSys{}
|
||||||
|
t.Cleanup(func() { globalNotificationSys = prior })
|
||||||
|
if notify {
|
||||||
|
tg, err := grid.SetupTestGrid(2)
|
||||||
|
mustIAM(t, err)
|
||||||
|
t.Cleanup(tg.Cleanup)
|
||||||
|
mustIAM(t, loadUserRPC.Register(tg.Managers[1], func(r *grid.MSS) (grid.NoPayload, *grid.RemoteErr) {
|
||||||
|
if err := sibling.UserNotificationHandler(ctx, r.Get(peerRESTUser), stsUser); err != nil {
|
||||||
|
return grid.NoPayload{}, grid.NewRemoteErr(err)
|
||||||
|
}
|
||||||
|
return grid.NoPayload{}, nil
|
||||||
|
}))
|
||||||
|
host, err := xnet.ParseHost(strings.TrimPrefix(tg.Hosts[1], "http://"))
|
||||||
|
mustIAM(t, err)
|
||||||
|
globalNotificationSys = &NotificationSys{peerClients: []*peerRESTClient{{host: host, gridConn: func() *grid.Connection { return tg.Managers[0].Connection(tg.Hosts[1]) }}}}
|
||||||
|
}
|
||||||
|
mustIAM(t, sys.RevokeTokens(ctx, "sts-parent", "selected"))
|
||||||
|
for _, c := range []auth.Credentials{first, uncached} {
|
||||||
|
r, err := loadIAMRevision(ctx, sys.store, getUserIdentityPath(c.AccessKey, stsUser))
|
||||||
|
mustIAM(t, err)
|
||||||
|
if !r.Deleted {
|
||||||
|
t.Errorf("durable token was omitted: %s", c.AccessKey)
|
||||||
|
}
|
||||||
|
if _, ok := iamDeletionItem(getUserIdentityPath(c.AccessKey, stsUser), r); !ok {
|
||||||
|
t.Error("STS revocation cannot be replayed")
|
||||||
|
}
|
||||||
|
if notify && backend == "object" {
|
||||||
|
if _, ok := sibling.GetUser(c.AccessKey); ok {
|
||||||
|
t.Error("notified sibling accepted revoked token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fresh := issue(sibling, "sts-parent", "selected")
|
||||||
|
// A missed notification must converge through the normal cache refresh.
|
||||||
|
mustIAM(t, sibling.LoadIAMCache(ctx, false))
|
||||||
|
for _, c := range []auth.Credentials{first, uncached} {
|
||||||
|
if _, ok := sibling.GetUser(c.AccessKey); ok {
|
||||||
|
t.Error("refresh retained revoked token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range []auth.Credentials{otherType, otherUser, fresh} {
|
||||||
|
mustIAM(t, sibling.UserNotificationHandler(ctx, c.AccessKey, stsUser))
|
||||||
|
if _, ok := sibling.GetUser(c.AccessKey); !ok {
|
||||||
|
t.Error("revocation removed an out-of-scope or new token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := sibling.GetUser("sts-parent"); !ok {
|
||||||
|
t.Error("revocation removed parent")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletion is keyed by the immutable STS access key, so delayed delivery cannot
|
||||||
|
// affect a newly issued token and a delayed creation cannot undo revocation.
|
||||||
|
func TestIAMSTSRevocationReorderedReplay(t *testing.T) {
|
||||||
|
for _, backend := range []string{"object", "etcd"} {
|
||||||
|
for _, missing := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("%s/missing=%t", backend, missing), func(t *testing.T) {
|
||||||
|
ctx, sys, _ := prepareIAMRevisionFixture(t, backend)
|
||||||
|
const parent = "sts-replay-parent"
|
||||||
|
_, err := sys.CreateUser(ctx, parent, madmin.AddOrUpdateUserReq{SecretKey: "synthetic-parent-password", Status: madmin.AccountEnabled})
|
||||||
|
mustIAM(t, err)
|
||||||
|
secret, err := getTokenSigningKey()
|
||||||
|
mustIAM(t, err)
|
||||||
|
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: parent}, secret)
|
||||||
|
mustIAM(t, err)
|
||||||
|
cred.ParentUser = parent
|
||||||
|
created := UTCNow().Add(-time.Minute)
|
||||||
|
peer := madmin.SRSTSCredential{AccessKey: cred.AccessKey, SecretKey: cred.SecretKey, SessionToken: cred.SessionToken, ParentUser: parent}
|
||||||
|
if !missing {
|
||||||
|
mustIAM(t, globalSiteReplicationSys.PeerSTSAccHandler(ctx, &peer, created))
|
||||||
|
}
|
||||||
|
fresh, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: parent}, secret)
|
||||||
|
mustIAM(t, err)
|
||||||
|
fresh.ParentUser = parent
|
||||||
|
_, err = sys.SetTempUser(ctx, fresh.AccessKey, fresh, "")
|
||||||
|
mustIAM(t, err)
|
||||||
|
// Older than the live record on this peer: deletion must still win.
|
||||||
|
item := iamReplicationItem{SRIAMItem: madmin.SRIAMItem{Type: iamSTSDeleteType, Name: cred.AccessKey, UpdatedAt: created.Add(-time.Second)}, STSExpiresAt: cred.Expiration.Add(globalMaxSkewTime)}
|
||||||
|
for range 2 {
|
||||||
|
mustIAM(t, applyIAMReplicationItem(ctx, item))
|
||||||
|
mustIAM(t, globalSiteReplicationSys.PeerSTSAccHandler(ctx, &peer, UTCNow()))
|
||||||
|
mustIAM(t, sys.store.LoadIAMCache(ctx, false))
|
||||||
|
if _, ok := sys.GetUser(ctx, cred.AccessKey); ok {
|
||||||
|
t.Fatal("delayed STS creation undid revocation")
|
||||||
|
}
|
||||||
|
if _, ok := sys.GetUser(ctx, fresh.AccessKey); !ok {
|
||||||
|
t.Fatal("delayed revocation removed new token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r, err := loadIAMRevision(ctx, sys.store, getUserIdentityPath(cred.AccessKey, stsUser))
|
||||||
|
mustIAM(t, err)
|
||||||
|
if !r.Deleted || !r.ExpiresAt.Equal(item.STSExpiresAt) || r.Credentials.SessionToken != "" || r.Credentials.SecretKey != "" {
|
||||||
|
t.Fatal("revocation lost expiry or retained secret material")
|
||||||
|
}
|
||||||
|
for _, status := range []madmin.AccountStatus{madmin.AccountDisabled, madmin.AccountEnabled} {
|
||||||
|
_, err = sys.SetUserStatus(ctx, parent, status)
|
||||||
|
mustIAM(t, err)
|
||||||
|
}
|
||||||
|
if _, ok := sys.GetUser(ctx, cred.AccessKey); ok {
|
||||||
|
t.Fatal("parent re-enable revived revoked token")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause the durable scan while another node persists a new credential. The
|
||||||
|
// revocation's original cutoff must survive the scan and leave that token live.
|
||||||
|
type iamSTSScanGateStore struct {
|
||||||
|
IAMStorageAPI
|
||||||
|
entered chan struct{}
|
||||||
|
resume chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *iamSTSScanGateStore) loadUsers(ctx context.Context, typ IAMUserType, users map[string]UserIdentity) error {
|
||||||
|
close(s.entered)
|
||||||
|
select {
|
||||||
|
case <-s.resume:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return s.IAMStorageAPI.loadUsers(ctx, typ, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIAMSTSRevocationConcurrentIssuance(t *testing.T) {
|
||||||
|
ctx, sys, obj := prepareIAMRevisionFixture(t)
|
||||||
|
_, err := sys.CreateUser(ctx, "sts-race-parent", madmin.AddOrUpdateUserReq{SecretKey: "synthetic-parent-password", Status: madmin.AccountEnabled})
|
||||||
|
mustIAM(t, err)
|
||||||
|
sibling := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(obj, MinIOUsersSysType)}
|
||||||
|
secret, err := getTokenSigningKey()
|
||||||
|
mustIAM(t, err)
|
||||||
|
issue := func() auth.Credentials {
|
||||||
|
c, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: "sts-race-parent"}, secret)
|
||||||
|
mustIAM(t, err)
|
||||||
|
c.ParentUser = "sts-race-parent"
|
||||||
|
_, err = sibling.SetTempUser(ctx, c.AccessKey, c, "")
|
||||||
|
mustIAM(t, err)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
old := issue()
|
||||||
|
gate := &iamSTSScanGateStore{IAMStorageAPI: sys.store.IAMStorageAPI, entered: make(chan struct{}), resume: make(chan struct{})}
|
||||||
|
sys.store.IAMStorageAPI = gate
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- sys.RevokeTokens(ctx, "sts-race-parent", "") }()
|
||||||
|
<-gate.entered
|
||||||
|
fresh := issue()
|
||||||
|
close(gate.resume)
|
||||||
|
mustIAM(t, <-done)
|
||||||
|
mustIAM(t, sibling.UserNotificationHandler(ctx, old.AccessKey, stsUser))
|
||||||
|
if _, ok := sibling.GetUser(old.AccessKey); ok {
|
||||||
|
t.Fatal("old uncached token survived")
|
||||||
|
}
|
||||||
|
mustIAM(t, sibling.UserNotificationHandler(ctx, fresh.AccessKey, stsUser))
|
||||||
|
if _, ok := sibling.GetUser(fresh.AccessKey); !ok {
|
||||||
|
t.Fatal("new token was included in earlier revocation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type iamSTSFailureStore struct {
|
||||||
|
IAMStorageAPI
|
||||||
|
failedPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *iamSTSFailureStore) saveIAMConfig(ctx context.Context, item any, path string, opts ...options) error {
|
||||||
|
if path == s.failedPath {
|
||||||
|
return errIAMInjectedWrite
|
||||||
|
}
|
||||||
|
return s.IAMStorageAPI.saveIAMConfig(ctx, item, path, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIAMSTSRevocationPartialFailure(t *testing.T) {
|
||||||
|
ctx, sys, _ := prepareIAMRevisionFixture(t)
|
||||||
|
_, err := sys.CreateUser(ctx, "sts-partial-parent", madmin.AddOrUpdateUserReq{SecretKey: "synthetic-parent-password", Status: madmin.AccountEnabled})
|
||||||
|
mustIAM(t, err)
|
||||||
|
secret, err := getTokenSigningKey()
|
||||||
|
mustIAM(t, err)
|
||||||
|
var keys []string
|
||||||
|
for range 2 {
|
||||||
|
c, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: "sts-partial-parent"}, secret)
|
||||||
|
mustIAM(t, err)
|
||||||
|
c.ParentUser = "sts-partial-parent"
|
||||||
|
_, err = sys.SetTempUser(ctx, c.AccessKey, c, "")
|
||||||
|
mustIAM(t, err)
|
||||||
|
keys = append(keys, c.AccessKey)
|
||||||
|
}
|
||||||
|
sys.store.IAMStorageAPI = &iamSTSFailureStore{IAMStorageAPI: sys.store.IAMStorageAPI, failedPath: getUserIdentityPath(keys[0], stsUser)}
|
||||||
|
revoked, err := sys.store.RevokeTokens(ctx, "sts-partial-parent", "")
|
||||||
|
if !errors.Is(err, errIAMInjectedWrite) || len(revoked) != 1 || revoked[0] != keys[1] {
|
||||||
|
t.Fatalf("committed keys or failure lost: %v %v", revoked, err)
|
||||||
|
}
|
||||||
|
if _, ok := sys.store.GetUser(keys[1]); ok {
|
||||||
|
t.Fatal("committed revocation retained cache entry")
|
||||||
|
}
|
||||||
|
if _, ok := sys.store.GetUser(keys[0]); !ok {
|
||||||
|
t.Fatal("failed revocation claimed cache success")
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -717,7 +717,14 @@ func (sys *IAMSys) RevokeTokens(ctx context.Context, accessKey, tokenRevokeType
|
|||||||
return errServerNotInitialized
|
return errServerNotInitialized
|
||||||
}
|
}
|
||||||
|
|
||||||
return sys.store.RevokeTokens(ctx, accessKey, tokenRevokeType)
|
keys, err := sys.store.RevokeTokens(ctx, accessKey, tokenRevokeType)
|
||||||
|
// Publish every committed tombstone even if another token failed to save.
|
||||||
|
for _, key := range keys {
|
||||||
|
sys.notifyForUser(ctx, key, true)
|
||||||
|
// Offline peers recover this same record through IAM deletion healing.
|
||||||
|
iamLogIf(ctx, globalSiteReplicationSys.IAMChangeHook(ctx, madmin.SRIAMItem{Type: iamSTSDeleteType, Name: key}))
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser - delete user (only for long-term users not STS users).
|
// DeleteUser - delete user (only for long-term users not STS users).
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -41,13 +40,13 @@ func requireMultipartUploadKeys(t *testing.T, got ListMultipartsInfo, want ...st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
func TestMultipartDiagnosticScanPagination(t *testing.T) {
|
||||||
obj, dirs, err := prepareErasureSets32(t.Context())
|
obj, dirs, err := prepareErasureSets32(t.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
z := obj.(*erasureServerPools)
|
z := obj.(*erasureServerPools)
|
||||||
setMultipartListingTestMode(t, false)
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
z.Shutdown(t.Context())
|
z.Shutdown(t.Context())
|
||||||
removeRoots(dirs)
|
removeRoots(dirs)
|
||||||
@@ -82,14 +81,14 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
|||||||
uploadIDs[object] = mp.UploadID
|
uploadIDs[object] = mp.UploadID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Durable multipart metadata, rather than this node-local cache, must be
|
// The diagnostic scanner must discover metadata independently of the cache.
|
||||||
// authoritative after a restart or when another node handles the request.
|
// These checks do not claim that public listing offers durable pagination.
|
||||||
z.mpCache.Range(func(uploadID string, _ MultipartInfo) bool {
|
z.mpCache.Range(func(uploadID string, _ MultipartInfo) bool {
|
||||||
z.mpCache.Delete(uploadID)
|
z.mpCache.Delete(uploadID)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
all, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 100)
|
all, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -98,7 +97,7 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
|||||||
t.Fatalf("complete listing has truncation state: %+v", all)
|
t.Fatalf("complete listing has truncation state: %+v", all)
|
||||||
}
|
}
|
||||||
|
|
||||||
first, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 1)
|
first, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -109,31 +108,31 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
|||||||
objects[0], uploadIDs[objects[0]])
|
objects[0], uploadIDs[objects[0]])
|
||||||
}
|
}
|
||||||
|
|
||||||
rest, err := z.ListMultipartUploads(t.Context(), bucket, "", first.NextKeyMarker, first.NextUploadIDMarker, "", 100)
|
rest, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", first.NextKeyMarker, first.NextUploadIDMarker, "", 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
requireMultipartUploadKeys(t, rest, objects[1:]...)
|
requireMultipartUploadKeys(t, rest, objects[1:]...)
|
||||||
|
|
||||||
afterKey, err := z.ListMultipartUploads(t.Context(), bucket, "", objects[1], "", "", 100)
|
afterKey, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", objects[1], "", "", 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
requireMultipartUploadKeys(t, afterKey, objects[2:]...)
|
requireMultipartUploadKeys(t, afterKey, objects[2:]...)
|
||||||
|
|
||||||
prefixed, err := z.ListMultipartUploads(t.Context(), bucket, "t/", "", "", "", 100)
|
prefixed, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "t/", "", "", "", 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
requireMultipartUploadKeys(t, prefixed, objects[:3]...)
|
requireMultipartUploadKeys(t, prefixed, objects[:3]...)
|
||||||
|
|
||||||
nested, err := z.ListMultipartUploads(t.Context(), bucket, "t/a_b/", "", "", "", 100)
|
nested, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "t/a_b/", "", "", "", 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
requireMultipartUploadKeys(t, nested, objects[:2]...)
|
requireMultipartUploadKeys(t, nested, objects[:2]...)
|
||||||
|
|
||||||
grouped, err := z.ListMultipartUploads(t.Context(), bucket, "t/", "", "", SlashSeparator, 100)
|
grouped, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "t/", "", "", SlashSeparator, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -142,7 +141,7 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
|||||||
t.Fatalf("common prefixes = %v, want %v", grouped.CommonPrefixes, want)
|
t.Fatalf("common prefixes = %v, want %v", grouped.CommonPrefixes, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
groupPage, err := z.ListMultipartUploads(t.Context(), bucket, "t/", "", "", SlashSeparator, 1)
|
groupPage, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "t/", "", "", SlashSeparator, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -154,7 +153,7 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
|||||||
groupPage.NextKeyMarker, groupPage.NextUploadIDMarker, groupPage.IsTruncated)
|
groupPage.NextKeyMarker, groupPage.NextUploadIDMarker, groupPage.IsTruncated)
|
||||||
}
|
}
|
||||||
|
|
||||||
groupRest, err := z.ListMultipartUploads(t.Context(), bucket, "t/", groupPage.NextKeyMarker, "", SlashSeparator, 1)
|
groupRest, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "t/", groupPage.NextKeyMarker, "", SlashSeparator, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -182,8 +181,8 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Simulate an upload written by a pre-upgrade server. Its key cannot be
|
// Simulate an upload written by a pre-upgrade server. Its key cannot be
|
||||||
// recovered by scanning the hashed namespace. Strict listing must fail;
|
// recovered by scanning the hashed namespace. Omit only that record, and
|
||||||
// the old path is available only through an explicit migration setting.
|
// preserve normal operations when its bucket, key and upload ID are known.
|
||||||
legacyObject := objects[1]
|
legacyObject := objects[1]
|
||||||
er := sets.getHashedSet(legacyObject)
|
er := sets.getHashedSet(legacyObject)
|
||||||
fi, metadata, err := er.checkUploadIDExists(t.Context(), bucket, legacyObject, uploadIDs[legacyObject], true)
|
fi, metadata, err := er.checkUploadIDExists(t.Context(), bucket, legacyObject, uploadIDs[legacyObject], true)
|
||||||
@@ -198,16 +197,25 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
|||||||
er.getUploadIDDir(bucket, legacyObject, uploadIDs[legacyObject]), metadata, fi.WriteQuorum(er.defaultWQuorum())); err != nil {
|
er.getUploadIDDir(bucket, legacyObject, uploadIDs[legacyObject]), metadata, fi.WriteQuorum(er.defaultWQuorum())); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err = z.ListMultipartUploads(t.Context(), bucket, legacyObject, "", "", "", 100)
|
legacy, err := z.scanMultipartUploadsForTest(t.Context(), bucket, legacyObject, "", "", "", 100)
|
||||||
if !errors.Is(err, errMultipartListingLegacy) {
|
|
||||||
t.Fatalf("legacy strict listing: %v", err)
|
|
||||||
}
|
|
||||||
setMultipartListingTestMode(t, true)
|
|
||||||
legacy, err := z.ListMultipartUploads(t.Context(), bucket, legacyObject, "", "", "", 100)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
requireMultipartUploadKeys(t, legacy, legacyObject)
|
requireMultipartUploadKeys(t, legacy)
|
||||||
|
remaining, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
requireMultipartUploadKeys(t, remaining, objects[2:]...)
|
||||||
|
part, err = z.PutObjectPart(t.Context(), bucket, legacyObject, uploadIDs[legacyObject], 1,
|
||||||
|
mustGetPutObjReader(t, bytes.NewBufferString("old part"), 8, "", ""), ObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("continue historical upload: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = z.CompleteMultipartUpload(t.Context(), bucket, legacyObject, uploadIDs[legacyObject],
|
||||||
|
[]CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{}); err != nil {
|
||||||
|
t.Fatalf("complete historical upload: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPaginateMultipartUploads(t *testing.T) {
|
func TestPaginateMultipartUploads(t *testing.T) {
|
||||||
@@ -260,7 +268,7 @@ func TestPaginateMultipartUploads(t *testing.T) {
|
|||||||
|
|
||||||
func TestListMultipartUploadsGlobalPageAcrossPools(t *testing.T) {
|
func TestListMultipartUploadsGlobalPageAcrossPools(t *testing.T) {
|
||||||
z, bucket := consistencyPools(t)
|
z, bucket := consistencyPools(t)
|
||||||
setMultipartListingTestMode(t, false)
|
|
||||||
objects := []string{"a/one", "b/two", "c/three", "d/four"}
|
objects := []string{"a/one", "b/two", "c/three", "d/four"}
|
||||||
for i, object := range objects {
|
for i, object := range objects {
|
||||||
if _, err := z.serverPools[i%len(z.serverPools)].NewMultipartUpload(t.Context(), bucket, object, ObjectOptions{}); err != nil {
|
if _, err := z.serverPools[i%len(z.serverPools)].NewMultipartUpload(t.Context(), bucket, object, ObjectOptions{}); err != nil {
|
||||||
@@ -272,7 +280,7 @@ func TestListMultipartUploadsGlobalPageAcrossPools(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
page, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 2)
|
page, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -281,7 +289,7 @@ func TestListMultipartUploadsGlobalPageAcrossPools(t *testing.T) {
|
|||||||
t.Fatalf("first global page = %+v", page)
|
t.Fatalf("first global page = %+v", page)
|
||||||
}
|
}
|
||||||
|
|
||||||
rest, err := z.ListMultipartUploads(t.Context(), bucket, "", page.NextKeyMarker, page.NextUploadIDMarker, "", 2)
|
rest, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", page.NextKeyMarker, page.NextUploadIDMarker, "", 2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
// 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"
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/minio/internal/dsync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func listingLockerTestWrite(t *testing.T, l *localLocker, args dsync.LockArgs) {
|
||||||
|
t.Helper()
|
||||||
|
if ok, err := l.Lock(t.Context(), args); !ok || err != nil {
|
||||||
|
t.Fatalf("Lock = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listingLockerTestUnlock(t *testing.T, l *localLocker, args dsync.LockArgs) {
|
||||||
|
t.Helper()
|
||||||
|
if ok, err := l.Unlock(t.Context(), args); !ok || err != nil {
|
||||||
|
t.Fatalf("Unlock = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingReverseWaitRechecks(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
change func(*testing.T, *localLocker, dsync.LockArgs)
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"writer-released", func(t *testing.T, l *localLocker, w dsync.LockArgs) { listingLockerTestUnlock(t, l, w) }, true},
|
||||||
|
{"writer-still-held", func(*testing.T, *localLocker, dsync.LockArgs) {}, false},
|
||||||
|
{"replacement-writer", func(t *testing.T, l *localLocker, w dsync.LockArgs) {
|
||||||
|
listingLockerTestUnlock(t, l, w)
|
||||||
|
listingLockerTestWrite(t, l, listingLockerTestArgs("new-writer", "key", "writer"))
|
||||||
|
}, false},
|
||||||
|
{"ordinary-reader-arrives", func(t *testing.T, l *localLocker, w dsync.LockArgs) {
|
||||||
|
listingLockerTestUnlock(t, l, w)
|
||||||
|
listingLockerTestRead(t, l, listingLockerTestArgs("ordinary-reader", "key", "ordinary-reader"))
|
||||||
|
}, true},
|
||||||
|
{"listing-reader-arrives", func(t *testing.T, l *localLocker, w dsync.LockArgs) {
|
||||||
|
listingLockerTestUnlock(t, l, w)
|
||||||
|
listingLockerTestRead(t, l, listingLockerTestArgs("other-listing", "key", listingLockerTestSource))
|
||||||
|
}, true},
|
||||||
|
{"force-unlock", func(t *testing.T, l *localLocker, w dsync.LockArgs) {
|
||||||
|
if ok, err := l.ForceUnlock(t.Context(), w); !ok || err != nil {
|
||||||
|
t.Fatalf("ForceUnlock = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}, true},
|
||||||
|
{"expiry", func(_ *testing.T, l *localLocker, _ dsync.LockArgs) { l.expireOldLocks(-time.Minute) }, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
l := newLocker()
|
||||||
|
writer := listingLockerTestArgs("writer", "key", "writer")
|
||||||
|
reader := listingLockerTestArgs("listing", "key", listingLockerTestSource)
|
||||||
|
listingLockerTestWrite(t, l, writer)
|
||||||
|
var got bool
|
||||||
|
var err error
|
||||||
|
start := time.Now()
|
||||||
|
go func() { got, err = l.RLock(t.Context(), reader) }()
|
||||||
|
synctest.Wait()
|
||||||
|
if state := listingLockerTestSnapshot(l); state.waiting != 1 {
|
||||||
|
t.Fatalf("waiting = %d, want 1", state.waiting)
|
||||||
|
}
|
||||||
|
listingLockerTestWrite(t, l, listingLockerTestArgs("unrelated", "other-key", "writer"))
|
||||||
|
if elapsed := time.Since(start); elapsed != 0 {
|
||||||
|
t.Fatalf("unrelated key waited %v", elapsed)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
tc.change(t, l, writer)
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
synctest.Wait()
|
||||||
|
if state := listingLockerTestSnapshot(l); got != tc.want || err != nil || state.waiting != 0 {
|
||||||
|
t.Fatalf("RLock = %v, %v, waiting = %d; want %v, nil, 0", got, err, state.waiting, tc.want)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed != 2*time.Millisecond {
|
||||||
|
t.Fatalf("one attempt elapsed %v, want 2ms", elapsed)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, holder := range l.DupLockMap()["key"] {
|
||||||
|
if holder.UID == reader.UID {
|
||||||
|
found = true
|
||||||
|
if holder.Writer || holder.Timestamp != UTCNow().UnixNano() || holder.TimeLastRefresh != holder.Timestamp {
|
||||||
|
t.Fatalf("invalid granted reader: %+v", holder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found != got {
|
||||||
|
t.Fatalf("reader present = %v, granted = %v", found, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingReverseWaitCancellation(t *testing.T) {
|
||||||
|
for _, when := range []string{"before-call", "during-wait", "deadline"} {
|
||||||
|
t.Run(when, func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
l := newLocker()
|
||||||
|
writer := listingLockerTestArgs("writer", "key", "writer")
|
||||||
|
listingLockerTestWrite(t, l, writer)
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
if when == "deadline" {
|
||||||
|
cancel()
|
||||||
|
ctx, cancel = context.WithTimeout(t.Context(), time.Millisecond)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
if when == "before-call" {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
var got bool
|
||||||
|
var err error
|
||||||
|
start := time.Now()
|
||||||
|
go func() { got, err = l.RLock(ctx, listingLockerTestArgs("listing", "key", listingLockerTestSource)) }()
|
||||||
|
synctest.Wait()
|
||||||
|
if when != "before-call" {
|
||||||
|
if state := listingLockerTestSnapshot(l); state.waiting != 1 {
|
||||||
|
t.Fatalf("waiting = %d, want 1", state.waiting)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
// Removal must not turn the canceled request into a grant.
|
||||||
|
listingLockerTestUnlock(t, l, writer)
|
||||||
|
cancel()
|
||||||
|
} else {
|
||||||
|
listingLockerTestUnlock(t, l, writer)
|
||||||
|
}
|
||||||
|
synctest.Wait()
|
||||||
|
state := listingLockerTestSnapshot(l)
|
||||||
|
if got || err == nil || err != ctx.Err() || state != (listingLockerTestState{}) {
|
||||||
|
t.Fatalf("canceled RLock = %v, %v; context %v; state %+v", got, err, ctx.Err(), state)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed >= 2*time.Millisecond {
|
||||||
|
t.Fatalf("canceled request waited for full timer: %v", elapsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingReverseWaitExcluded(t *testing.T) {
|
||||||
|
for _, source := range []string{"ordinary-reader", "", "[<unknown>:0:()]", "listingRLock", "[metacache-server-pool.go:0:(*erasureServerPools).listingRLock()]"} {
|
||||||
|
t.Run(source, func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
l := newLocker()
|
||||||
|
listingLockerTestWrite(t, l, listingLockerTestArgs("writer", "key", "writer"))
|
||||||
|
start := time.Now()
|
||||||
|
if ok, err := l.RLock(t.Context(), listingLockerTestArgs("reader", "key", source)); ok || err != nil {
|
||||||
|
t.Fatalf("excluded RLock = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
if state := listingLockerTestSnapshot(l); time.Since(start) != 0 || state.waiting != 0 || state.uids != 1 {
|
||||||
|
t.Fatalf("excluded RLock changed timing or state: %+v", state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingReverseWaitFastPaths(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
l := newLocker()
|
||||||
|
start := time.Now()
|
||||||
|
for i, source := range []string{listingLockerTestSource, "ordinary-reader", ""} {
|
||||||
|
listingLockerTestRead(t, l, listingLockerTestArgs(fmt.Sprintf("shared-%d", i), "shared", source))
|
||||||
|
listingLockerTestRead(t, l, listingLockerTestArgs(fmt.Sprintf("free-%d", i), fmt.Sprintf("free-%d", i), source))
|
||||||
|
}
|
||||||
|
if state := listingLockerTestSnapshot(l); time.Since(start) != 0 || state.waiting != 0 || state.uids != 6 {
|
||||||
|
t.Fatalf("uncontended readers changed timing or state: %+v", state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingReverseWaitOverloaded(t *testing.T) {
|
||||||
|
for _, when := range []string{"before-call", "during-wait"} {
|
||||||
|
t.Run(when, func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
l := newLocker()
|
||||||
|
writer := listingLockerTestArgs("writer", "key", "writer")
|
||||||
|
listingLockerTestWrite(t, l, writer)
|
||||||
|
defer l.waitMutex.Store(0)
|
||||||
|
if when == "before-call" {
|
||||||
|
l.waitMutex.Store(lockMutexWaitLimit + 1)
|
||||||
|
}
|
||||||
|
var got bool
|
||||||
|
var err error
|
||||||
|
go func() {
|
||||||
|
got, err = l.RLock(t.Context(), listingLockerTestArgs("listing", "key", listingLockerTestSource))
|
||||||
|
}()
|
||||||
|
synctest.Wait()
|
||||||
|
if when == "during-wait" {
|
||||||
|
if state := listingLockerTestSnapshot(l); state.waiting != 1 {
|
||||||
|
t.Fatalf("waiting = %d, want 1", state.waiting)
|
||||||
|
}
|
||||||
|
listingLockerTestUnlock(t, l, writer)
|
||||||
|
l.waitMutex.Store(lockMutexWaitLimit + 1)
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
synctest.Wait()
|
||||||
|
} else {
|
||||||
|
listingLockerTestUnlock(t, l, writer)
|
||||||
|
}
|
||||||
|
if state := listingLockerTestSnapshot(l); got || err != nil || state != (listingLockerTestState{}) || l.locksOverloaded.Load() != 1 {
|
||||||
|
t.Fatalf("overloaded RLock = %v, %v; state %+v; overloads %d", got, err, state, l.locksOverloaded.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingWaitSharedAdmission(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
l := newLocker()
|
||||||
|
listingLockerTestRead(t, l, listingLockerTestArgs("reader", "read-key", listingLockerTestSource))
|
||||||
|
listingLockerTestWrite(t, l, listingLockerTestArgs("writer", "write-key", "writer"))
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
results := make(chan error, listingLockWaitLimit)
|
||||||
|
for i := range listingLockWaitLimit {
|
||||||
|
go func() {
|
||||||
|
var ok bool
|
||||||
|
var err error
|
||||||
|
if i%2 == 0 {
|
||||||
|
ok, err = l.Lock(ctx, listingLockerTestArgs(fmt.Sprintf("writer-%d", i), "read-key", "writer"))
|
||||||
|
} else {
|
||||||
|
ok, err = l.RLock(ctx, listingLockerTestArgs(fmt.Sprintf("listing-%d", i), "write-key", listingLockerTestSource))
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
err = fmt.Errorf("granted over a conflicting holder")
|
||||||
|
}
|
||||||
|
results <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
synctest.Wait()
|
||||||
|
if state := listingLockerTestSnapshot(l); state.waiting != listingLockWaitLimit {
|
||||||
|
t.Fatalf("combined waiting = %d, want %d", state.waiting, listingLockWaitLimit)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
if ok, err := l.Lock(ctx, listingLockerTestArgs("excess-writer", "read-key", "writer")); ok || err != nil {
|
||||||
|
t.Fatalf("excess Lock = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
if ok, err := l.RLock(ctx, listingLockerTestArgs("excess-listing", "write-key", listingLockerTestSource)); ok || err != nil {
|
||||||
|
t.Fatalf("excess RLock = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
listingLockerTestWrite(t, l, listingLockerTestArgs("unrelated-writer", "unrelated-write", "writer"))
|
||||||
|
listingLockerTestRead(t, l, listingLockerTestArgs("unrelated-listing", "unrelated-read", listingLockerTestSource))
|
||||||
|
if state := listingLockerTestSnapshot(l); time.Since(start) != 0 || state.waiting != listingLockWaitLimit || state.uids != 4 {
|
||||||
|
t.Fatalf("combined cap changed immediate paths: %+v", state)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
synctest.Wait()
|
||||||
|
for range listingLockWaitLimit {
|
||||||
|
if err := <-results; err != context.Canceled {
|
||||||
|
t.Fatalf("canceled waiter: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state := listingLockerTestSnapshot(l); state.waiting != 0 || state.uids != 4 {
|
||||||
|
t.Fatalf("combined wait leaked: %+v", state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type listingReverseCountLocker struct {
|
||||||
|
*localLocker
|
||||||
|
calls atomic.Int32
|
||||||
|
grants atomic.Int32
|
||||||
|
reject bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *listingReverseCountLocker) RLock(ctx context.Context, args dsync.LockArgs) (bool, error) {
|
||||||
|
l.calls.Add(1)
|
||||||
|
if l.reject {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
ok, err := l.localLocker.RLock(ctx, args)
|
||||||
|
if ok {
|
||||||
|
l.grants.Add(1)
|
||||||
|
}
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistributedListingReverseWait(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
free, held int
|
||||||
|
immediateRejects int
|
||||||
|
cancel bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "all-writers-release", want: true},
|
||||||
|
{name: "two-writers-remain", held: 2, want: true},
|
||||||
|
{name: "three-writers-remain", held: 3},
|
||||||
|
{name: "partial-grant-then-quorum", free: 1, want: true},
|
||||||
|
{name: "partial-grant-without-quorum", free: 1, held: 3},
|
||||||
|
{name: "late-grant-after-rejection", immediateRejects: 3},
|
||||||
|
{name: "partial-and-late-grants-after-cancel", free: 1, cancel: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
clients := make([]dsync.NetLocker, 4)
|
||||||
|
writer := listingLockerTestArgs("writer", "key", "writer")
|
||||||
|
for i := range clients {
|
||||||
|
l := &listingReverseCountLocker{localLocker: newLocker(), reject: i < tc.immediateRejects}
|
||||||
|
clients[i] = l
|
||||||
|
if i >= tc.free {
|
||||||
|
listingLockerTestWrite(t, l.localLocker, writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ds := &dsync.Dsync{GetLockers: func() ([]dsync.NetLocker, string) { return clients, writer.Owner }, Timeouts: dsync.DefaultTimeouts}
|
||||||
|
dm := dsync.NewDRWMutex(ds, "key")
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
result := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
result <- dm.GetRLock(ctx, nil, "listing", listingLockerTestSource, dsync.Options{Timeout: time.Second, RetryInterval: -1})
|
||||||
|
}()
|
||||||
|
synctest.Wait()
|
||||||
|
for i, c := range clients {
|
||||||
|
l := c.(*listingReverseCountLocker)
|
||||||
|
if i < tc.free && l.grants.Load() != 1 {
|
||||||
|
t.Fatal("uncontended partial grant did not arrive immediately")
|
||||||
|
}
|
||||||
|
if i >= tc.free && !l.reject && listingLockerTestSnapshot(l.localLocker).waiting != 1 {
|
||||||
|
t.Fatal("conflicting reader did not wait in its first RPC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
earlyResult := tc.immediateRejects == 3 || tc.cancel
|
||||||
|
if tc.cancel {
|
||||||
|
cancel()
|
||||||
|
synctest.Wait()
|
||||||
|
}
|
||||||
|
if earlyResult {
|
||||||
|
select {
|
||||||
|
case got := <-result:
|
||||||
|
if got {
|
||||||
|
t.Fatal("attempt succeeded before delayed replies")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("attempt did not return before delayed replies")
|
||||||
|
}
|
||||||
|
for i := range tc.free {
|
||||||
|
if state := listingLockerTestSnapshot(clients[i].(*listingReverseCountLocker).localLocker); state.uids != 0 {
|
||||||
|
t.Fatal("early partial grant was not released")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
for i, c := range clients {
|
||||||
|
if i >= tc.free+tc.held {
|
||||||
|
listingLockerTestUnlock(t, c.(*listingReverseCountLocker).localLocker, writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
synctest.Wait()
|
||||||
|
if !earlyResult {
|
||||||
|
if got := <-result; got != tc.want {
|
||||||
|
t.Fatalf("GetRLock = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, c := range clients {
|
||||||
|
l := c.(*listingReverseCountLocker)
|
||||||
|
if l.calls.Load() != 1 || listingLockerTestSnapshot(l.localLocker).waiting != 0 {
|
||||||
|
t.Fatalf("peer %d calls = %d or leaked admission", i, l.calls.Load())
|
||||||
|
}
|
||||||
|
wantGrant := !l.reject && (i < tc.free || i >= tc.free+tc.held)
|
||||||
|
if (l.grants.Load() == 1) != wantGrant {
|
||||||
|
t.Fatalf("peer %d grants = %d, want granted = %v", i, l.grants.Load(), wantGrant)
|
||||||
|
}
|
||||||
|
if !tc.want {
|
||||||
|
for _, holder := range l.DupLockMap()["key"] {
|
||||||
|
if holder.UID == "listing" {
|
||||||
|
t.Fatalf("peer %d leaked partial or late read grant", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.want {
|
||||||
|
dm.RUnlock(t.Context())
|
||||||
|
synctest.Wait()
|
||||||
|
}
|
||||||
|
for i, c := range clients {
|
||||||
|
l := c.(*listingReverseCountLocker)
|
||||||
|
if i >= tc.free && i < tc.free+tc.held {
|
||||||
|
listingLockerTestUnlock(t, l.localLocker, writer)
|
||||||
|
}
|
||||||
|
if state := listingLockerTestSnapshot(l.localLocker); state != (listingLockerTestState{}) {
|
||||||
|
t.Fatalf("peer %d final state is not empty: %+v", i, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
// 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"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/minio/internal/dsync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const listingLockerTestSource = "[metacache-server-pool.go:274:(*erasureServerPools).listingRLock()]"
|
||||||
|
|
||||||
|
func listingLockerTestArgs(uid, resource, source string) dsync.LockArgs {
|
||||||
|
quorum := 3
|
||||||
|
return dsync.LockArgs{UID: uid, Resources: []string{resource}, Source: source, Owner: "listing-wait-test", Quorum: &quorum}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listingLockerTestRead(t *testing.T, l *localLocker, args dsync.LockArgs) {
|
||||||
|
t.Helper()
|
||||||
|
if ok, err := l.RLock(t.Context(), args); !ok || err != nil {
|
||||||
|
t.Fatalf("RLock = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listingLockerTestRUnlock(t *testing.T, l *localLocker, args dsync.LockArgs) {
|
||||||
|
t.Helper()
|
||||||
|
if ok, err := l.RUnlock(t.Context(), args); !ok || err != nil {
|
||||||
|
t.Fatalf("RUnlock = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type listingLockerTestState struct {
|
||||||
|
waiting, resources, uids int
|
||||||
|
}
|
||||||
|
|
||||||
|
func listingLockerTestSnapshot(l *localLocker) listingLockerTestState {
|
||||||
|
defer l.getMutex()()
|
||||||
|
return listingLockerTestState{waiting: l.listingWaiters, resources: len(l.lockMap), uids: len(l.lockUID)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingSource(t *testing.T) {
|
||||||
|
for _, source := range []string{
|
||||||
|
"", "listingRLock", "[<unknown>:0:()]",
|
||||||
|
strings.Replace(listingLockerTestSource, ":274:", ":", 1),
|
||||||
|
strings.Replace(listingLockerTestSource, "274", "", 1),
|
||||||
|
strings.Replace(listingLockerTestSource, "274", "0", 1),
|
||||||
|
strings.Replace(listingLockerTestSource, "274", "1x", 1),
|
||||||
|
strings.Replace(listingLockerTestSource, "274", "-1", 1),
|
||||||
|
strings.Replace(listingLockerTestSource, "listingRLock", "GetObjectNInfo", 1),
|
||||||
|
strings.Replace(listingLockerTestSource, "listingRLock", "listingRLock.func1", 1),
|
||||||
|
strings.Replace(listingLockerTestSource, "metacache-server-pool.go", "other.go", 1),
|
||||||
|
listingLockerTestSource + "trailing",
|
||||||
|
} {
|
||||||
|
if isListingReadLock([]lockRequesterInfo{{Source: source}}) {
|
||||||
|
t.Errorf("unrecognized source accepted: %q", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isListingReadLock(nil) || isListingReadLock([]lockRequesterInfo{{Source: listingLockerTestSource, Writer: true}}) {
|
||||||
|
t.Fatal("empty or write holders accepted")
|
||||||
|
}
|
||||||
|
if isListingReadLock([]lockRequesterInfo{{Source: listingLockerTestSource}, {Source: "ordinary-reader"}}) {
|
||||||
|
t.Fatal("mixed readers accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the real runtime-generated source, not only a hand-written label.
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
lockers := []dsync.NetLocker{newLocker(), newLocker(), newLocker(), newLocker()}
|
||||||
|
set := &erasureObjects{nsMutex: newNSLock(true), getLockers: func() ([]dsync.NetLocker, string) { return lockers, "listing-source-test" }}
|
||||||
|
z := &erasureServerPools{serverPools: []*erasureSets{{sets: []*erasureObjects{set}}, {sets: []*erasureObjects{set}}}}
|
||||||
|
_, unlock, err := z.listingRLock(t.Context(), "bucket", "object", set)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range lockers {
|
||||||
|
l := c.(*localLocker)
|
||||||
|
for _, holders := range l.DupLockMap() {
|
||||||
|
if !isListingReadLock(holders) {
|
||||||
|
t.Errorf("actual listingRLock source was not recognized: %#v", holders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unlock()
|
||||||
|
synctest.Wait()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzListingLockSource(f *testing.F) {
|
||||||
|
f.Add(listingLockerTestSource)
|
||||||
|
f.Add("[metacache-server-pool.go:(*erasureServerPools).listingRLock()]")
|
||||||
|
f.Add("")
|
||||||
|
f.Fuzz(func(t *testing.T, source string) {
|
||||||
|
// Source is diagnostic text from a peer, including older versions. No
|
||||||
|
// malformed string may panic or make a writer eligible for this wait.
|
||||||
|
isListingReadLock([]lockRequesterInfo{{Source: source}})
|
||||||
|
if isListingReadLock([]lockRequesterInfo{{Source: source, Writer: true}}) {
|
||||||
|
t.Fatal("writer was accepted as a listing reader")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingWaitRechecks(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
change func(*testing.T, *localLocker, dsync.LockArgs)
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"reader-released", func(t *testing.T, l *localLocker, r dsync.LockArgs) { listingLockerTestRUnlock(t, l, r) }, true},
|
||||||
|
{"reader-still-held", func(*testing.T, *localLocker, dsync.LockArgs) {}, false},
|
||||||
|
{"one-of-two-readers-remains", func(t *testing.T, l *localLocker, r dsync.LockArgs) {
|
||||||
|
other := r
|
||||||
|
other.UID = "second-reader"
|
||||||
|
listingLockerTestRead(t, l, other)
|
||||||
|
listingLockerTestRUnlock(t, l, r)
|
||||||
|
}, false},
|
||||||
|
{"ordinary-reader-arrives", func(t *testing.T, l *localLocker, r dsync.LockArgs) {
|
||||||
|
listingLockerTestRUnlock(t, l, r)
|
||||||
|
listingLockerTestRead(t, l, listingLockerTestArgs("new-reader", "key", "ordinary-reader"))
|
||||||
|
}, false},
|
||||||
|
{"other-writer-wins", func(t *testing.T, l *localLocker, r dsync.LockArgs) {
|
||||||
|
listingLockerTestRUnlock(t, l, r)
|
||||||
|
if ok, err := l.Lock(t.Context(), listingLockerTestArgs("other-writer", "key", "writer")); !ok || err != nil {
|
||||||
|
t.Fatalf("competing Lock = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}, false},
|
||||||
|
{"force-unlock", func(t *testing.T, l *localLocker, r dsync.LockArgs) {
|
||||||
|
if ok, err := l.ForceUnlock(t.Context(), r); !ok || err != nil {
|
||||||
|
t.Fatalf("ForceUnlock = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}, true},
|
||||||
|
{"expiry", func(_ *testing.T, l *localLocker, _ dsync.LockArgs) { l.expireOldLocks(-time.Minute) }, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
l := newLocker()
|
||||||
|
reader := listingLockerTestArgs("reader", "key", listingLockerTestSource)
|
||||||
|
listingLockerTestRead(t, l, reader)
|
||||||
|
var got bool
|
||||||
|
var err error
|
||||||
|
start := time.Now()
|
||||||
|
go func() { got, err = l.Lock(t.Context(), listingLockerTestArgs("writer", "key", "writer")) }()
|
||||||
|
synctest.Wait()
|
||||||
|
if state := listingLockerTestSnapshot(l); state.waiting != 1 {
|
||||||
|
t.Fatalf("waiting = %d, want 1", state.waiting)
|
||||||
|
}
|
||||||
|
// The global mutex must be released throughout the timer wait.
|
||||||
|
if ok, err := l.Lock(t.Context(), listingLockerTestArgs("unrelated", "other-key", "writer")); !ok || err != nil {
|
||||||
|
t.Fatalf("unrelated key blocked: %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed != 0 {
|
||||||
|
t.Fatalf("unrelated key waited %v", elapsed)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
tc.change(t, l, reader)
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
synctest.Wait()
|
||||||
|
if state := listingLockerTestSnapshot(l); got != tc.want || err != nil || state.waiting != 0 {
|
||||||
|
t.Fatalf("Lock = %v, %v, waiting = %d; want %v, nil, 0", got, err, state.waiting, tc.want)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed != 2*time.Millisecond {
|
||||||
|
t.Fatalf("one attempt elapsed %v, want 2ms", elapsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingWaitCancellation(t *testing.T) {
|
||||||
|
for _, when := range []string{"before-call", "during-wait", "deadline"} {
|
||||||
|
t.Run(when, func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
l := newLocker()
|
||||||
|
reader := listingLockerTestArgs("reader", "key", listingLockerTestSource)
|
||||||
|
listingLockerTestRead(t, l, reader)
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
if when == "deadline" {
|
||||||
|
cancel()
|
||||||
|
ctx, cancel = context.WithTimeout(t.Context(), time.Millisecond)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
if when == "before-call" {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
var got bool
|
||||||
|
var err error
|
||||||
|
go func() { got, err = l.Lock(ctx, listingLockerTestArgs("writer", "key", "writer")) }()
|
||||||
|
synctest.Wait()
|
||||||
|
if when != "before-call" {
|
||||||
|
if state := listingLockerTestSnapshot(l); state.waiting != 1 {
|
||||||
|
t.Fatalf("waiting = %d", state.waiting)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
synctest.Wait()
|
||||||
|
if state := listingLockerTestSnapshot(l); got || err != ctx.Err() || err == nil || state.waiting != 0 {
|
||||||
|
t.Fatalf("canceled Lock = %v, %v; context %v; waiting = %d", got, err, ctx.Err(), state.waiting)
|
||||||
|
}
|
||||||
|
listingLockerTestRUnlock(t, l, reader)
|
||||||
|
if state := listingLockerTestSnapshot(l); state.resources != 0 || state.uids != 0 {
|
||||||
|
t.Fatal("canceled wait left a lock")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingWaitExcluded(t *testing.T) {
|
||||||
|
for _, kind := range []string{"ordinary-reader", "unknown-reader", "mixed-readers", "writer", "bulk", "overloaded"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
l := newLocker()
|
||||||
|
reader := listingLockerTestArgs("holder", "key", listingLockerTestSource)
|
||||||
|
if kind == "ordinary-reader" {
|
||||||
|
reader.Source = "[erasure-object.go:100:(*erasureObjects).GetObjectNInfo()]"
|
||||||
|
}
|
||||||
|
if kind == "unknown-reader" {
|
||||||
|
reader.Source = ""
|
||||||
|
}
|
||||||
|
if kind == "writer" {
|
||||||
|
if ok, err := l.Lock(t.Context(), reader); !ok || err != nil {
|
||||||
|
t.Fatalf("initial write = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
listingLockerTestRead(t, l, reader)
|
||||||
|
}
|
||||||
|
if kind == "mixed-readers" {
|
||||||
|
listingLockerTestRead(t, l, listingLockerTestArgs("ordinary", "key", "ordinary-reader"))
|
||||||
|
}
|
||||||
|
writer := listingLockerTestArgs("writer", "key", "writer")
|
||||||
|
if kind == "bulk" {
|
||||||
|
writer.Resources = []string{"key", "other-key"}
|
||||||
|
}
|
||||||
|
if kind == "overloaded" {
|
||||||
|
l.waitMutex.Store(lockMutexWaitLimit + 1)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
if ok, err := l.Lock(t.Context(), writer); ok || err != nil {
|
||||||
|
t.Fatalf("conflicting Lock = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
state := listingLockerTestSnapshot(l)
|
||||||
|
if elapsed := time.Since(start); elapsed != 0 || state.waiting != 0 {
|
||||||
|
t.Fatalf("excluded path waited %v; waiters = %d", elapsed, state.waiting)
|
||||||
|
}
|
||||||
|
if _, ok := l.DupLockMap()["other-key"]; ok {
|
||||||
|
t.Fatal("bulk request partially acquired an unrelated key")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingWaitAdmission(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
l := newLocker()
|
||||||
|
reader := listingLockerTestArgs("reader", "key", listingLockerTestSource)
|
||||||
|
listingLockerTestRead(t, l, reader)
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
results := make(chan error, listingLockWaitLimit)
|
||||||
|
for i := range listingLockWaitLimit {
|
||||||
|
go func() {
|
||||||
|
ok, err := l.Lock(ctx, listingLockerTestArgs(fmt.Sprintf("writer-%d", i), "key", "writer"))
|
||||||
|
if ok {
|
||||||
|
err = fmt.Errorf("granted over a live reader")
|
||||||
|
}
|
||||||
|
results <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
synctest.Wait()
|
||||||
|
if state := listingLockerTestSnapshot(l); state.waiting != listingLockWaitLimit {
|
||||||
|
t.Fatalf("waiting = %d, want %d", state.waiting, listingLockWaitLimit)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
if ok, err := l.Lock(ctx, listingLockerTestArgs("excess", "key", "writer")); ok || err != nil {
|
||||||
|
t.Fatalf("excess waiter = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
if state := listingLockerTestSnapshot(l); time.Since(start) != 0 || state.waiting != listingLockWaitLimit {
|
||||||
|
t.Fatal("excess waiter consumed time or admission")
|
||||||
|
}
|
||||||
|
if ok, err := l.Lock(ctx, listingLockerTestArgs("unrelated", "other-key", "writer")); !ok || err != nil {
|
||||||
|
t.Fatalf("admission limit rejected an uncontended key: %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
synctest.Wait()
|
||||||
|
for range listingLockWaitLimit {
|
||||||
|
if err := <-results; err != context.Canceled {
|
||||||
|
t.Fatalf("canceled waiter: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state := listingLockerTestSnapshot(l); state.waiting != 0 {
|
||||||
|
t.Fatalf("admission leaked: %d", state.waiting)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLockerListingWaitContended(t *testing.T) {
|
||||||
|
for _, n := range []int{2, 8, 32} {
|
||||||
|
t.Run(fmt.Sprintf("%d-writers", n), func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
l := newLocker()
|
||||||
|
reader := listingLockerTestArgs("reader", "key", listingLockerTestSource)
|
||||||
|
listingLockerTestRead(t, l, reader)
|
||||||
|
results := make(chan bool, n)
|
||||||
|
for i := range n {
|
||||||
|
go func() {
|
||||||
|
ok, err := l.Lock(t.Context(), listingLockerTestArgs(fmt.Sprintf("writer-%d", i), "key", "writer"))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Lock: %v", err)
|
||||||
|
}
|
||||||
|
results <- ok
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
synctest.Wait()
|
||||||
|
if state := listingLockerTestSnapshot(l); state.waiting != n {
|
||||||
|
t.Fatalf("waiting = %d, want %d", state.waiting, n)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
listingLockerTestRUnlock(t, l, reader)
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
synctest.Wait()
|
||||||
|
granted := 0
|
||||||
|
for range n {
|
||||||
|
if <-results {
|
||||||
|
granted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state := listingLockerTestSnapshot(l); granted != 1 || state.uids != 1 || state.waiting != 0 {
|
||||||
|
t.Fatalf("grants = %d, indexed = %d, waiting = %d", granted, state.uids, state.waiting)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type listingCountLocker struct {
|
||||||
|
*localLocker
|
||||||
|
calls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *listingCountLocker) Lock(ctx context.Context, args dsync.LockArgs) (bool, error) {
|
||||||
|
l.calls.Add(1)
|
||||||
|
return l.localLocker.Lock(ctx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise real DRWMutex quorum and late-response cleanup. The mixed case with
|
||||||
|
// two immediate rejections fails before the upgraded lockers finish waiting.
|
||||||
|
func TestDistributedListingWait(t *testing.T) {
|
||||||
|
for _, ordinaryReaders := range []int{0, 1, 2} {
|
||||||
|
t.Run(fmt.Sprintf("%d-immediate-rejections", ordinaryReaders), func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
clients := make([]dsync.NetLocker, 4)
|
||||||
|
reader := listingLockerTestArgs("reader", "key", listingLockerTestSource)
|
||||||
|
for i := range clients {
|
||||||
|
l := &listingCountLocker{localLocker: newLocker()}
|
||||||
|
clients[i] = l
|
||||||
|
r := reader
|
||||||
|
if i < ordinaryReaders {
|
||||||
|
r.Source = "old-node-unknown-source"
|
||||||
|
}
|
||||||
|
listingLockerTestRead(t, l.localLocker, r)
|
||||||
|
}
|
||||||
|
ds := &dsync.Dsync{GetLockers: func() ([]dsync.NetLocker, string) { return clients, reader.Owner }, Timeouts: dsync.DefaultTimeouts}
|
||||||
|
dm := dsync.NewDRWMutex(ds, "key")
|
||||||
|
var got bool
|
||||||
|
go func() {
|
||||||
|
got = dm.GetLock(t.Context(), nil, "writer", "writer", dsync.Options{Timeout: time.Second, RetryInterval: -1})
|
||||||
|
}()
|
||||||
|
synctest.Wait()
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
for _, c := range clients {
|
||||||
|
listingLockerTestRUnlock(t, c.(*listingCountLocker).localLocker, reader)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
synctest.Wait()
|
||||||
|
if got != (ordinaryReaders < 2) {
|
||||||
|
t.Fatalf("GetLock = %v", got)
|
||||||
|
}
|
||||||
|
granted := 0
|
||||||
|
for _, c := range clients {
|
||||||
|
l := c.(*listingCountLocker)
|
||||||
|
if l.calls.Load() != 1 {
|
||||||
|
t.Fatalf("lock calls = %d, want one round", l.calls.Load())
|
||||||
|
}
|
||||||
|
state := listingLockerTestSnapshot(l.localLocker)
|
||||||
|
if state.resources != 0 {
|
||||||
|
granted++
|
||||||
|
}
|
||||||
|
if state.waiting != 0 {
|
||||||
|
t.Fatal("wait admission leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got && granted < 3 {
|
||||||
|
t.Fatalf("writer accepted without quorum: %d", granted)
|
||||||
|
}
|
||||||
|
if !got && granted != 0 {
|
||||||
|
t.Fatalf("late grants leaked after rejected attempt: %d", granted)
|
||||||
|
}
|
||||||
|
if got {
|
||||||
|
dm.Unlock(t.Context())
|
||||||
|
synctest.Wait()
|
||||||
|
}
|
||||||
|
for _, c := range clients {
|
||||||
|
l := c.(*listingCountLocker)
|
||||||
|
if state := listingLockerTestSnapshot(l.localLocker); state.resources != 0 || state.uids != 0 {
|
||||||
|
t.Fatal("final lock state not empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
-3
@@ -23,6 +23,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -38,6 +39,14 @@ import (
|
|||||||
// Rejected clients are expected to retry.
|
// Rejected clients are expected to retry.
|
||||||
const lockMutexWaitLimit = 1000
|
const lockMutexWaitLimit = 1000
|
||||||
|
|
||||||
|
// A short listing recovery read or conflicting writer may finish before the
|
||||||
|
// rejected request retries. Recheck once in the same request, sharing a bound
|
||||||
|
// for timers outside the mutex. This is not a queue and gives neither priority.
|
||||||
|
const (
|
||||||
|
listingLockWait = 2 * time.Millisecond
|
||||||
|
listingLockWaitLimit = 64
|
||||||
|
)
|
||||||
|
|
||||||
// lockRequesterInfo stores various info from the client for each lock that is requested.
|
// lockRequesterInfo stores various info from the client for each lock that is requested.
|
||||||
type lockRequesterInfo struct {
|
type lockRequesterInfo struct {
|
||||||
Name string // name of the resource lock was requested for
|
Name string // name of the resource lock was requested for
|
||||||
@@ -65,6 +74,8 @@ type localLocker struct {
|
|||||||
waitMutex atomic.Int32
|
waitMutex atomic.Int32
|
||||||
lockMap map[string][]lockRequesterInfo
|
lockMap map[string][]lockRequesterInfo
|
||||||
lockUID map[string]string // UUID -> resource map.
|
lockUID map[string]string // UUID -> resource map.
|
||||||
|
// Protected by mutex. These waiters are not included in waitMutex.
|
||||||
|
listingWaiters int
|
||||||
|
|
||||||
// the following are updated on every cleanup defined in lockValidityDuration
|
// the following are updated on every cleanup defined in lockValidityDuration
|
||||||
readers atomic.Int32
|
readers atomic.Int32
|
||||||
@@ -96,6 +107,32 @@ func (l *localLocker) canTakeLock(resources ...string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isListingReadLock recognizes only the existing diagnostic source emitted by
|
||||||
|
// listingRLock. Unknown or changed source formats retain immediate rejection;
|
||||||
|
// Source is not a new wire contract or proof that the read will finish quickly.
|
||||||
|
func isListingReadLock(holders []lockRequesterInfo) bool {
|
||||||
|
if len(holders) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const prefix = "[metacache-server-pool.go:"
|
||||||
|
const suffix = ":(*erasureServerPools).listingRLock()]"
|
||||||
|
for _, holder := range holders {
|
||||||
|
if holder.Writer || len(holder.Source) <= len(prefix)+len(suffix) || !strings.HasPrefix(holder.Source, prefix) || !strings.HasSuffix(holder.Source, suffix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
line := holder.Source[len(prefix) : len(holder.Source)-len(suffix)]
|
||||||
|
if len(line) == 0 || line[0] == '0' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range line {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (l *localLocker) Lock(ctx context.Context, args dsync.LockArgs) (reply bool, err error) {
|
func (l *localLocker) Lock(ctx context.Context, args dsync.LockArgs) (reply bool, err error) {
|
||||||
if len(args.Resources) > maxDeleteList {
|
if len(args.Resources) > maxDeleteList {
|
||||||
return false, fmt.Errorf("internal error: localLocker.Lock called with more than %d resources", maxDeleteList)
|
return false, fmt.Errorf("internal error: localLocker.Lock called with more than %d resources", maxDeleteList)
|
||||||
@@ -112,10 +149,34 @@ func (l *localLocker) Lock(ctx context.Context, args dsync.LockArgs) (reply bool
|
|||||||
return false, ctx.Err()
|
return false, ctx.Err()
|
||||||
}
|
}
|
||||||
if !l.canTakeLock(args.Resources...) {
|
if !l.canTakeLock(args.Resources...) {
|
||||||
// Not all locks can be taken on resources,
|
// Only a single-resource write blocked exclusively by listing recovery
|
||||||
// reject it completely.
|
// readers gets a short, bounded opportunity to succeed in this attempt.
|
||||||
|
if len(args.Resources) != 1 || l.listingWaiters >= listingLockWaitLimit || !isListingReadLock(l.lockMap[args.Resources[0]]) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
l.listingWaiters++
|
||||||
|
l.mutex.Unlock()
|
||||||
|
timer := time.NewTimer(listingLockWait)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
timer.Stop()
|
||||||
|
// Restore the mutex before returning to the existing deferred unlock.
|
||||||
|
// Readers or another writer may have entered while it was released.
|
||||||
|
l.getMutex()
|
||||||
|
l.listingWaiters--
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return false, ctx.Err()
|
||||||
|
}
|
||||||
|
if l.waitMutex.Load() > lockMutexWaitLimit {
|
||||||
|
l.locksOverloaded.Add(1)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if !l.canTakeLock(args.Resources...) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// No locks held on the all resources, so claim write
|
// No locks held on the all resources, so claim write
|
||||||
// lock on all resources at once.
|
// lock on all resources at once.
|
||||||
@@ -206,6 +267,31 @@ func (l *localLocker) RLock(ctx context.Context, args dsync.LockArgs) (reply boo
|
|||||||
return false, ctx.Err()
|
return false, ctx.Err()
|
||||||
}
|
}
|
||||||
resource := args.Resources[0]
|
resource := args.Resources[0]
|
||||||
|
lri, ok := l.lockMap[resource]
|
||||||
|
if ok && isWriteLock(lri) && l.listingWaiters < listingLockWaitLimit && isListingReadLock([]lockRequesterInfo{{Source: args.Source}}) {
|
||||||
|
// Only a positively identified listing read blocked by a writer may
|
||||||
|
// wait once. A long or replacement writer still rejects normally.
|
||||||
|
l.listingWaiters++
|
||||||
|
l.mutex.Unlock()
|
||||||
|
timer := time.NewTimer(listingLockWait)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
timer.Stop()
|
||||||
|
// Restore the mutex before the existing deferred unlock, then use
|
||||||
|
// the current holders in the normal read-lock grant path below.
|
||||||
|
l.getMutex()
|
||||||
|
l.listingWaiters--
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return false, ctx.Err()
|
||||||
|
}
|
||||||
|
if l.waitMutex.Load() > lockMutexWaitLimit {
|
||||||
|
l.locksOverloaded.Add(1)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
lri, ok = l.lockMap[resource]
|
||||||
|
}
|
||||||
now := UTCNow()
|
now := UTCNow()
|
||||||
lrInfo := lockRequesterInfo{
|
lrInfo := lockRequesterInfo{
|
||||||
Name: resource,
|
Name: resource,
|
||||||
@@ -217,7 +303,6 @@ func (l *localLocker) RLock(ctx context.Context, args dsync.LockArgs) (reply boo
|
|||||||
TimeLastRefresh: now.UnixNano(),
|
TimeLastRefresh: now.UnixNano(),
|
||||||
Quorum: *args.Quorum,
|
Quorum: *args.Quorum,
|
||||||
}
|
}
|
||||||
lri, ok := l.lockMap[resource]
|
|
||||||
if ok {
|
if ok {
|
||||||
if reply = !isWriteLock(lri); reply {
|
if reply = !isWriteLock(lri); reply {
|
||||||
// Unless there is a write lock
|
// Unless there is a write lock
|
||||||
|
|||||||
+126
-9
@@ -37,6 +37,10 @@ type metaCacheEntry struct {
|
|||||||
// Entries without metadata will only be present in non-recursive scans.
|
// Entries without metadata will only be present in non-recursive scans.
|
||||||
metadata []byte
|
metadata []byte
|
||||||
|
|
||||||
|
// listingSet marks an eligible unresolved walk entry. It is consumed by the
|
||||||
|
// pool merger before any cache serialization or downstream filtering.
|
||||||
|
listingSet *erasureObjects
|
||||||
|
|
||||||
// cached contains the metadata if decoded.
|
// cached contains the metadata if decoded.
|
||||||
cached *xlMetaV2
|
cached *xlMetaV2
|
||||||
|
|
||||||
@@ -46,17 +50,17 @@ type metaCacheEntry struct {
|
|||||||
|
|
||||||
// isDir returns if the entry is representing a prefix directory.
|
// isDir returns if the entry is representing a prefix directory.
|
||||||
func (e metaCacheEntry) isDir() bool {
|
func (e metaCacheEntry) isDir() bool {
|
||||||
return len(e.metadata) == 0 && strings.HasSuffix(e.name, slashSeparator)
|
return e.listingSet == nil && len(e.metadata) == 0 && strings.HasSuffix(e.name, slashSeparator)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isObject returns if the entry is representing an object.
|
// isObject returns if the entry is representing an object.
|
||||||
func (e metaCacheEntry) isObject() bool {
|
func (e metaCacheEntry) isObject() bool {
|
||||||
return len(e.metadata) > 0
|
return e.listingSet != nil || len(e.metadata) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// isObjectDir returns if the entry is representing an object/
|
// isObjectDir returns if the entry is representing an object/
|
||||||
func (e metaCacheEntry) isObjectDir() bool {
|
func (e metaCacheEntry) isObjectDir() bool {
|
||||||
return len(e.metadata) > 0 && strings.HasSuffix(e.name, slashSeparator)
|
return (e.listingSet != nil || len(e.metadata) > 0) && strings.HasSuffix(e.name, slashSeparator)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasPrefix returns whether an entry has a specific prefix
|
// hasPrefix returns whether an entry has a specific prefix
|
||||||
@@ -670,26 +674,82 @@ func (m *metaCacheEntriesSorted) forwardPast(s string) {
|
|||||||
// If the context is canceled the function will return the error,
|
// If the context is canceled the function will return the error,
|
||||||
// otherwise the function will return nil.
|
// otherwise the function will return nil.
|
||||||
func mergeEntryChannels(ctx context.Context, in []chan metaCacheEntry, out chan<- metaCacheEntry, readQuorum int) error {
|
func mergeEntryChannels(ctx context.Context, in []chan metaCacheEntry, out chan<- metaCacheEntry, readQuorum int) error {
|
||||||
|
return mergeEntryChannelsWithErrors(ctx, in, out, readQuorum, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalErr is inspected after each input closes and before the merger
|
||||||
|
// advances beyond it. Callers must publish the error before closing the input.
|
||||||
|
func mergeEntryChannelsWithErrors(ctx context.Context, in []chan metaCacheEntry, out chan<- metaCacheEntry, readQuorum int, terminalErr func(int) error) error {
|
||||||
|
return mergeEntryChannelsWithResolver(ctx, in, out, readQuorum, terminalErr, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve is called only for groups containing a pending listing entry. All
|
||||||
|
// selected inputs are advanced (and their terminal errors checked) before it is
|
||||||
|
// called, so recovery never holds an object lock while waiting for input.
|
||||||
|
func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntry, out chan<- metaCacheEntry, readQuorum int, terminalErr func(int) error, resolve func([]*metaCacheEntry) (*metaCacheEntry, error)) error {
|
||||||
defer xioutil.SafeClose(out)
|
defer xioutil.SafeClose(out)
|
||||||
top := make([]*metaCacheEntry, len(in))
|
top := make([]*metaCacheEntry, len(in))
|
||||||
nDone := 0
|
nDone := 0
|
||||||
ctxDone := ctx.Done()
|
ctxDone := ctx.Done()
|
||||||
|
|
||||||
// Use simpler forwarder.
|
// Keep the single-input normal path allocation-free. Only a pending entry
|
||||||
|
// requires looking ahead to observe its stream's terminal error first.
|
||||||
if len(in) == 1 {
|
if len(in) == 1 {
|
||||||
|
var buffered metaCacheEntry
|
||||||
|
haveBuffered, exhausted := false, false
|
||||||
for {
|
for {
|
||||||
|
var v metaCacheEntry
|
||||||
|
if haveBuffered {
|
||||||
|
v, haveBuffered = buffered, false
|
||||||
|
} else {
|
||||||
select {
|
select {
|
||||||
case <-ctxDone:
|
case <-ctxDone:
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case v, ok := <-in[0]:
|
case entry, ok := <-in[0]:
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if terminalErr != nil {
|
||||||
|
return terminalErr(0)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
v = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.listingSet != nil {
|
||||||
|
if resolve == nil {
|
||||||
|
return errInvalidArgument
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctxDone:
|
||||||
|
return ctx.Err()
|
||||||
|
case entry, ok := <-in[0]:
|
||||||
|
buffered, haveBuffered, exhausted = entry, ok, !ok
|
||||||
|
if !ok && terminalErr != nil {
|
||||||
|
if err := terminalErr(0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending := v
|
||||||
|
resolved, err := resolve([]*metaCacheEntry{&pending})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resolved == nil {
|
||||||
|
if exhausted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v = *resolved
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case <-ctxDone:
|
case <-ctxDone:
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case out <- v:
|
case out <- v:
|
||||||
}
|
}
|
||||||
|
if exhausted {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -700,6 +760,11 @@ func mergeEntryChannels(ctx context.Context, in []chan metaCacheEntry, out chan<
|
|||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case entry, ok := <-in[idx]:
|
case entry, ok := <-in[idx]:
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if terminalErr != nil {
|
||||||
|
if err := terminalErr(idx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
top[idx] = nil
|
top[idx] = nil
|
||||||
nDone++
|
nDone++
|
||||||
} else {
|
} else {
|
||||||
@@ -716,6 +781,15 @@ func mergeEntryChannels(ctx context.Context, in []chan metaCacheEntry, out chan<
|
|||||||
}
|
}
|
||||||
last := ""
|
last := ""
|
||||||
var toMerge []int
|
var toMerge []int
|
||||||
|
var directoryCollisions []int
|
||||||
|
discardDirectories := func() error {
|
||||||
|
for _, idx := range directoryCollisions {
|
||||||
|
if err := selectFrom(idx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Choose the best to return.
|
// Choose the best to return.
|
||||||
for {
|
for {
|
||||||
@@ -725,6 +799,7 @@ func mergeEntryChannels(ctx context.Context, in []chan metaCacheEntry, out chan<
|
|||||||
best := top[0]
|
best := top[0]
|
||||||
bestIdx := 0
|
bestIdx := 0
|
||||||
toMerge = toMerge[:0]
|
toMerge = toMerge[:0]
|
||||||
|
directoryCollisions = directoryCollisions[:0]
|
||||||
for i, other := range top[1:] {
|
for i, other := range top[1:] {
|
||||||
otherIdx := i + 1
|
otherIdx := i + 1
|
||||||
if other == nil {
|
if other == nil {
|
||||||
@@ -754,10 +829,9 @@ func mergeEntryChannels(ctx context.Context, in []chan metaCacheEntry, out chan<
|
|||||||
if serverDebugLog {
|
if serverDebugLog {
|
||||||
console.Debugln("mergeEntryChannels: discarding directory", other.name, "for object", best.name)
|
console.Debugln("mergeEntryChannels: discarding directory", other.name, "for object", best.name)
|
||||||
}
|
}
|
||||||
// Discard the directory.
|
// A pending object can resolve to absence. Keep the
|
||||||
if err := selectFrom(otherIdx); err != nil {
|
// directory until the whole object group is confirmed.
|
||||||
return err
|
directoryCollisions = append(directoryCollisions, otherIdx)
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Replace directory with object.
|
// Replace directory with object.
|
||||||
@@ -773,11 +847,54 @@ func mergeEntryChannels(ctx context.Context, in []chan metaCacheEntry, out chan<
|
|||||||
}
|
}
|
||||||
if best.name > other.name {
|
if best.name > other.name {
|
||||||
toMerge = toMerge[:0]
|
toMerge = toMerge[:0]
|
||||||
|
directoryCollisions = directoryCollisions[:0]
|
||||||
best = other
|
best = other
|
||||||
bestIdx = otherIdx
|
bestIdx = otherIdx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pending := best.listingSet != nil
|
||||||
|
for _, idx := range toMerge {
|
||||||
|
pending = pending || top[idx].listingSet != nil
|
||||||
|
}
|
||||||
|
if pending {
|
||||||
|
if resolve == nil {
|
||||||
|
return errInvalidArgument
|
||||||
|
}
|
||||||
|
group := make([]*metaCacheEntry, 1, len(toMerge)+1)
|
||||||
|
group[0] = best
|
||||||
|
for _, idx := range toMerge {
|
||||||
|
group = append(group, top[idx])
|
||||||
|
if err := selectFrom(idx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := selectFrom(bestIdx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolved, err := resolve(group)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resolved != nil {
|
||||||
|
if err := discardDirectories(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resolved != nil && resolved.name > last {
|
||||||
|
select {
|
||||||
|
case <-ctxDone:
|
||||||
|
return ctx.Err()
|
||||||
|
case out <- *resolved:
|
||||||
|
last = resolved.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := discardDirectories(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Merge any unmerged
|
// Merge any unmerged
|
||||||
if len(toMerge) > 0 {
|
if len(toMerge) > 0 {
|
||||||
versions := make([][]xlMetaV2ShallowVersion, 0, len(toMerge)+1)
|
versions := make([][]xlMetaV2ShallowVersion, 0, len(toMerge)+1)
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
// 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/xml"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/minio/internal/grid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listingReadFailDisk struct {
|
||||||
|
StorageAPI
|
||||||
|
object string
|
||||||
|
reads *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *listingReadFailDisk) ReadXL(ctx context.Context, bucket, object string, readData bool) (RawFileInfo, error) {
|
||||||
|
if object == d.object {
|
||||||
|
d.reads.Add(1)
|
||||||
|
return RawFileInfo{}, errDiskNotFound
|
||||||
|
}
|
||||||
|
return d.StorageAPI.ReadXL(ctx, bucket, object, readData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install before issuing requests: cache writers can outlive their HTTP
|
||||||
|
// response, so disk faults must be switched without replacing getDisks.
|
||||||
|
// Each published slice must remain immutable.
|
||||||
|
func installListingDiskSwitch(set *erasureObjects) ([]StorageAPI, func([]StorageAPI)) {
|
||||||
|
disks := set.getDisks()
|
||||||
|
var current atomic.Value
|
||||||
|
current.Store(disks)
|
||||||
|
set.getDisks = func() []StorageAPI { return current.Load().([]StorageAPI) }
|
||||||
|
return disks, func(disks []StorageAPI) { current.Store(disks) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingCachePreservesFallbackError(t *testing.T) {
|
||||||
|
for _, continuation := range []bool{false, true} {
|
||||||
|
name := "cold"
|
||||||
|
if continuation {
|
||||||
|
name = "continuation"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
testListingCacheFallbackError(t, continuation, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingCacheCleanupKeepsOriginalID(t *testing.T) {
|
||||||
|
testListingCacheFallbackError(t, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use actual peer RPCs to verify the abandoned cache is updated after the
|
||||||
|
// caller clears its own listing ID. The local-only path cannot observe this.
|
||||||
|
func recordListingPeerCleanup(t *testing.T) func() {
|
||||||
|
t.Helper()
|
||||||
|
tg, err := grid.SetupTestGrid(2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
previous := globalNotificationSys
|
||||||
|
t.Cleanup(func() {
|
||||||
|
tg.Cleanup()
|
||||||
|
globalNotificationSys = previous
|
||||||
|
})
|
||||||
|
getIDs := make(chan string, 8)
|
||||||
|
type cleanupResult struct {
|
||||||
|
id string
|
||||||
|
err *grid.RemoteErr
|
||||||
|
}
|
||||||
|
updated := make(chan cleanupResult, 1)
|
||||||
|
server := &peerRESTServer{}
|
||||||
|
if err := getMetacacheListingRPC.Register(tg.Managers[1], func(o *listPathOptions) (*metacache, *grid.RemoteErr) {
|
||||||
|
getIDs <- o.ID
|
||||||
|
return server.GetMetacacheListingHandler(o)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := updateMetacacheListingRPC.Register(tg.Managers[1], func(m *metacache) (*metacache, *grid.RemoteErr) {
|
||||||
|
result, err := server.UpdateMetacacheListingHandler(m)
|
||||||
|
if m.error == "no longer used" {
|
||||||
|
updated <- cleanupResult{id: m.id, err: err}
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client := &peerRESTClient{gridConn: func() *grid.Connection { return tg.Managers[0].Connection(tg.Hosts[1]) }}
|
||||||
|
globalNotificationSys = &NotificationSys{peerClients: []*peerRESTClient{client}, allPeerClients: []*peerRESTClient{client}}
|
||||||
|
return func() {
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var ids []string
|
||||||
|
for range 2 {
|
||||||
|
select {
|
||||||
|
case id := <-getIDs:
|
||||||
|
ids = append(ids, id)
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("peer did not receive the failed cache cleanup lookup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ids[0] == "" || ids[1] != ids[0] {
|
||||||
|
t.Fatalf("cleanup lookup lost the original cache ID: %q", ids)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case result := <-updated:
|
||||||
|
if result.err != nil || result.id != ids[0] {
|
||||||
|
t.Fatalf("cleanup update: id=%q err=%v, want id=%q", result.id, result.err, ids[0])
|
||||||
|
}
|
||||||
|
t.Logf("peer cleanup Get/Update retained cache ID %q", result.id)
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("peer did not finish the failed cache cleanup update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListingCacheFallbackError(t *testing.T, continuation, peer bool) {
|
||||||
|
t.Helper()
|
||||||
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
|
var waitPeerCleanup func()
|
||||||
|
if peer {
|
||||||
|
waitPeerCleanup = recordListingPeerCleanup(t)
|
||||||
|
}
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
disks, switchDisks := installListingDiskSwitch(set)
|
||||||
|
body := bytes.Repeat([]byte{'a'}, 8192)
|
||||||
|
mtime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
for _, object := range []string{"a", "b", "z"} {
|
||||||
|
nullQuorumPut(t, z, bucket, object, body, ObjectOptions{MTime: mtime})
|
||||||
|
}
|
||||||
|
old := make([][]byte, 3)
|
||||||
|
for i := range old {
|
||||||
|
old[i] = mustReadNullQuorumMeta(t, disks[i], bucket, "z")
|
||||||
|
}
|
||||||
|
nullQuorumPut(t, z, bucket, "z", body, ObjectOptions{MTime: mtime.Add(time.Minute)})
|
||||||
|
var token string
|
||||||
|
if continuation {
|
||||||
|
rec := nullQuorumRequest(t, router, http.MethodGet, getListObjectsV2URL("", bucket, "", "1", "", "", ""))
|
||||||
|
var list ListObjectsV2Response
|
||||||
|
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &list) != nil || !list.IsTruncated || list.NextContinuationToken == "" {
|
||||||
|
t.Fatalf("first page: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
token = list.NextContinuationToken
|
||||||
|
}
|
||||||
|
for i := range old {
|
||||||
|
if err := disks[i].WriteAll(t.Context(), bucket, "z/"+xlStorageFormatFile, old[i]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var reads atomic.Int64
|
||||||
|
walkers := slices.Clone(disks)
|
||||||
|
for i := range walkers {
|
||||||
|
walkers[i] = &listingReadFailDisk{StorageAPI: disks[i], object: "z", reads: &reads}
|
||||||
|
if i >= 8 {
|
||||||
|
walkers[i] = &failedMetadataWalkDisk{StorageAPI: walkers[i]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switchDisks(walkers)
|
||||||
|
target := getListObjectsV2URL("", bucket, "", "1000", "", "", "")
|
||||||
|
if token != "" {
|
||||||
|
target += "&continuation-token=" + url.QueryEscape(token)
|
||||||
|
}
|
||||||
|
rec := nullQuorumRequest(t, router, http.MethodGet, target)
|
||||||
|
t.Logf("LIST=%d failed metadata reads=%d body=%s", rec.Code, reads.Load(), rec.Body.String())
|
||||||
|
if reads.Load() == 0 {
|
||||||
|
t.Fatal("fallback not exercised")
|
||||||
|
}
|
||||||
|
var apiErr APIErrorResponse
|
||||||
|
if rec.Code != http.StatusServiceUnavailable || xml.Unmarshal(rec.Body.Bytes(), &apiErr) != nil || apiErr.Code != "SlowDownRead" {
|
||||||
|
t.Fatalf("fallback failure lost its read-quorum error: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if waitPeerCleanup != nil {
|
||||||
|
waitPeerCleanup()
|
||||||
|
}
|
||||||
|
if continuation {
|
||||||
|
caches, _ := localMetacacheMgr.getBucket(t.Context(), bucket).cloneCaches()
|
||||||
|
if len(caches) == 0 {
|
||||||
|
t.Fatal("continuation did not create a cache")
|
||||||
|
}
|
||||||
|
for _, cache := range caches {
|
||||||
|
if cache.status != scanStateError {
|
||||||
|
t.Fatalf("incomplete cache was not marked failed: %+v", cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switchDisks(disks)
|
||||||
|
retry := nullQuorumRequest(t, router, http.MethodGet, target)
|
||||||
|
var recovered ListObjectsV2Response
|
||||||
|
if retry.Code != http.StatusOK || xml.Unmarshal(retry.Body.Bytes(), &recovered) != nil {
|
||||||
|
t.Fatalf("retry after metadata recovers: %d %s", retry.Code, retry.Body.String())
|
||||||
|
}
|
||||||
|
var keys []string
|
||||||
|
for _, entry := range recovered.Contents {
|
||||||
|
keys = append(keys, entry.Key)
|
||||||
|
}
|
||||||
|
want := []string{"a", "b", "z"}
|
||||||
|
if continuation {
|
||||||
|
want = []string{"b", "z"}
|
||||||
|
}
|
||||||
|
if !slices.Equal(keys, want) || recovered.IsTruncated {
|
||||||
|
t.Fatalf("retry returned incomplete result: %+v", recovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingCachePagination(t *testing.T) {
|
||||||
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
|
for _, key := range []string{"a", "b", "c", "d"} {
|
||||||
|
nullQuorumPut(t, z, bucket, key, []byte(key), ObjectOptions{})
|
||||||
|
}
|
||||||
|
var keys []string
|
||||||
|
var token string
|
||||||
|
var finished bool
|
||||||
|
for page := 0; page < 8; page++ {
|
||||||
|
target := getListObjectsV2URL("", bucket, "", "1", "", "", "")
|
||||||
|
if token != "" {
|
||||||
|
target += "&continuation-token=" + url.QueryEscape(token)
|
||||||
|
}
|
||||||
|
rec := nullQuorumRequest(t, router, http.MethodGet, target)
|
||||||
|
var list ListObjectsV2Response
|
||||||
|
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &list) != nil {
|
||||||
|
t.Fatalf("page %d: %d %s", page, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, entry := range list.Contents {
|
||||||
|
keys = append(keys, entry.Key)
|
||||||
|
}
|
||||||
|
if !list.IsTruncated {
|
||||||
|
finished = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if list.NextContinuationToken == "" || list.NextContinuationToken == token {
|
||||||
|
t.Fatal("pagination did not advance")
|
||||||
|
}
|
||||||
|
token = list.NextContinuationToken
|
||||||
|
}
|
||||||
|
if !finished || !slices.Equal(keys, []string{"a", "b", "c", "d"}) {
|
||||||
|
t.Fatalf("pagination returned %v, finished=%v", keys, finished)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cached full page must remain streamable even if a later key is slow. The
|
||||||
|
// completion barrier belongs at EOF, not before every page response.
|
||||||
|
func TestListingCacheFullPageDoesNotWaitForTail(t *testing.T) {
|
||||||
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
|
disks, switchDisks := installListingDiskSwitch(z.serverPools[0].sets[0])
|
||||||
|
for _, key := range []string{"a", "b", "c", "d", "e", "zz"} {
|
||||||
|
nullQuorumPut(t, z, bucket, key, []byte(key), ObjectOptions{})
|
||||||
|
}
|
||||||
|
first := nullQuorumRequest(t, router, http.MethodGet, getListObjectsV2URL("", bucket, "", "1", "", "", ""))
|
||||||
|
var page ListObjectsV2Response
|
||||||
|
if first.Code != http.StatusOK || xml.Unmarshal(first.Body.Bytes(), &page) != nil || !page.IsTruncated {
|
||||||
|
t.Fatalf("first page: %d %s", first.Code, first.Body.String())
|
||||||
|
}
|
||||||
|
resume := make(chan struct{})
|
||||||
|
release := sync.OnceFunc(func() { close(resume) })
|
||||||
|
defer release()
|
||||||
|
var blocked, active atomic.Int64
|
||||||
|
walkers := slices.Clone(disks)
|
||||||
|
for i, disk := range walkers {
|
||||||
|
walkers[i] = &listingPausedTailDisk{StorageAPI: disk, bucket: bucket, tail: "zz", resume: resume, blocked: &blocked, active: &active}
|
||||||
|
}
|
||||||
|
switchDisks(walkers)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
target := getListObjectsV2URL("", bucket, "", "1", "", "", "") + "&continuation-token=" + url.QueryEscape(page.NextContinuationToken)
|
||||||
|
req, err := newTestSignedRequestV4(http.MethodGet, target, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req.WithContext(ctx))
|
||||||
|
response <- rec
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case rec := <-response:
|
||||||
|
page = ListObjectsV2Response{}
|
||||||
|
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &page) != nil || !page.IsTruncated || len(page.Contents) != 1 || page.Contents[0].Key != "b" || page.NextContinuationToken == "" {
|
||||||
|
t.Fatalf("full page before tail: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if blocked.Load() == 0 || active.Load() == 0 {
|
||||||
|
t.Fatal("full page did not overlap the paused tail")
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("full page waited for the paused tail")
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
waitForListingCondition(t, "disk walkers did not finish", func() bool { return active.Load() == 0 })
|
||||||
|
target = getListObjectsV2URL("", bucket, "", "1000", "", "", "") + "&continuation-token=" + url.QueryEscape(page.NextContinuationToken)
|
||||||
|
rec := nullQuorumRequest(t, router, http.MethodGet, target)
|
||||||
|
page = ListObjectsV2Response{}
|
||||||
|
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &page) != nil || page.IsTruncated {
|
||||||
|
t.Fatalf("page after tail resumes: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var keys []string
|
||||||
|
for _, entry := range page.Contents {
|
||||||
|
keys = append(keys, entry.Key)
|
||||||
|
}
|
||||||
|
if !slices.Equal(keys, []string{"c", "d", "e", "zz"}) {
|
||||||
|
t.Fatalf("cached remainder lost keys: %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the error update pending at the actual peer RPC boundary. Other cache
|
||||||
|
// operations, including readers and abandoned-cache cleanup, remain live.
|
||||||
|
func listingCacheErrorPeer(t *testing.T, fail bool) (*peerRESTClient, <-chan struct{}, func()) {
|
||||||
|
t.Helper()
|
||||||
|
tg, err := grid.SetupTestGrid(2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ready, resume := make(chan struct{}), make(chan struct{})
|
||||||
|
release := sync.OnceFunc(func() { close(resume) })
|
||||||
|
t.Cleanup(func() {
|
||||||
|
release()
|
||||||
|
tg.Cleanup()
|
||||||
|
})
|
||||||
|
var entered sync.Once
|
||||||
|
server := &peerRESTServer{}
|
||||||
|
if err := getMetacacheListingRPC.Register(tg.Managers[1], server.GetMetacacheListingHandler); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := updateMetacacheListingRPC.Register(tg.Managers[1], func(m *metacache) (*metacache, *grid.RemoteErr) {
|
||||||
|
if m.error != "" && m.error != "no longer used" {
|
||||||
|
entered.Do(func() { close(ready) })
|
||||||
|
select {
|
||||||
|
case <-resume:
|
||||||
|
case <-t.Context().Done():
|
||||||
|
return nil, grid.NewRemoteErr(t.Context().Err())
|
||||||
|
}
|
||||||
|
if fail {
|
||||||
|
return nil, grid.NewRemoteErr(errors.New("cache error update failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return server.UpdateMetacacheListingHandler(m)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &peerRESTClient{gridConn: func() *grid.Connection { return tg.Managers[0].Connection(tg.Hosts[1]) }}, ready, release
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingCacheRetainsErrorAfterPeerFailure(t *testing.T) {
|
||||||
|
client, ready, release := listingCacheErrorPeer(t, true)
|
||||||
|
o := listPathOptions{ID: mustGetUUID(), Bucket: "bucket"}
|
||||||
|
m := o.newMetacache()
|
||||||
|
mc := metaCacheRPC{meta: &m, rpc: client, o: o}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
mc.setErr("terminal listing failure")
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
release()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Error("setErr worker did not finish before grid cleanup")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case <-ready:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("error update did not reach peer")
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("failed peer update did not return")
|
||||||
|
}
|
||||||
|
if m.id != o.ID || m.bucket != o.Bucket || m.status != scanStateError || m.error != "terminal listing failure" || mc.getErr() == nil {
|
||||||
|
t.Fatalf("peer failure erased local failure: %+v", m)
|
||||||
|
}
|
||||||
|
mc.setErr("later failure")
|
||||||
|
if m.error != "terminal listing failure" {
|
||||||
|
t.Fatalf("later failure replaced the terminal error: %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// 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"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListFallbackPreservesPoolDeleteMarker(t *testing.T) {
|
||||||
|
for _, markerPool := range []int{0, 1} {
|
||||||
|
t.Run(fmt.Sprint(markerPool), func(t *testing.T) {
|
||||||
|
z, bucket, router := listingPoolsBackend(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
live, deleted := z.serverPools[1-markerPool].sets[0], z.serverPools[markerPool].sets[0]
|
||||||
|
disks, switchDisks := installListingDiskSwitch(deleted)
|
||||||
|
const object = "gone"
|
||||||
|
body := bytes.Repeat([]byte{'a'}, 8192)
|
||||||
|
oldTime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
for _, set := range []*erasureObjects{live, deleted} {
|
||||||
|
_, err := set.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{MTime: oldTime, VersionSuspended: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
old := make([][]byte, 3)
|
||||||
|
for i := range old {
|
||||||
|
old[i] = mustReadNullQuorumMeta(t, disks[i], bucket, object)
|
||||||
|
}
|
||||||
|
_, err := deleted.DeleteObject(ctx, bucket, object, ObjectOptions{VersionSuspended: true, DeleteMarker: true, MTime: oldTime.Add(time.Minute)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
get := nullQuorumRequest(t, router, http.MethodGet, getGetObjectURL("", bucket, object))
|
||||||
|
if get.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected deleted GET, got %d %s", get.Code, get.Body.String())
|
||||||
|
}
|
||||||
|
first := nullQuorumRequest(t, router, http.MethodGet, getListObjectsV2URL("", bucket, "", "1000", "", "", ""))
|
||||||
|
var page ListObjectsV2Response
|
||||||
|
if first.Code != http.StatusOK || xml.Unmarshal(first.Body.Bytes(), &page) != nil || len(page.Contents) != 0 {
|
||||||
|
t.Fatalf("normal LIST marker: %d %s", first.Code, first.Body.String())
|
||||||
|
}
|
||||||
|
for i := range old {
|
||||||
|
if err := disks[i].WriteAll(ctx, bucket, object+"/"+xlStorageFormatFile, old[i]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walkers := slices.Clone(disks)
|
||||||
|
for i := 8; i < len(walkers); i++ {
|
||||||
|
walkers[i] = &failedMetadataWalkDisk{StorageAPI: disks[i]}
|
||||||
|
}
|
||||||
|
switchDisks(walkers)
|
||||||
|
fi, _, _, err := deleted.getObjectFileInfo(ctx, bucket, object, ObjectOptions{}, false)
|
||||||
|
if err != nil || !fi.Deleted {
|
||||||
|
t.Fatalf("read fallback marker: %+v %v", fi, err)
|
||||||
|
}
|
||||||
|
rec := nullQuorumRequest(t, router, http.MethodGet, getListObjectsV2URL("", bucket, "", "1000", "", "", ""))
|
||||||
|
page = ListObjectsV2Response{}
|
||||||
|
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &page) != nil || len(page.Contents) != 0 {
|
||||||
|
t.Fatalf("fallback resurrected old pool copy: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The regular metadata reader can translate a persistent generation split to
|
||||||
|
// ObjectNotFound even though every drive still has valid metadata. LIST must
|
||||||
|
// preserve that uncertainty instead of treating the key as deleted.
|
||||||
|
func TestListFallbackRejectsSplitGenerations(t *testing.T) {
|
||||||
|
z, bucket, _ := nullQuorumBackend(t)
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
disks := set.getDisks()
|
||||||
|
const object = "object"
|
||||||
|
mtime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
var generations [3][][]byte
|
||||||
|
for generation := range generations {
|
||||||
|
body := bytes.Repeat([]byte{byte('a' + generation)}, 8192)
|
||||||
|
nullQuorumPut(t, z, bucket, object, body, ObjectOptions{MTime: mtime.Add(time.Duration(generation) * time.Minute)})
|
||||||
|
generations[generation] = make([][]byte, len(disks))
|
||||||
|
for i, disk := range disks {
|
||||||
|
generations[generation][i] = mustReadNullQuorumMeta(t, disk, bucket, object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All sixteen drives are online, but the 7/5/4 split leaves no generation
|
||||||
|
// with the eight matching snapshots required by the listing resolver.
|
||||||
|
for i, disk := range disks {
|
||||||
|
generation := 0
|
||||||
|
if i >= 7 {
|
||||||
|
generation = 1
|
||||||
|
}
|
||||||
|
if i >= 12 {
|
||||||
|
generation = 2
|
||||||
|
}
|
||||||
|
if err := disk.WriteAll(t.Context(), bucket, object+"/"+xlStorageFormatFile, generations[generation][i]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, _, err := set.getObjectFileInfo(t.Context(), bucket, object, ObjectOptions{}, false); !isErrObjectNotFound(err) {
|
||||||
|
t.Fatalf("split metadata did not exercise ambiguous not-found: %v", err)
|
||||||
|
}
|
||||||
|
entries := make(metaCacheEntries, len(disks))
|
||||||
|
for i, disk := range disks {
|
||||||
|
entries[i] = metaCacheEntry{name: object, metadata: mustReadNullQuorumMeta(t, disk, bucket, object)}
|
||||||
|
if _, err := entries[i].xlmeta(); err != nil {
|
||||||
|
t.Fatalf("drive %d no longer has valid metadata: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolver := metadataResolutionParams{dirQuorum: 8, objQuorum: 8, bucket: bucket, requestedVersions: 1}
|
||||||
|
entry, err := z.resolveListingGroup(t.Context(), bucket, []*metaCacheEntry{set.resolveListEntry(entries, nil, &resolver)})
|
||||||
|
if entry != nil || !isErrReadQuorum(err) {
|
||||||
|
t.Fatalf("split live metadata became absence: entry=%v err=%v", entry, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Copyright (c) 2026 Ruohang Feng
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listingDirectoryWalkSnapshot struct {
|
||||||
|
StorageAPI
|
||||||
|
bucket string
|
||||||
|
snapshot []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d listingDirectoryWalkSnapshot) WalkDir(ctx context.Context, opts WalkDirOptions, out io.Writer) error {
|
||||||
|
if opts.Bucket != d.bucket {
|
||||||
|
return d.StorageAPI.WalkDir(ctx, opts, out)
|
||||||
|
}
|
||||||
|
_, err := out.Write(d.snapshot)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture two actual directory-walk generations for a directory object, then
|
||||||
|
// acknowledge its deletion. A different pool still contains dir/child. The
|
||||||
|
// stale source walkers disagree, while every current source drive is absent.
|
||||||
|
func TestListingRecoveryDeletedDirectoryKeepsCommonPrefix(t *testing.T) {
|
||||||
|
for _, marker := range []bool{false, true} {
|
||||||
|
for _, sourcePool := range []int{0, 1} {
|
||||||
|
t.Run(fmt.Sprintf("pool=%d/marker=%t", sourcePool, marker), func(t *testing.T) {
|
||||||
|
z, bucket, router := listingPoolsBackend(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
source := z.serverPools[sourcePool].sets[0]
|
||||||
|
target := z.serverPools[1-sourcePool].sets[0]
|
||||||
|
childBody := []byte("surviving child")
|
||||||
|
put := func(set *erasureObjects, key string, tm time.Time) {
|
||||||
|
var body []byte
|
||||||
|
if key == "dir/child" {
|
||||||
|
body = childBody
|
||||||
|
}
|
||||||
|
_, err := set.PutObject(ctx, bucket, encodeDirObject(key), mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{MTime: tm, VersionSuspended: marker})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tm := time.Now().UTC().Add(-time.Hour)
|
||||||
|
put(target, "dir/child", tm)
|
||||||
|
disks, switchDisks := installListingDiskSwitch(source)
|
||||||
|
snapshots := make([][]byte, len(disks))
|
||||||
|
for gen := range 3 {
|
||||||
|
if gen < 2 {
|
||||||
|
put(source, "dir/", tm.Add(time.Duration(gen)*time.Minute))
|
||||||
|
} else {
|
||||||
|
if _, err := source.DeleteObject(ctx, bucket, encodeDirObject("dir/"), ObjectOptions{VersionSuspended: marker, DeleteMarker: marker, MTime: tm.Add(2 * time.Minute)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, disk := range disks {
|
||||||
|
wanted := 0
|
||||||
|
if i >= 6 {
|
||||||
|
wanted = 1
|
||||||
|
}
|
||||||
|
if i >= 12 {
|
||||||
|
wanted = 2
|
||||||
|
}
|
||||||
|
if wanted != gen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := disk.WalkDir(ctx, WalkDirOptions{Bucket: bucket, Recursive: false}, &b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshots[i] = append([]byte(nil), b.Bytes()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"dir/child", "dir/"} {
|
||||||
|
request, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", bucket, key), 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request.WithContext(ctx))
|
||||||
|
t.Logf("GET %s HTTP %d", key, response.Code)
|
||||||
|
want := http.StatusOK
|
||||||
|
if key == "dir/" {
|
||||||
|
want = http.StatusNotFound
|
||||||
|
}
|
||||||
|
if response.Code != want {
|
||||||
|
t.Fatalf("GET %s: got %d want %d: %s", key, response.Code, want, response.Body.String())
|
||||||
|
}
|
||||||
|
if key == "dir/child" && !bytes.Equal(response.Body.Bytes(), childBody) {
|
||||||
|
t.Fatal("surviving child content changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrapped := make([]StorageAPI, len(disks))
|
||||||
|
for i, disk := range disks {
|
||||||
|
wrapped[i] = listingDirectoryWalkSnapshot{disk, bucket, snapshots[i]}
|
||||||
|
}
|
||||||
|
switchDisks(wrapped)
|
||||||
|
t.Cleanup(func() { switchDisks(disks) })
|
||||||
|
req, err := newTestSignedRequestV4(http.MethodGet, getListObjectsV2URL("", bucket, "", "1000", "", "", "/"), 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req.WithContext(ctx))
|
||||||
|
t.Logf("LIST HTTP %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
var page ListObjectsV2Response
|
||||||
|
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &page) != nil {
|
||||||
|
t.Fatalf("LIST failed: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if len(page.CommonPrefixes) != 1 || page.CommonPrefixes[0].Prefix != "dir/" {
|
||||||
|
t.Fatalf("acknowledged child lost its CommonPrefix: %+v", page)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The directory must remain in the sorted input when an earlier object name
|
||||||
|
// disappears. Emitting it immediately would skip an intervening name, and
|
||||||
|
// keeping only one colliding directory would leave input order significant.
|
||||||
|
func TestListingRecoveryDirectoryCollisionOrdering(t *testing.T) {
|
||||||
|
for _, object := range []string{"dir", "dir/"} {
|
||||||
|
for _, present := range []bool{false, true} {
|
||||||
|
if present && object == "dir" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for source := range 3 {
|
||||||
|
t.Run(fmt.Sprintf("object=%s/present=%t/source=%d", object, present, source), func(t *testing.T) {
|
||||||
|
in := make([]chan metaCacheEntry, 6)
|
||||||
|
for i := range in {
|
||||||
|
in[i] = make(chan metaCacheEntry, 1)
|
||||||
|
entry := metaCacheEntry{name: "dir/"}
|
||||||
|
if i == source {
|
||||||
|
entry = metaCacheEntry{name: object, listingSet: &erasureObjects{}}
|
||||||
|
}
|
||||||
|
if i >= 3 {
|
||||||
|
entry = metaCacheEntry{name: []string{"dir-", "dir.ext", "dir0"}[i-3], metadata: []byte{1}}
|
||||||
|
}
|
||||||
|
in[i] <- entry
|
||||||
|
close(in[i])
|
||||||
|
}
|
||||||
|
out := make(chan metaCacheEntry, len(in))
|
||||||
|
err := mergeEntryChannelsWithResolver(t.Context(), in, out, 1, nil, func(group []*metaCacheEntry) (*metaCacheEntry, error) {
|
||||||
|
if len(group) != 1 || group[0].listingSet == nil {
|
||||||
|
t.Fatalf("directory entered the object group: %+v", group)
|
||||||
|
}
|
||||||
|
if !present {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &metaCacheEntry{name: object, metadata: []byte{1}}, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for entry := range out {
|
||||||
|
names = append(names, entry.name)
|
||||||
|
if entry.name == "dir/" && entry.isObject() != present {
|
||||||
|
t.Fatalf("directory collision returned the wrong entry type: %+v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{"dir-", "dir.ext", "dir/", "dir0"}
|
||||||
|
if !slices.Equal(names, want) {
|
||||||
|
t.Fatalf("names=%v, want %v", names, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A regular walk result may be stale when a pending peer forces current
|
||||||
|
// recovery. Keep sibling prefixes until the complete object group is resolved.
|
||||||
|
func TestListingRecoveryMixedDirectoryCollision(t *testing.T) {
|
||||||
|
orders := [][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
|
||||||
|
for _, order := range orders {
|
||||||
|
for _, present := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("order=%v/present=%t", order, present), func(t *testing.T) {
|
||||||
|
entries := []metaCacheEntry{
|
||||||
|
{name: "dir/", metadata: []byte{1}},
|
||||||
|
{name: "dir/"},
|
||||||
|
{name: "dir/", listingSet: &erasureObjects{}},
|
||||||
|
}
|
||||||
|
in := make([]chan metaCacheEntry, len(order))
|
||||||
|
for i, index := range order {
|
||||||
|
in[i] = make(chan metaCacheEntry, 2)
|
||||||
|
in[i] <- entries[index]
|
||||||
|
in[i] <- metaCacheEntry{name: fmt.Sprintf("tail-%d", i), metadata: []byte{1}}
|
||||||
|
close(in[i])
|
||||||
|
}
|
||||||
|
out := make(chan metaCacheEntry, 6)
|
||||||
|
calls := 0
|
||||||
|
err := mergeEntryChannelsWithResolver(t.Context(), in, out, 1, nil, func(group []*metaCacheEntry) (*metaCacheEntry, error) {
|
||||||
|
calls++
|
||||||
|
if len(group) != 2 {
|
||||||
|
t.Fatalf("incomplete object group: %+v", group)
|
||||||
|
}
|
||||||
|
pending := 0
|
||||||
|
for _, entry := range group {
|
||||||
|
if entry.name != "dir/" || entry.isDir() {
|
||||||
|
t.Fatalf("unexpected recovery entry: %+v", entry)
|
||||||
|
}
|
||||||
|
if entry.listingSet != nil {
|
||||||
|
pending++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pending != 1 {
|
||||||
|
t.Fatalf("pending entries=%d, want 1", pending)
|
||||||
|
}
|
||||||
|
if !present {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &metaCacheEntry{name: "dir/", metadata: []byte{1}}, nil
|
||||||
|
})
|
||||||
|
if err != nil || calls != 1 {
|
||||||
|
t.Fatalf("err=%v recovery calls=%d", err, calls)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for entry := range out {
|
||||||
|
names = append(names, entry.name)
|
||||||
|
if entry.listingSet != nil || entry.name == "dir/" && entry.isObject() != present {
|
||||||
|
t.Fatalf("unexpected merged entry: %+v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{"dir/", "tail-0", "tail-1", "tail-2"}
|
||||||
|
if !slices.Equal(names, want) {
|
||||||
|
t.Fatalf("names=%v, want %v", names, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// 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"
|
||||||
|
"encoding/xml"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exercise the actual LIST pipeline with the disputed entry in pool 1.
|
||||||
|
func TestListFallbackMultiPoolHTTPReadLock(t *testing.T) {
|
||||||
|
for _, object := range []string{"object", "dir/"} {
|
||||||
|
t.Run(object, func(t *testing.T) {
|
||||||
|
z, bucket, router := listingPoolsBackend(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
set := z.serverPools[1].sets[0]
|
||||||
|
disks := set.getDisks()
|
||||||
|
encoded := encodeDirObject(object)
|
||||||
|
body := bytes.Repeat([]byte{'a'}, 8192)
|
||||||
|
if object == "dir/" {
|
||||||
|
body = nil
|
||||||
|
}
|
||||||
|
mtime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
put := func(tm time.Time) {
|
||||||
|
_, err := set.PutObject(ctx, bucket, encoded, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{MTime: tm})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put(mtime)
|
||||||
|
old := make([][]byte, 3)
|
||||||
|
for i := range old {
|
||||||
|
old[i] = mustReadNullQuorumMeta(t, disks[i], bucket, encoded)
|
||||||
|
}
|
||||||
|
put(mtime.Add(time.Minute))
|
||||||
|
for i := range old {
|
||||||
|
if err := disks[i].WriteAll(ctx, bucket, encoded+"/"+xlStorageFormatFile, old[i]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walkers := slices.Clone(disks)
|
||||||
|
for i := 8; i < len(walkers); i++ {
|
||||||
|
walkers[i] = &failedMetadataWalkDisk{StorageAPI: disks[i]}
|
||||||
|
}
|
||||||
|
set.getDisks = func() []StorageAPI { return walkers }
|
||||||
|
lock := z.NewNSLock(bucket, encoded)
|
||||||
|
lkctx, err := lock.GetLock(ctx, globalOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
locked := true
|
||||||
|
defer func() {
|
||||||
|
if locked {
|
||||||
|
lock.Unlock(lkctx)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
target := getListObjectsV2URL("", bucket, "", "1000", "", "", "")
|
||||||
|
req, err := newTestSignedRequestV4(http.MethodGet, target, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
router.ServeHTTP(rec, req.WithContext(ctx))
|
||||||
|
}()
|
||||||
|
waitForListingReadLock(t, z.serverPools[0].sets[0].nsMutex, bucket, encoded, done)
|
||||||
|
lock.Unlock(lkctx)
|
||||||
|
locked = false
|
||||||
|
<-done
|
||||||
|
var list ListObjectsV2Response
|
||||||
|
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &list) != nil {
|
||||||
|
t.Fatalf("LIST HTTP=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if len(list.Contents) != 1 || list.Contents[0].Key != object {
|
||||||
|
t.Fatalf("LIST lost key: %+v", list)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// 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"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/minio/internal/dsync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use the real distributed-lock algorithm with in-process lock servers.
|
||||||
|
// This is a protocol-level check, not a network or rolling-restart test.
|
||||||
|
func TestListFallbackDistributedLockRoute(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
multiPool, sharedLockers bool
|
||||||
|
}{
|
||||||
|
{"multi-pool-shared-lockers", true, true},
|
||||||
|
{"multi-pool-disjoint-lockers", true, false},
|
||||||
|
{"single-pool-target-set-lockers", false, false},
|
||||||
|
{"single-pool-first-set-lockers", false, false},
|
||||||
|
{"single-pool-shared-ns-distinct-lockers", false, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
a := []dsync.NetLocker{newLocker(), newLocker()}
|
||||||
|
b := []dsync.NetLocker{newLocker(), newLocker()}
|
||||||
|
if tc.sharedLockers {
|
||||||
|
b = a
|
||||||
|
}
|
||||||
|
set0 := &erasureObjects{nsMutex: newNSLock(true), getLockers: func() ([]dsync.NetLocker, string) { return a, "listing-test" }}
|
||||||
|
target := &erasureObjects{nsMutex: newNSLock(true), getLockers: func() ([]dsync.NetLocker, string) { return b, "listing-test" }}
|
||||||
|
if tc.name == "single-pool-shared-ns-distinct-lockers" {
|
||||||
|
target.nsMutex = set0.nsMutex
|
||||||
|
}
|
||||||
|
z := &erasureServerPools{serverPools: []*erasureSets{{sets: []*erasureObjects{set0, target}}}}
|
||||||
|
if tc.multiPool {
|
||||||
|
z.serverPools = []*erasureSets{{sets: []*erasureObjects{set0}}, {sets: []*erasureObjects{target}}}
|
||||||
|
}
|
||||||
|
writer := target.NewNSLock("bucket", "object")
|
||||||
|
if tc.multiPool || tc.name == "single-pool-first-set-lockers" {
|
||||||
|
writer = z.NewNSLock("bucket", "object")
|
||||||
|
}
|
||||||
|
wctx, err := writer.GetLock(t.Context(), globalOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
locked := true
|
||||||
|
defer func() {
|
||||||
|
if locked {
|
||||||
|
writer.Unlock(wctx)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 80*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_, unlock, err := z.listingRLock(ctx, "bucket", "object", target)
|
||||||
|
if err == nil {
|
||||||
|
unlock()
|
||||||
|
t.Fatal("fallback read lock acquired while ordinary writer lock is held")
|
||||||
|
}
|
||||||
|
writer.Unlock(wctx)
|
||||||
|
locked = false
|
||||||
|
ctx, cancel = context.WithTimeout(t.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, unlock, err = z.listingRLock(ctx, "bucket", "object", target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fallback read lock failed after writer unlock: %v", err)
|
||||||
|
}
|
||||||
|
unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancellation while waiting for the second domain must release the first.
|
||||||
|
func TestListingSecondLockCancellationReleasesFirst(t *testing.T) {
|
||||||
|
first := &erasureObjects{nsMutex: newNSLock(false)}
|
||||||
|
target := &erasureObjects{nsMutex: newNSLock(false)}
|
||||||
|
z := &erasureServerPools{serverPools: []*erasureSets{{sets: []*erasureObjects{first, target}}}}
|
||||||
|
writer := target.NewNSLock("bucket", "object")
|
||||||
|
wctx, err := writer.GetLock(t.Context(), globalOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer writer.Unlock(wctx)
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan struct{})
|
||||||
|
var lockErr error
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
_, unlock, err := z.listingRLock(ctx, "bucket", "object", target)
|
||||||
|
lockErr = err
|
||||||
|
if err == nil {
|
||||||
|
unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
waitForListingReadLock(t, target.nsMutex, "bucket", "object", done)
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("second lock did not cancel")
|
||||||
|
}
|
||||||
|
if lockErr == nil {
|
||||||
|
t.Fatal("canceled second lock succeeded")
|
||||||
|
}
|
||||||
|
poolWriter := z.NewNSLock("bucket", "object")
|
||||||
|
ctx, cancel = context.WithTimeout(t.Context(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
pctx, err := poolWriter.GetLock(ctx, globalOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first lock leaked after second failed: %v", err)
|
||||||
|
}
|
||||||
|
poolWriter.Unlock(pctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// 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"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The fallback on pool 1 must contend with the
|
||||||
|
// pools-layer lock used by ordinary multi-pool writers (anchored at pool 0).
|
||||||
|
func TestListFallbackMultiPoolReadLock(t *testing.T) {
|
||||||
|
z, bucket, _ := listingPoolsBackend(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
set := z.serverPools[1].sets[0]
|
||||||
|
disks := set.getDisks()
|
||||||
|
const object = "object"
|
||||||
|
body := bytes.Repeat([]byte{'a'}, 8192)
|
||||||
|
mtime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
put := func(tm time.Time) {
|
||||||
|
_, err := set.PutObject(ctx, bucket, object, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{MTime: tm})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put(mtime)
|
||||||
|
old := make([][]byte, len(disks))
|
||||||
|
for i := range old {
|
||||||
|
old[i] = mustReadNullQuorumMeta(t, disks[i], bucket, object)
|
||||||
|
}
|
||||||
|
put(mtime.Add(time.Minute))
|
||||||
|
entries := make(metaCacheEntries, len(disks))
|
||||||
|
errs := make([]error, len(disks))
|
||||||
|
for i := range 3 {
|
||||||
|
entries[i] = metaCacheEntry{name: object, metadata: old[i]}
|
||||||
|
}
|
||||||
|
for i := 3; i < 8; i++ {
|
||||||
|
entries[i] = metaCacheEntry{name: object, metadata: mustReadNullQuorumMeta(t, disks[i], bucket, object)}
|
||||||
|
}
|
||||||
|
for i := 8; i < len(disks); i++ {
|
||||||
|
errs[i] = errDiskNotFound
|
||||||
|
}
|
||||||
|
resolver := metadataResolutionParams{dirQuorum: 8, objQuorum: 8, bucket: bucket, requestedVersions: 1}
|
||||||
|
lock := z.NewNSLock(bucket, object)
|
||||||
|
lkctx, err := lock.GetLock(ctx, globalOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
locked := true
|
||||||
|
defer func() {
|
||||||
|
if locked {
|
||||||
|
lock.Unlock(lkctx)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
done := make(chan struct{})
|
||||||
|
var entry *metaCacheEntry
|
||||||
|
var readErr error
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
entry, readErr = z.resolveListingGroup(ctx, bucket, []*metaCacheEntry{set.resolveListEntry(entries, errs, &resolver)})
|
||||||
|
}()
|
||||||
|
waitForListingReadLock(t, z.serverPools[0].sets[0].nsMutex, bucket, object, done)
|
||||||
|
lock.Unlock(lkctx)
|
||||||
|
locked = false
|
||||||
|
<-done
|
||||||
|
if readErr != nil || entry == nil || entry.name != object {
|
||||||
|
t.Fatalf("fallback after writer unlock: entry=%v err=%v", entry, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe the reader registered on the writer's actual namespace lock before
|
||||||
|
// releasing the writer. This tests mutual exclusion without a timed sleep.
|
||||||
|
func waitForListingReadLock(t *testing.T, mutex *nsLockMap, bucket, object string, done <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
timer := time.NewTimer(5 * time.Second)
|
||||||
|
defer timer.Stop()
|
||||||
|
tick := time.NewTicker(time.Millisecond)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
mutex.lockMapMutex.Lock()
|
||||||
|
lock := mutex.lockMap[pathJoin(bucket, object)]
|
||||||
|
waiting := lock != nil && lock.ref > 1
|
||||||
|
mutex.lockMapMutex.Unlock()
|
||||||
|
if waiting {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
t.Fatal("listing completed while the writer held its namespace lock")
|
||||||
|
case <-timer.C:
|
||||||
|
t.Fatal("listing did not wait on the writer's namespace lock")
|
||||||
|
case <-tick.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listingPoolsBackend(t *testing.T) (*erasureServerPools, string, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
obj, dirs, err := prepareErasurePoolsWithContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
z := obj.(*erasureServerPools)
|
||||||
|
previous := newObjectLayerFn()
|
||||||
|
setObjectLayer(z)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
z.Shutdown(context.Background())
|
||||||
|
removeRoots(dirs)
|
||||||
|
setObjectLayer(previous)
|
||||||
|
})
|
||||||
|
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return z, bucket, router
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// 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/xml"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/s2"
|
||||||
|
"github.com/tinylib/msgp/msgp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Replay real disk-walk metadata in its existing wire format, but pause before
|
||||||
|
// the final key. This keeps the healthy pool's listing alive after the other
|
||||||
|
// pool has already failed, without forging entries or listing errors.
|
||||||
|
type listingPausedTailDisk struct {
|
||||||
|
StorageAPI
|
||||||
|
bucket string
|
||||||
|
tail string
|
||||||
|
resume <-chan struct{}
|
||||||
|
blocked *atomic.Int64
|
||||||
|
active *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *listingPausedTailDisk) WalkDir(ctx context.Context, opts WalkDirOptions, out io.Writer) (result error) {
|
||||||
|
if opts.Bucket != d.bucket {
|
||||||
|
return d.StorageAPI.WalkDir(ctx, opts, out)
|
||||||
|
}
|
||||||
|
if d.active != nil {
|
||||||
|
d.active.Add(1)
|
||||||
|
defer d.active.Add(-1)
|
||||||
|
}
|
||||||
|
var source bytes.Buffer
|
||||||
|
if err := d.StorageAPI.WalkDir(ctx, opts, &source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r := newMetacacheReader(bytes.NewReader(source.Bytes()))
|
||||||
|
defer r.Close()
|
||||||
|
compressed := s2.NewWriter(out)
|
||||||
|
defer func() {
|
||||||
|
if err := compressed.Close(); result == nil {
|
||||||
|
result = err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
w := msgp.NewWriter(compressed)
|
||||||
|
if err := w.WriteByte(metacacheStreamVersion); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
entry, err := r.next()
|
||||||
|
if err == io.EOF {
|
||||||
|
if err = w.WriteBool(false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = w.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entry.name == d.tail {
|
||||||
|
if err = w.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = compressed.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.blocked.Add(1)
|
||||||
|
select {
|
||||||
|
case <-d.resume:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = w.WriteBool(true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = w.WriteString(entry.name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = w.WriteBytes(entry.metadata); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingMergeDoesNotSkipFailedPool(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
z, bucket, router := listingPoolsBackend(t)
|
||||||
|
broken, healthy := z.serverPools[0].sets[0], z.serverPools[1].sets[0]
|
||||||
|
brokenDisks, switchBrokenDisks := installListingDiskSwitch(broken)
|
||||||
|
healthyDisks, switchHealthyDisks := installListingDiskSwitch(healthy)
|
||||||
|
body := bytes.Repeat([]byte{'a'}, 8192)
|
||||||
|
mtime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
put := func(set *erasureObjects, name string, tm time.Time) {
|
||||||
|
_, err := set.PutObject(ctx, bucket, name, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{MTime: tm})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put(broken, "a", mtime)
|
||||||
|
put(broken, "e", mtime)
|
||||||
|
for _, name := range []string{"f", "g", "h", "i", "j", "k", "zz"} {
|
||||||
|
put(healthy, name, mtime)
|
||||||
|
}
|
||||||
|
first := nullQuorumRequest(t, router, http.MethodGet, getListObjectsV2URL("", bucket, "", "1", "", "", ""))
|
||||||
|
var page ListObjectsV2Response
|
||||||
|
if first.Code != http.StatusOK || xml.Unmarshal(first.Body.Bytes(), &page) != nil || !page.IsTruncated || page.NextContinuationToken == "" || len(page.Contents) != 1 || page.Contents[0].Key != "a" {
|
||||||
|
t.Fatalf("first page HTTP=%d body=%s", first.Code, first.Body.String())
|
||||||
|
}
|
||||||
|
firstToken := page.NextContinuationToken
|
||||||
|
old := make([][]byte, 3)
|
||||||
|
for i := range old {
|
||||||
|
old[i] = mustReadNullQuorumMeta(t, brokenDisks[i], bucket, "e")
|
||||||
|
}
|
||||||
|
put(broken, "e", mtime.Add(time.Minute))
|
||||||
|
for i := range old {
|
||||||
|
if err := brokenDisks[i].WriteAll(ctx, bucket, "e/"+xlStorageFormatFile, old[i]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var reads, blocked, active atomic.Int64
|
||||||
|
brokenWalkers := slices.Clone(brokenDisks)
|
||||||
|
for i := range brokenWalkers {
|
||||||
|
brokenWalkers[i] = &listingReadFailDisk{StorageAPI: brokenDisks[i], object: "e", reads: &reads}
|
||||||
|
if i >= 8 {
|
||||||
|
brokenWalkers[i] = &failedMetadataWalkDisk{StorageAPI: brokenWalkers[i]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switchBrokenDisks(brokenWalkers)
|
||||||
|
resume := make(chan struct{})
|
||||||
|
defer close(resume)
|
||||||
|
healthyWalkers := slices.Clone(healthyDisks)
|
||||||
|
for i, disk := range healthyWalkers {
|
||||||
|
healthyWalkers[i] = &listingPausedTailDisk{StorageAPI: disk, bucket: bucket, tail: "zz", resume: resume, blocked: &blocked, active: &active}
|
||||||
|
}
|
||||||
|
switchHealthyDisks(healthyWalkers)
|
||||||
|
|
||||||
|
target := getListObjectsV2URL("", bucket, "", "2", "", "", "") + "&continuation-token=" + url.QueryEscape(firstToken)
|
||||||
|
req, err := newTestSignedRequestV4(http.MethodGet, target, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req.WithContext(ctx))
|
||||||
|
response <- rec
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case rec := <-response:
|
||||||
|
t.Logf("continuation LIST=%d failed fallback ReadXL=%d paused healthy walkers=%d body=%s", rec.Code, reads.Load(), blocked.Load(), rec.Body.String())
|
||||||
|
if reads.Load() == 0 || blocked.Load() == 0 {
|
||||||
|
t.Fatal("schedule did not exercise fallback failure and unfinished healthy pool")
|
||||||
|
}
|
||||||
|
var apiErr APIErrorResponse
|
||||||
|
if rec.Code != http.StatusServiceUnavailable || xml.Unmarshal(rec.Body.Bytes(), &apiErr) != nil || apiErr.Code != "SlowDownRead" {
|
||||||
|
t.Fatalf("failed pool advanced the page instead of reporting read quorum: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
waitForListingCondition(t, "canceled disk walkers did not exit", func() bool { return active.Load() == 0 })
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatalf("no early response: %v reads=%d blocked=%d", ctx.Err(), reads.Load(), blocked.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingMergeCancellation(t *testing.T) {
|
||||||
|
z, bucket, _ := nullQuorumBackend(t)
|
||||||
|
for _, key := range []string{"a", "b", "zz"} {
|
||||||
|
nullQuorumPut(t, z, bucket, key, []byte(key), ObjectOptions{})
|
||||||
|
}
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
var blocked, active atomic.Int64
|
||||||
|
resume := make(chan struct{})
|
||||||
|
defer close(resume)
|
||||||
|
walkers := slices.Clone(set.getDisks())
|
||||||
|
for i, disk := range walkers {
|
||||||
|
walkers[i] = &listingPausedTailDisk{StorageAPI: disk, bucket: bucket, tail: "zz", resume: resume, blocked: &blocked, active: &active}
|
||||||
|
}
|
||||||
|
set.getDisks = func() []StorageAPI { return walkers }
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
entries := make(chan metaCacheEntry, 10)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- z.listMerged(ctx, listPathOptions{Bucket: bucket, Recursive: true, Limit: 10}, entries)
|
||||||
|
}()
|
||||||
|
waitForListingCondition(t, "disk walks did not reach the paused tail", func() bool { return blocked.Load() > 0 })
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled merge returned %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("canceled merge did not finish")
|
||||||
|
}
|
||||||
|
// listMerged must close its output and release every paused disk walk.
|
||||||
|
for range entries {
|
||||||
|
}
|
||||||
|
waitForListingCondition(t, "canceled disk walkers did not exit", func() bool { return active.Load() == 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForListingCondition(t *testing.T, failure string, ready func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
timer := time.NewTimer(5 * time.Second)
|
||||||
|
defer timer.Stop()
|
||||||
|
tick := time.NewTicker(time.Millisecond)
|
||||||
|
defer tick.Stop()
|
||||||
|
for !ready() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
t.Fatal(failure)
|
||||||
|
case <-tick.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
// 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"
|
||||||
|
"slices"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listingRecoveryDisk struct {
|
||||||
|
StorageAPI
|
||||||
|
reads *atomic.Int64
|
||||||
|
err error
|
||||||
|
emptySuccess bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *listingRecoveryDisk) ReadXL(ctx context.Context, bucket, object string, data bool) (RawFileInfo, error) {
|
||||||
|
if d.reads != nil {
|
||||||
|
d.reads.Add(1)
|
||||||
|
}
|
||||||
|
if d.emptySuccess {
|
||||||
|
return RawFileInfo{}, nil
|
||||||
|
}
|
||||||
|
if d.err != nil {
|
||||||
|
return RawFileInfo{}, d.err
|
||||||
|
}
|
||||||
|
return d.StorageAPI.ReadXL(ctx, bucket, object, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingRecoveryStrictAbsence(t *testing.T) {
|
||||||
|
z, bucket, _ := nullQuorumBackend(t)
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
disks, switchDisks := installListingDiskSwitch(set)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
offline, empty, absent bool
|
||||||
|
}{
|
||||||
|
{name: "all-native-not-found", absent: true},
|
||||||
|
{name: "offline", offline: true},
|
||||||
|
{name: "volume-not-found", err: errVolumeNotFound},
|
||||||
|
{name: "version-not-found", err: errFileVersionNotFound},
|
||||||
|
{name: "corrupt", err: errFileCorrupt},
|
||||||
|
{name: "decode-generated-not-found", empty: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
next := slices.Clone(disks)
|
||||||
|
next[0] = &listingRecoveryDisk{StorageAPI: disks[0], err: tc.err, emptySuccess: tc.empty}
|
||||||
|
if tc.offline {
|
||||||
|
next[0] = &offlineNullQuorumDisk{StorageAPI: disks[0]}
|
||||||
|
}
|
||||||
|
switchDisks(next)
|
||||||
|
_, absent, err := set.readListingFileInfo(t.Context(), bucket, "missing")
|
||||||
|
if absent != tc.absent || (tc.absent && err != nil) || (!tc.absent && !isErrReadQuorum(err)) {
|
||||||
|
t.Fatalf("absent=%v err=%v, want absent=%v", absent, err, tc.absent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
switchDisks(disks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The target walk can predate the copy. Only current cross-pool reads can
|
||||||
|
// distinguish this copy-before-delete schedule from a genuine object deletion.
|
||||||
|
func TestListingRecoveryCurrentPools(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
target, marker, unknown, stale bool
|
||||||
|
}{
|
||||||
|
{name: "copied-before-source-delete", target: true},
|
||||||
|
{name: "deleted-with-stale-other-walk", stale: true},
|
||||||
|
{name: "current-marker-over-stale-live", target: true, marker: true, stale: true},
|
||||||
|
{name: "missing-source-unknown-target", unknown: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
z, bucket, _ := listingPoolsBackend(t)
|
||||||
|
target, source := z.serverPools[0].sets[0], z.serverPools[1].sets[0]
|
||||||
|
targetDisks, switchTarget := installListingDiskSwitch(target)
|
||||||
|
sourceDisks, switchSource := installListingDiskSwitch(source)
|
||||||
|
const object = "moved"
|
||||||
|
body := bytes.Repeat([]byte{'a'}, 8192)
|
||||||
|
mtime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
put := func(set *erasureObjects) {
|
||||||
|
_, err := set.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{MTime: mtime, VersionSuspended: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put(source)
|
||||||
|
var stale *metaCacheEntry
|
||||||
|
if tc.stale || tc.target {
|
||||||
|
put(target)
|
||||||
|
stale = &metaCacheEntry{name: object, metadata: mustReadNullQuorumMeta(t, targetDisks[0], bucket, object)}
|
||||||
|
}
|
||||||
|
if tc.stale && !tc.target {
|
||||||
|
if _, err := target.DeleteObject(t.Context(), bucket, object, ObjectOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.marker {
|
||||||
|
if _, err := target.DeleteObject(t.Context(), bucket, object, ObjectOptions{VersionSuspended: true, DeleteMarker: true, MTime: mtime.Add(time.Minute)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Actual copy is committed before actual source cleanup.
|
||||||
|
if _, err := source.DeleteObject(t.Context(), bucket, object, ObjectOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var sourceReads, targetReads atomic.Int64
|
||||||
|
nextSource, nextTarget := slices.Clone(sourceDisks), slices.Clone(targetDisks)
|
||||||
|
for i := range nextSource {
|
||||||
|
nextSource[i] = &listingRecoveryDisk{StorageAPI: sourceDisks[i], reads: &sourceReads}
|
||||||
|
}
|
||||||
|
for i := range nextTarget {
|
||||||
|
nextTarget[i] = &listingRecoveryDisk{StorageAPI: targetDisks[i], reads: &targetReads}
|
||||||
|
}
|
||||||
|
if tc.unknown {
|
||||||
|
nextTarget[0] = &listingRecoveryDisk{StorageAPI: targetDisks[0], reads: &targetReads, err: errDiskNotFound}
|
||||||
|
}
|
||||||
|
switchSource(nextSource)
|
||||||
|
switchTarget(nextTarget)
|
||||||
|
group := []*metaCacheEntry{{name: object, listingSet: source}}
|
||||||
|
if stale != nil && tc.stale {
|
||||||
|
group = append(group, stale)
|
||||||
|
}
|
||||||
|
entry, err := z.resolveListingGroup(t.Context(), bucket, group)
|
||||||
|
if sourceReads.Load() != int64(len(sourceDisks)) || targetReads.Load() != int64(len(targetDisks)) {
|
||||||
|
t.Fatalf("reads source=%d target=%d", sourceReads.Load(), targetReads.Load())
|
||||||
|
}
|
||||||
|
if tc.unknown {
|
||||||
|
if entry != nil || !isErrReadQuorum(err) {
|
||||||
|
t.Fatalf("unknown became success: entry=%v err=%v", entry, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !tc.target {
|
||||||
|
if entry != nil {
|
||||||
|
t.Fatalf("stale walk survived current absence: %+v", entry)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if entry == nil || entry.listingSet != nil {
|
||||||
|
t.Fatalf("missing current result: %+v", entry)
|
||||||
|
}
|
||||||
|
fi, err := entry.fileInfo(bucket)
|
||||||
|
if err != nil || fi.Deleted != tc.marker {
|
||||||
|
t.Fatalf("current result deleted=%v err=%v, want marker=%v", fi.Deleted, err, tc.marker)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingRecoveryNormalPathDoesNotReadOrLock(t *testing.T) {
|
||||||
|
z, bucket, _ := listingPoolsBackend(t)
|
||||||
|
const object = "normal"
|
||||||
|
set := z.serverPools[1].sets[0]
|
||||||
|
body := []byte("normal data")
|
||||||
|
if _, err := set.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var reads atomic.Int64
|
||||||
|
for _, pool := range z.serverPools {
|
||||||
|
for _, set := range pool.sets {
|
||||||
|
disks, switchDisks := installListingDiskSwitch(set)
|
||||||
|
wrapped := slices.Clone(disks)
|
||||||
|
for i := range wrapped {
|
||||||
|
wrapped[i] = &listingRecoveryDisk{StorageAPI: disks[i], reads: &reads}
|
||||||
|
}
|
||||||
|
switchDisks(wrapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer := z.NewNSLock(bucket, object)
|
||||||
|
lkctx, err := writer.GetLock(t.Context(), globalOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer writer.Unlock(lkctx)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out := make(chan metaCacheEntry, 10)
|
||||||
|
err = z.listMerged(ctx, listPathOptions{Bucket: bucket, Recursive: true, Limit: 10}, out)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
t.Fatalf("normal listing waited on writer or failed: %v", err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for entry := range out {
|
||||||
|
names = append(names, entry.name)
|
||||||
|
if entry.listingSet != nil {
|
||||||
|
t.Fatal("pending escaped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(names, []string{object}) || reads.Load() != 0 {
|
||||||
|
t.Fatalf("normal names=%v additional ReadXL=%d", names, reads.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingPendingDirectoryCollision(t *testing.T) {
|
||||||
|
for _, objectFirst := range []bool{false, true} {
|
||||||
|
for _, present := range []bool{true, false} {
|
||||||
|
t.Run(fmt.Sprintf("objectFirst=%v/present=%v", objectFirst, present), func(t *testing.T) {
|
||||||
|
in := []chan metaCacheEntry{make(chan metaCacheEntry, 2), make(chan metaCacheEntry, 2), make(chan metaCacheEntry, 2)}
|
||||||
|
objectIndex := 1
|
||||||
|
if objectFirst {
|
||||||
|
objectIndex = 0
|
||||||
|
}
|
||||||
|
in[objectIndex] <- metaCacheEntry{name: "dir/", listingSet: &erasureObjects{}}
|
||||||
|
in[1-objectIndex] <- metaCacheEntry{name: "dir/"}
|
||||||
|
in[2] <- metaCacheEntry{name: "a", metadata: []byte{1}}
|
||||||
|
for _, c := range in {
|
||||||
|
close(c)
|
||||||
|
}
|
||||||
|
out := make(chan metaCacheEntry, 4)
|
||||||
|
calls := 0
|
||||||
|
err := mergeEntryChannelsWithResolver(t.Context(), in, out, 1, nil, func(group []*metaCacheEntry) (*metaCacheEntry, error) {
|
||||||
|
calls++
|
||||||
|
if len(group) != 1 || group[0].listingSet == nil {
|
||||||
|
t.Fatalf("wrong pending group: %+v", group)
|
||||||
|
}
|
||||||
|
if !present {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &metaCacheEntry{name: "dir/", metadata: []byte{1}}, nil
|
||||||
|
})
|
||||||
|
if err != nil || calls != 1 || len(out) != 2 {
|
||||||
|
t.Fatalf("err=%v recoveries=%d outputs=%d", err, calls, len(out))
|
||||||
|
}
|
||||||
|
first := <-out
|
||||||
|
if first.name != "a" {
|
||||||
|
t.Fatalf("expected smaller key first, got %+v", first)
|
||||||
|
}
|
||||||
|
entry := <-out
|
||||||
|
if entry.name != "dir/" || entry.listingSet != nil {
|
||||||
|
t.Fatalf("unexpected recovered entry: %+v", entry)
|
||||||
|
}
|
||||||
|
if present && (entry.isDir() || !entry.isObjectDir()) {
|
||||||
|
t.Fatalf("object became directory: %+v", entry)
|
||||||
|
}
|
||||||
|
if !present && (!entry.isDir() || entry.name != "dir/") {
|
||||||
|
t.Fatalf("absent pending object dropped live directory prefix: %+v", entry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingRecoveryRejectsUnexpectedSinglePoolSet(t *testing.T) {
|
||||||
|
sets := []*erasureObjects{{}, {}}
|
||||||
|
pool := &erasureSets{sets: sets, distributionAlgo: formatErasureVersionV3DistributionAlgoV3}
|
||||||
|
z := &erasureServerPools{serverPools: []*erasureSets{pool}}
|
||||||
|
for _, object := range []string{"key", "directory/"} {
|
||||||
|
target := pool.getHashedSet(encodeDirObject(object))
|
||||||
|
wrong := sets[0]
|
||||||
|
if wrong == target {
|
||||||
|
wrong = sets[1]
|
||||||
|
}
|
||||||
|
entry, err := z.resolveListingGroup(t.Context(), "bucket", []*metaCacheEntry{{name: object, listingSet: wrong}})
|
||||||
|
if entry != nil || !isErrReadQuorum(err) {
|
||||||
|
t.Fatalf("unexpected set became success: %v %v", entry, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingRecoveryReleasesBeforeOutput(t *testing.T) {
|
||||||
|
z, bucket, _ := nullQuorumBackend(t)
|
||||||
|
const object = "object/"
|
||||||
|
nullQuorumPut(t, z, bucket, object, []byte("data"), ObjectOptions{})
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
input := make(chan metaCacheEntry, 1)
|
||||||
|
input <- metaCacheEntry{name: object, listingSet: set}
|
||||||
|
close(input)
|
||||||
|
out := make(chan metaCacheEntry)
|
||||||
|
resolved := make(chan struct{})
|
||||||
|
done := make(chan error, 1)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
done <- mergeEntryChannelsWithResolver(ctx, []chan metaCacheEntry{input}, out, 1, nil, func(group []*metaCacheEntry) (*metaCacheEntry, error) {
|
||||||
|
entry, err := z.resolveListingGroup(ctx, bucket, group)
|
||||||
|
close(resolved)
|
||||||
|
return entry, err
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-resolved:
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal(ctx.Err())
|
||||||
|
}
|
||||||
|
lock := set.NewNSLock(bucket, encodeDirObject(object))
|
||||||
|
lkctx, err := lock.GetLock(ctx, globalOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writer blocked by output backpressure: %v", err)
|
||||||
|
}
|
||||||
|
lock.Unlock(lkctx)
|
||||||
|
entry := <-out
|
||||||
|
if entry.name != object || !entry.isObjectDir() || entry.isDir() || entry.listingSet != nil {
|
||||||
|
t.Fatalf("directory object identity lost: %+v", entry)
|
||||||
|
}
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingRecoveryTerminalErrorPrecedesLock(t *testing.T) {
|
||||||
|
for _, n := range []int{1, 2} {
|
||||||
|
t.Run(fmt.Sprint(n), func(t *testing.T) {
|
||||||
|
inputs := make([]chan metaCacheEntry, n)
|
||||||
|
for i := range inputs {
|
||||||
|
inputs[i] = make(chan metaCacheEntry, 1)
|
||||||
|
inputs[i] <- metaCacheEntry{name: "key", listingSet: &erasureObjects{}}
|
||||||
|
close(inputs[i])
|
||||||
|
}
|
||||||
|
out := make(chan metaCacheEntry, 1)
|
||||||
|
want := errors.New("walk failed")
|
||||||
|
called := false
|
||||||
|
err := mergeEntryChannelsWithResolver(t.Context(), inputs, out, 1, func(int) error { return want }, func([]*metaCacheEntry) (*metaCacheEntry, error) { called = true; return nil, nil })
|
||||||
|
if !errors.Is(err, want) || called || len(out) != 0 {
|
||||||
|
t.Fatalf("err=%v recovery=%v output=%d", err, called, len(out))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingPendingCannotBeSerialized(t *testing.T) {
|
||||||
|
entry := metaCacheEntry{name: "directory/", listingSet: &erasureObjects{}}
|
||||||
|
if entry.isDir() || !entry.isObjectDir() {
|
||||||
|
t.Fatal("pending object became a directory")
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := newMetacacheWriter(&buf, 0)
|
||||||
|
if err := w.write(entry); err == nil {
|
||||||
|
t.Fatal("pending entry serialized")
|
||||||
|
}
|
||||||
|
_ = w.Close()
|
||||||
|
buf.Reset()
|
||||||
|
w = newMetacacheWriter(&buf, 0)
|
||||||
|
stream, err := w.stream()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stream <- entry
|
||||||
|
close(stream)
|
||||||
|
if err = w.Close(); err == nil {
|
||||||
|
t.Fatal("stream serialized pending entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListingEntryRepresentationCost(t *testing.T) {
|
||||||
|
type before struct {
|
||||||
|
_ string // name
|
||||||
|
_ []byte // metadata
|
||||||
|
_ *xlMetaV2 // cached
|
||||||
|
_ bool // reusable
|
||||||
|
}
|
||||||
|
t.Logf("metaCacheEntry before=%d after=%d bytes", unsafe.Sizeof(before{}), unsafe.Sizeof(metaCacheEntry{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkListingMergeNormal(b *testing.B) {
|
||||||
|
for _, n := range []int{1, 2} {
|
||||||
|
for _, resolver := range []bool{false, true} {
|
||||||
|
b.Run(fmt.Sprintf("inputs=%d/resolver=%v", n, resolver), func(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
inputs := make([]chan metaCacheEntry, n)
|
||||||
|
for i := range inputs {
|
||||||
|
inputs[i] = make(chan metaCacheEntry, 100)
|
||||||
|
for j := range 100 {
|
||||||
|
inputs[i] <- metaCacheEntry{name: fmt.Sprintf("key-%03d-%d/", j, i)}
|
||||||
|
}
|
||||||
|
close(inputs[i])
|
||||||
|
}
|
||||||
|
out := make(chan metaCacheEntry, n*100)
|
||||||
|
var resolveGroup func([]*metaCacheEntry) (*metaCacheEntry, error)
|
||||||
|
if resolver {
|
||||||
|
resolveGroup = func([]*metaCacheEntry) (*metaCacheEntry, error) { return nil, io.ErrUnexpectedEOF }
|
||||||
|
}
|
||||||
|
if err := mergeEntryChannelsWithResolver(context.Background(), inputs, out, 1, nil, resolveGroup); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -52,6 +53,37 @@ type nullQuorumWalkDisk struct {
|
|||||||
index int
|
index int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type failedMetadataWalkDisk struct {
|
||||||
|
StorageAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *failedMetadataWalkDisk) WalkDir(context.Context, WalkDirOptions, io.Writer) error {
|
||||||
|
return errDiskNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineNullQuorumDisk models a drive of a node that is away.
|
||||||
|
type offlineNullQuorumDisk struct {
|
||||||
|
StorageAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *offlineNullQuorumDisk) IsOnline() bool { return false }
|
||||||
|
|
||||||
|
func (d *offlineNullQuorumDisk) DiskInfo(context.Context, DiskInfoOptions) (DiskInfo, error) {
|
||||||
|
return DiskInfo{}, errDiskNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *offlineNullQuorumDisk) WalkDir(context.Context, WalkDirOptions, io.Writer) error {
|
||||||
|
return errDiskNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *offlineNullQuorumDisk) ReadXL(context.Context, string, string, bool) (RawFileInfo, error) {
|
||||||
|
return RawFileInfo{}, errDiskNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *offlineNullQuorumDisk) ReadVersion(context.Context, string, string, string, string, ReadOptions) (FileInfo, error) {
|
||||||
|
return FileInfo{}, errDiskNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func (d *nullQuorumWalkDisk) WalkDir(ctx context.Context, opts WalkDirOptions, out io.Writer) error {
|
func (d *nullQuorumWalkDisk) WalkDir(ctx context.Context, opts WalkDirOptions, out io.Writer) error {
|
||||||
if opts.Bucket != d.schedule.bucket {
|
if opts.Bucket != d.schedule.bucket {
|
||||||
return d.StorageAPI.WalkDir(ctx, opts, out)
|
return d.StorageAPI.WalkDir(ctx, opts, out)
|
||||||
@@ -119,6 +151,30 @@ func nullQuorumRequest(t *testing.T, router http.Handler, method, target string)
|
|||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullQuorumPut(t *testing.T, z *erasureServerPools, bucket, object string, body []byte, opts ObjectOptions) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := z.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), opts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullQuorumListV2Keys(t *testing.T, router http.Handler, bucket string) (int, []string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := nullQuorumRequest(t, router, http.MethodGet, getListObjectsV2URL("", bucket, "", "1000", "", "", ""))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
return rec.Code, nil
|
||||||
|
}
|
||||||
|
var list ListObjectsV2Response
|
||||||
|
if err := xml.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(list.Contents))
|
||||||
|
for _, object := range list.Contents {
|
||||||
|
keys = append(keys, object.Key)
|
||||||
|
}
|
||||||
|
return rec.Code, keys
|
||||||
|
}
|
||||||
|
|
||||||
func TestListObjectsSingleNullQuorumHTTP(t *testing.T) {
|
func TestListObjectsSingleNullQuorumHTTP(t *testing.T) {
|
||||||
z, bucket, router := nullQuorumBackend(t)
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
oldBody := bytes.Repeat([]byte{'a'}, 8192)
|
oldBody := bytes.Repeat([]byte{'a'}, 8192)
|
||||||
@@ -227,6 +283,400 @@ func TestListObjectsSingleNullQuorumHTTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListObjectsFallsBackToReadableMetadata(t *testing.T) {
|
||||||
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
disks, switchDisks := installListingDiskSwitch(set)
|
||||||
|
const object = "object"
|
||||||
|
oldBody := bytes.Repeat([]byte{'a'}, 8192)
|
||||||
|
newBody := bytes.Repeat([]byte{'b'}, 8192)
|
||||||
|
oldTime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
|
||||||
|
if _, err := z.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, bytes.NewReader(oldBody), int64(len(oldBody)), "", ""), ObjectOptions{MTime: oldTime}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldMeta := make([][]byte, len(disks))
|
||||||
|
for i, disk := range disks {
|
||||||
|
oldMeta[i] = mustReadNullQuorumMeta(t, disk, bucket, object)
|
||||||
|
}
|
||||||
|
if _, err := z.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, bytes.NewReader(newBody), int64(len(newBody)), "", ""), ObjectOptions{MTime: oldTime.Add(time.Minute)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The eight readable walk streams are split 3/5 across two generations;
|
||||||
|
// the other eight streams fail. Neither generation is proven readable by
|
||||||
|
// the listing inputs, so returning HTTP 200 would silently omit the key.
|
||||||
|
for i := range 3 {
|
||||||
|
if err := disks[i].WriteAll(t.Context(), bucket, object+"/"+xlStorageFormatFile, oldMeta[i]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrapped := make([]StorageAPI, len(disks))
|
||||||
|
for i, disk := range disks {
|
||||||
|
wrapped[i] = disk
|
||||||
|
if i >= 8 {
|
||||||
|
wrapped[i] = &failedMetadataWalkDisk{StorageAPI: disk}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switchDisks(wrapped)
|
||||||
|
|
||||||
|
rec := nullQuorumRequest(t, router, http.MethodGet, getListObjectsV2URL("", bucket, "", "1000", "", "", ""))
|
||||||
|
var list ListObjectsV2Response
|
||||||
|
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &list) != nil {
|
||||||
|
t.Fatalf("LIST failed to resolve readable metadata: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if list.KeyCount != 1 || len(list.Contents) != 1 || list.Contents[0].Key != object {
|
||||||
|
t.Fatalf("LIST omitted the readable key: %+v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The object itself still has a read quorum outside the failed walk
|
||||||
|
// streams, demonstrating why the LIST must report uncertainty, not absence.
|
||||||
|
get := nullQuorumRequest(t, router, http.MethodGet, getGetObjectURL("", bucket, object))
|
||||||
|
if get.Code != http.StatusOK || !bytes.Equal(get.Body.Bytes(), newBody) {
|
||||||
|
t.Fatalf("GET lost the readable generation: %d %q", get.Code, get.Body.String())
|
||||||
|
}
|
||||||
|
// Walk uses a separate producer but must share the same latest-only pool
|
||||||
|
// recovery. Versioned walks retain their existing no-fallback behavior.
|
||||||
|
for _, latestOnly := range []bool{false, true} {
|
||||||
|
out := make(chan itemOrErr[ObjectInfo], 10)
|
||||||
|
if err := z.Walk(t.Context(), bucket, "", out, WalkOptions{LatestOnly: latestOnly, AskDisks: "strict"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var objects []ObjectInfo
|
||||||
|
for result := range out {
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatal(result.Err)
|
||||||
|
}
|
||||||
|
objects = append(objects, result.Item)
|
||||||
|
}
|
||||||
|
if latestOnly {
|
||||||
|
if len(objects) != 1 || objects[0].Name != object || !objects[0].ModTime.Equal(oldTime.Add(time.Minute)) {
|
||||||
|
t.Fatalf("Walk latest recovery: %+v", objects)
|
||||||
|
}
|
||||||
|
} else if len(objects) != 0 {
|
||||||
|
t.Fatalf("versioned Walk unexpectedly enabled recovery: %+v", objects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failedDisks := slices.Clone(wrapped)
|
||||||
|
for i, disk := range failedDisks {
|
||||||
|
failedDisks[i] = &listingReadFailDisk{StorageAPI: disk, object: object, reads: &atomic.Int64{}}
|
||||||
|
}
|
||||||
|
switchDisks(failedDisks)
|
||||||
|
out := make(chan itemOrErr[ObjectInfo], 10)
|
||||||
|
if err := z.Walk(t.Context(), bucket, "", out, WalkOptions{LatestOnly: true, AskDisks: "strict"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
failed := false
|
||||||
|
for result := range out {
|
||||||
|
if result.Err == nil {
|
||||||
|
t.Fatalf("failed Walk yielded object: %+v", result.Item)
|
||||||
|
}
|
||||||
|
if isErrReadQuorum(result.Err) {
|
||||||
|
failed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !failed {
|
||||||
|
t.Fatal("failed Walk recovery did not report quorum error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fallback must read under the object read lock. A lock-free read of an
|
||||||
|
// object being overwritten stably reports a live object as absent, which is
|
||||||
|
// the omission this repair exists to remove. Hold the write lock and assert
|
||||||
|
// the listing waits for it rather than resolving straight through.
|
||||||
|
func TestListFallbackTakesObjectReadLock(t *testing.T) {
|
||||||
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
disks := set.getDisks()
|
||||||
|
const object = "object"
|
||||||
|
oldBody, newBody := bytes.Repeat([]byte{'a'}, 8192), bytes.Repeat([]byte{'b'}, 8192)
|
||||||
|
oldTime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
|
||||||
|
if _, err := z.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, bytes.NewReader(oldBody), int64(len(oldBody)), "", ""), ObjectOptions{MTime: oldTime}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldMeta := make([][]byte, len(disks))
|
||||||
|
for i, disk := range disks {
|
||||||
|
oldMeta[i] = mustReadNullQuorumMeta(t, disk, bucket, object)
|
||||||
|
}
|
||||||
|
if _, err := z.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, bytes.NewReader(newBody), int64(len(newBody)), "", ""), ObjectOptions{MTime: oldTime.Add(time.Minute)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Split the readable walk streams so the entry cannot be resolved from
|
||||||
|
// the snapshots and the fallback has to run.
|
||||||
|
for i := range 3 {
|
||||||
|
if err := disks[i].WriteAll(t.Context(), bucket, object+"/"+xlStorageFormatFile, oldMeta[i]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrapped := make([]StorageAPI, len(disks))
|
||||||
|
for i, disk := range disks {
|
||||||
|
wrapped[i] = disk
|
||||||
|
if i >= 8 {
|
||||||
|
wrapped[i] = &failedMetadataWalkDisk{StorageAPI: disk}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set.getDisks = func() []StorageAPI { return wrapped }
|
||||||
|
|
||||||
|
lock := set.NewNSLock(bucket, object)
|
||||||
|
lkctx, err := lock.GetLock(t.Context(), globalOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
locked := true
|
||||||
|
defer func() {
|
||||||
|
if locked {
|
||||||
|
lock.Unlock(lkctx)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
target := getListObjectsV2URL("", bucket, "", "1000", "", "", "")
|
||||||
|
req, err := newTestSignedRequestV4(http.MethodGet, target, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
}()
|
||||||
|
waitForListingReadLock(t, set.nsMutex, bucket, object, done)
|
||||||
|
lock.Unlock(lkctx)
|
||||||
|
locked = false
|
||||||
|
<-done
|
||||||
|
|
||||||
|
var list ListObjectsV2Response
|
||||||
|
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &list) != nil {
|
||||||
|
t.Fatalf("LIST failed after the lock was released: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if list.KeyCount != 1 || len(list.Contents) != 1 || list.Contents[0].Key != object {
|
||||||
|
t.Fatalf("LIST omitted the readable key: %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A truncated xl.meta is what a concurrent overwrite looks like on one drive,
|
||||||
|
// and the walker skips that entry. Skipping is safe and deliberate: the key is
|
||||||
|
// only omitted when no generation reaches quorum, which listPath now resolves
|
||||||
|
// against the read path. Pin that, so the walker is not "hardened" into
|
||||||
|
// failing a whole listing over a minority of unreadable drives.
|
||||||
|
func TestListObjectsSurvivesTruncatedMetadataMinority(t *testing.T) {
|
||||||
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
disks := set.getDisks()
|
||||||
|
const object = "object"
|
||||||
|
body := bytes.Repeat([]byte{'a'}, 8192)
|
||||||
|
|
||||||
|
if _, err := z.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate xl.meta on a minority of drives, as an overwrite in flight does.
|
||||||
|
for i := range 4 {
|
||||||
|
meta := mustReadNullQuorumMeta(t, disks[i], bucket, object)
|
||||||
|
if err := disks[i].WriteAll(t.Context(), bucket, object+"/"+xlStorageFormatFile, meta[:len(meta)/2]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := nullQuorumRequest(t, router, http.MethodGet, getListObjectsV2URL("", bucket, "", "1000", "", "", ""))
|
||||||
|
var list ListObjectsV2Response
|
||||||
|
if rec.Code != http.StatusOK || xml.Unmarshal(rec.Body.Bytes(), &list) != nil {
|
||||||
|
t.Fatalf("a truncated minority failed the listing: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if list.KeyCount != 1 || len(list.Contents) != 1 || list.Contents[0].Key != object {
|
||||||
|
t.Fatalf("a truncated minority omitted the key: %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListStaleMinorityIgnored(t *testing.T) {
|
||||||
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
|
disks := z.serverPools[0].sets[0].getDisks()
|
||||||
|
body := bytes.Repeat([]byte{'a'}, 8192)
|
||||||
|
nullQuorumPut(t, z, bucket, "gone", body, ObjectOptions{})
|
||||||
|
nullQuorumPut(t, z, bucket, "keep", body, ObjectOptions{})
|
||||||
|
for _, disk := range disks[4:] {
|
||||||
|
if err := disk.Delete(t.Context(), bucket, "gone", DeleteOptions{Recursive: true, Immediate: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if code, keys := nullQuorumListV2Keys(t, router, bucket); code != http.StatusOK || !slices.Equal(keys, []string{"keep"}) {
|
||||||
|
t.Errorf("ListObjectsV2: got %d %v; want 200 [keep]", code, keys)
|
||||||
|
}
|
||||||
|
results := make(chan itemOrErr[ObjectInfo], 16)
|
||||||
|
if err := z.Walk(t.Context(), bucket, "", results, WalkOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for result := range results {
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatal(result.Err)
|
||||||
|
}
|
||||||
|
names = append(names, result.Item.Name)
|
||||||
|
}
|
||||||
|
if !slices.Equal(names, []string{"keep"}) {
|
||||||
|
t.Errorf("Walk: got %v; want [keep]", names)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := nullQuorumRequest(t, router, http.MethodGet, getListObjectVersionsURL("", bucket, "", "1000", ""))
|
||||||
|
var list struct {
|
||||||
|
Versions []struct {
|
||||||
|
Key string `xml:"Key"`
|
||||||
|
} `xml:"Version"`
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("ListObjectVersions: got %d; want 200", rec.Code)
|
||||||
|
} else if err := xml.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if len(list.Versions) != 1 || list.Versions[0].Key != "keep" {
|
||||||
|
t.Errorf("ListObjectVersions: got %+v; want [keep]", list.Versions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := z.DeleteObject(t.Context(), bucket, "keep", ObjectOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := z.DeleteBucket(t.Context(), bucket, DeleteBucketOptions{}); err != nil {
|
||||||
|
t.Errorf("DeleteBucket with only a stale minority: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIAMListingIgnoresStaleMinority(t *testing.T) {
|
||||||
|
z, _, _ := nullQuorumBackend(t)
|
||||||
|
body := []byte(`{"version":1}`)
|
||||||
|
alive := iamConfigUsersPrefix + "alive/identity.json"
|
||||||
|
ghost := iamConfigUsersPrefix + "ghost/identity.json"
|
||||||
|
nullQuorumPut(t, z, minioMetaBucket, alive, body, ObjectOptions{})
|
||||||
|
nullQuorumPut(t, z, minioMetaBucket, ghost, body, ObjectOptions{})
|
||||||
|
for _, disk := range z.serverPools[0].getHashedSet(ghost).getDisks()[4:] {
|
||||||
|
if err := disk.Delete(t.Context(), minioMetaBucket, ghost, DeleteOptions{Recursive: true, Immediate: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := newIAMObjectStore(z, MinIOUsersSysType).listAllIAMConfigItems(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IAM listing failed on a stale minority (retriable=%v): %v", configRetriableErrors(err), err)
|
||||||
|
}
|
||||||
|
if got := items["users/"]; !slices.Equal(got, []string{"alive/identity.json"}) {
|
||||||
|
t.Errorf("IAM users: got %v; want [alive/identity.json]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSplitDirectoryObject(t *testing.T) {
|
||||||
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
disks := set.getDisks()
|
||||||
|
const object = "dir/"
|
||||||
|
encoded := encodeDirObject(object)
|
||||||
|
mtime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
|
||||||
|
nullQuorumPut(t, z, bucket, object, nil, ObjectOptions{MTime: mtime})
|
||||||
|
old := make([][]byte, 3)
|
||||||
|
for i := range old {
|
||||||
|
old[i] = mustReadNullQuorumMeta(t, disks[i], bucket, encoded)
|
||||||
|
}
|
||||||
|
nullQuorumPut(t, z, bucket, object, nil, ObjectOptions{MTime: mtime.Add(time.Minute)})
|
||||||
|
for i := range old {
|
||||||
|
if err := disks[i].WriteAll(t.Context(), bucket, encoded+"/"+xlStorageFormatFile, old[i]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walkers := slices.Clone(disks)
|
||||||
|
for i := 8; i < len(walkers); i++ {
|
||||||
|
walkers[i] = &failedMetadataWalkDisk{StorageAPI: disks[i]}
|
||||||
|
}
|
||||||
|
set.getDisks = func() []StorageAPI { return walkers }
|
||||||
|
if code, keys := nullQuorumListV2Keys(t, router, bucket); code != http.StatusOK || !slices.Equal(keys, []string{object}) {
|
||||||
|
t.Errorf("ListObjectsV2: got %d %v; want 200 [%s]", code, keys, object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListFallbackConfirmsCurrentAbsence(t *testing.T) {
|
||||||
|
z, bucket, _ := nullQuorumBackend(t)
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
disks := set.getDisks()
|
||||||
|
const object = "object"
|
||||||
|
body := bytes.Repeat([]byte{'a'}, 8192)
|
||||||
|
mtime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
|
||||||
|
nullQuorumPut(t, z, bucket, object, body, ObjectOptions{MTime: mtime})
|
||||||
|
old := make([][]byte, len(disks))
|
||||||
|
for i := range disks {
|
||||||
|
old[i] = mustReadNullQuorumMeta(t, disks[i], bucket, object)
|
||||||
|
}
|
||||||
|
nullQuorumPut(t, z, bucket, object, body, ObjectOptions{MTime: mtime.Add(time.Minute)})
|
||||||
|
newer := make([][]byte, len(disks))
|
||||||
|
for i := range disks {
|
||||||
|
newer[i] = mustReadNullQuorumMeta(t, disks[i], bucket, object)
|
||||||
|
if err := disks[i].Delete(t.Context(), bucket, object, DeleteOptions{Recursive: true, Immediate: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make(metaCacheEntries, len(disks))
|
||||||
|
errs := make([]error, len(disks))
|
||||||
|
for i := range 3 {
|
||||||
|
entries[i] = metaCacheEntry{name: object, metadata: old[i]}
|
||||||
|
}
|
||||||
|
for i := 3; i < 8; i++ {
|
||||||
|
entries[i] = metaCacheEntry{name: object, metadata: newer[i]}
|
||||||
|
}
|
||||||
|
for i := 8; i < len(errs); i++ {
|
||||||
|
errs[i] = errDiskNotFound
|
||||||
|
}
|
||||||
|
resolver := metadataResolutionParams{dirQuorum: 8, objQuorum: 8, bucket: bucket, requestedVersions: 1}
|
||||||
|
entry, err := z.resolveListingGroup(t.Context(), bucket, []*metaCacheEntry{set.resolveListEntry(entries, errs, &resolver)})
|
||||||
|
if entry != nil || err != nil {
|
||||||
|
t.Fatalf("strict current absence: got entry=%v err=%v; want no entry or error", entry, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// G0 was written 8+8 while a node was away. G1 was later written 12+4 but
|
||||||
|
// missed drive 0, which still holds G0. Then a node holding four G1 drives
|
||||||
|
// goes away: eleven G1 and one G0 remain online. G1 exists but lacks one of
|
||||||
|
// its twelve data shards until the node returns, so HEAD must not report 404
|
||||||
|
// and LIST must still show the key.
|
||||||
|
func TestListCrossParityMinorityStaysVisible(t *testing.T) {
|
||||||
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
disks, switchDisks := installListingDiskSwitch(set)
|
||||||
|
const object = "object"
|
||||||
|
mtime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
|
||||||
|
nullQuorumPut(t, z, bucket, object, bytes.Repeat([]byte{'a'}, 256<<10), ObjectOptions{MTime: mtime, MaxParity: true})
|
||||||
|
g0 := mustReadNullQuorumMeta(t, disks[0], bucket, object)
|
||||||
|
var x xlMetaV2
|
||||||
|
if err := x.LoadOrConvert(g0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if h := x.versions[0].header; h.EcM != 8 || h.EcN != 8 {
|
||||||
|
t.Fatalf("G0 is %d+%d, want 8+8", h.EcM, h.EcN)
|
||||||
|
}
|
||||||
|
g1Body := bytes.Repeat([]byte{'b'}, 256<<10)
|
||||||
|
nullQuorumPut(t, z, bucket, object, g1Body, ObjectOptions{MTime: mtime.Add(time.Minute)})
|
||||||
|
if err := disks[0].WriteAll(t.Context(), bucket, object+"/"+xlStorageFormatFile, g0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
online := slices.Clone(disks)
|
||||||
|
for i := 12; i < len(online); i++ {
|
||||||
|
online[i] = &offlineNullQuorumDisk{StorageAPI: disks[i]}
|
||||||
|
}
|
||||||
|
switchDisks(online)
|
||||||
|
if code := nullQuorumRequest(t, router, http.MethodHead, getHeadObjectURL("", bucket, object)).Code; code == http.StatusNotFound {
|
||||||
|
t.Errorf("HEAD with a node away: 404 for an existing object")
|
||||||
|
}
|
||||||
|
if code, keys := nullQuorumListV2Keys(t, router, bucket); code != http.StatusOK || !slices.Equal(keys, []string{object}) {
|
||||||
|
t.Errorf("ListObjectsV2 with a node away: got %d %v; want 200 [%s]", code, keys, object)
|
||||||
|
}
|
||||||
|
|
||||||
|
switchDisks(disks)
|
||||||
|
if get := nullQuorumRequest(t, router, http.MethodGet, getGetObjectURL("", bucket, object)); get.Code != http.StatusOK || !bytes.Equal(get.Body.Bytes(), g1Body) {
|
||||||
|
t.Fatalf("GET after the node returns: %d", get.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSingleNullQuorumReadAndConsumers(t *testing.T) {
|
func TestSingleNullQuorumReadAndConsumers(t *testing.T) {
|
||||||
z, bucket, router := nullQuorumBackend(t)
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
set := z.serverPools[0].sets[0]
|
set := z.serverPools[0].sets[0]
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
// 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"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/minio/internal/grid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Read real cache objects and use an actual peer RPC, counting only the block
|
||||||
|
// under test. Gates control publication, never replace quorum or EOF decisions.
|
||||||
|
type listingCachePartsProbe struct {
|
||||||
|
state atomic.Pointer[metacache]
|
||||||
|
rpcFails atomic.Bool
|
||||||
|
reads atomic.Int64
|
||||||
|
mu sync.Mutex
|
||||||
|
probes []int
|
||||||
|
checks []time.Time
|
||||||
|
onProbe func(int) bool
|
||||||
|
onCheck func(int)
|
||||||
|
target string
|
||||||
|
readErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *listingCachePartsProbe) setState(o listPathOptions, failed bool) {
|
||||||
|
m := o.newMetacache()
|
||||||
|
m.lastUpdate = time.Now()
|
||||||
|
if failed {
|
||||||
|
m.status, m.error = scanStateError, "cache producer failed"
|
||||||
|
} else {
|
||||||
|
m.status = scanStateStarted
|
||||||
|
}
|
||||||
|
p.state.Store(&m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *listingCachePartsProbe) snapshot() ([]int, []time.Time) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return slices.Clone(p.probes), slices.Clone(p.checks)
|
||||||
|
}
|
||||||
|
|
||||||
|
type listingCachePartsDisk struct {
|
||||||
|
StorageAPI
|
||||||
|
index int
|
||||||
|
probe *listingCachePartsProbe
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *listingCachePartsDisk) ReadXL(ctx context.Context, bucket, object string, readData bool) (RawFileInfo, error) {
|
||||||
|
if bucket == minioMetaBucket && object == d.probe.target {
|
||||||
|
d.probe.reads.Add(1)
|
||||||
|
if d.probe.readErr != nil {
|
||||||
|
return RawFileInfo{}, d.probe.readErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d.StorageAPI.ReadXL(ctx, bucket, object, readData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *listingCachePartsDisk) ReadVersion(ctx context.Context, origVolume, volume, path, versionID string, opts ReadOptions) (FileInfo, error) {
|
||||||
|
if volume == minioMetaBucket && path == d.probe.target {
|
||||||
|
d.probe.mu.Lock()
|
||||||
|
d.probe.probes = append(d.probe.probes, d.index)
|
||||||
|
d.probe.mu.Unlock()
|
||||||
|
if d.probe.onProbe != nil && d.probe.onProbe(d.index) {
|
||||||
|
return FileInfo{}, errFileNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d.StorageAPI.ReadVersion(ctx, origVolume, volume, path, versionID, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listingCachePartsBackend(t *testing.T, part int) (*erasureObjects, listPathOptions, *listingCachePartsProbe, func([]StorageAPI)) {
|
||||||
|
t.Helper()
|
||||||
|
z, bucket, _ := nullQuorumBackend(t)
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
o := listPathOptions{ID: mustGetUUID(), Bucket: bucket, Limit: 100, Recursive: true, IncludeDirectories: true}
|
||||||
|
p := &listingCachePartsProbe{target: o.objectPath(part)}
|
||||||
|
p.setState(o, false)
|
||||||
|
disks, switchDisks := installListingDiskSwitch(set)
|
||||||
|
wrapped := slices.Clone(disks)
|
||||||
|
for i, disk := range disks {
|
||||||
|
wrapped[i] = &listingCachePartsDisk{StorageAPI: disk, index: i, probe: p}
|
||||||
|
}
|
||||||
|
switchDisks(wrapped)
|
||||||
|
|
||||||
|
tg, err := grid.SetupTestGrid(2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
previous := globalNotificationSys
|
||||||
|
t.Cleanup(func() {
|
||||||
|
globalNotificationSys = previous
|
||||||
|
tg.Cleanup()
|
||||||
|
})
|
||||||
|
if err := getMetacacheListingRPC.Register(tg.Managers[1], func(got *listPathOptions) (*metacache, *grid.RemoteErr) {
|
||||||
|
if got.ID != o.ID || got.Create {
|
||||||
|
return nil, grid.NewRemoteErr(errors.New("invalid cache state lookup"))
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.checks = append(p.checks, time.Now())
|
||||||
|
n := len(p.checks)
|
||||||
|
p.mu.Unlock()
|
||||||
|
if p.onCheck != nil {
|
||||||
|
p.onCheck(n)
|
||||||
|
}
|
||||||
|
if p.rpcFails.Load() {
|
||||||
|
return nil, grid.NewRemoteErr(errors.New("cache state RPC failed"))
|
||||||
|
}
|
||||||
|
m := *p.state.Load()
|
||||||
|
return &m, nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client := &peerRESTClient{gridConn: func() *grid.Connection { return tg.Managers[0].Connection(tg.Hosts[1]) }}
|
||||||
|
globalNotificationSys = &NotificationSys{peerClients: []*peerRESTClient{client}, allPeerClients: []*peerRESTClient{client}}
|
||||||
|
return set, o, p, switchDisks
|
||||||
|
}
|
||||||
|
|
||||||
|
func putListingCachePart(t *testing.T, set *erasureObjects, o listPathOptions, part int, name string, eos bool) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := newMetacacheWriter(&buf, 0)
|
||||||
|
if err := w.write(metaCacheEntry{name: name}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block := metacacheBlock{n: part, First: name, Last: name, EOS: eos}
|
||||||
|
_, err := set.putMetacacheObject(t.Context(), o.objectPath(part), mustGetPutObjReader(t, bytes.NewReader(buf.Bytes()), int64(buf.Len()), "", ""), ObjectOptions{UserDefined: block.headerKV()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMetadataPartsHealthyNoStateRPC(t *testing.T) {
|
||||||
|
set, o, p, _ := listingCachePartsBackend(t, 1)
|
||||||
|
putListingCachePart(t, set, o, 0, "a/", false)
|
||||||
|
putListingCachePart(t, set, o, 1, "b/", true)
|
||||||
|
entries, err := set.streamMetadataParts(t.Context(), o)
|
||||||
|
if !errors.Is(err, io.EOF) || entries.len() != 2 || entries.o[0].name != "a/" || entries.o[1].name != "b/" {
|
||||||
|
t.Fatalf("healthy two-block read: entries=%v err=%v", entries.o, err)
|
||||||
|
}
|
||||||
|
probes, checks := p.snapshot()
|
||||||
|
if len(probes) != 0 || len(checks) != 0 {
|
||||||
|
t.Fatalf("healthy read added probes=%v state RPCs=%d", probes, len(checks))
|
||||||
|
}
|
||||||
|
t.Logf("healthy persisted blocks: state RPCs=0 retry probes=0 target ReadXL=%d", p.reads.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMetadataPartsTerminalError(t *testing.T) {
|
||||||
|
for _, part := range []int{0, 1} {
|
||||||
|
t.Run(fmt.Sprintf("part=%d", part), func(t *testing.T) {
|
||||||
|
set, o, p, _ := listingCachePartsBackend(t, part)
|
||||||
|
if part > 0 {
|
||||||
|
putListingCachePart(t, set, o, 0, "a/", false)
|
||||||
|
}
|
||||||
|
p.setState(o, true)
|
||||||
|
start := time.Now()
|
||||||
|
entries, err := set.streamMetadataParts(t.Context(), o)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if err == nil || errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "cache producer failed") || entries.len() != part {
|
||||||
|
t.Fatalf("failed cache became successful: entries=%v err=%v", entries.o, err)
|
||||||
|
}
|
||||||
|
probes, checks := p.snapshot()
|
||||||
|
if len(checks) != 1 || len(probes) != 0 || elapsed > time.Second {
|
||||||
|
t.Fatalf("terminal error waited for disk retries: elapsed=%v state RPCs=%d probes=%v", elapsed, len(checks), probes)
|
||||||
|
}
|
||||||
|
t.Logf("terminal part=%d elapsed=%v state RPCs=%d probes=%d target ReadXL=%d", part, elapsed, len(checks), len(probes), p.reads.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMetadataPartsLateTerminalError(t *testing.T) {
|
||||||
|
set, o, p, _ := listingCachePartsBackend(t, 1)
|
||||||
|
putListingCachePart(t, set, o, 0, "a/", false)
|
||||||
|
p.onProbe = func(int) bool {
|
||||||
|
p.setState(o, true)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
entries, err := set.streamMetadataParts(t.Context(), o)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
probes, checks := p.snapshot()
|
||||||
|
if err == nil || errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "cache producer failed") || entries.len() != 1 {
|
||||||
|
t.Fatalf("late producer error lost: entries=%v err=%v", entries.o, err)
|
||||||
|
}
|
||||||
|
if elapsed > time.Second || len(probes) != 2 || !slices.Equal(probes, []int{0, 1}) || len(checks) != 2 {
|
||||||
|
t.Fatalf("late error retry work: elapsed=%v probes=%v state RPCs=%d", elapsed, probes, len(checks))
|
||||||
|
}
|
||||||
|
if checks[1].Sub(checks[0]) < 250*time.Millisecond || p.reads.Load() > 16 {
|
||||||
|
t.Fatalf("unbounded retry: state interval=%v target ReadXL=%d", checks[1].Sub(checks[0]), p.reads.Load())
|
||||||
|
}
|
||||||
|
t.Logf("late terminal elapsed=%v state RPCs=%d probes=%v target ReadXL=%d", elapsed, len(checks), probes, p.reads.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
type listingCachePartsResult struct {
|
||||||
|
entries metaCacheEntriesSorted
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func startListingCacheParts(t *testing.T, set *erasureObjects, o listPathOptions) (<-chan listingCachePartsResult, context.CancelFunc) {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
done := make(chan listingCachePartsResult, 1)
|
||||||
|
joined := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(joined)
|
||||||
|
entries, err := set.streamMetadataParts(ctx, o)
|
||||||
|
done <- listingCachePartsResult{entries: entries, err: err}
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-joined:
|
||||||
|
case <-time.After(8 * time.Second):
|
||||||
|
t.Error("cache continuation worker did not finish")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return done, cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveListingCacheParts(t *testing.T, done <-chan listingCachePartsResult) listingCachePartsResult {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case result := <-done:
|
||||||
|
return result
|
||||||
|
case <-time.After(8 * time.Second):
|
||||||
|
t.Fatal("cache continuation did not finish")
|
||||||
|
return listingCachePartsResult{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMetadataPartsGeneratingThenPublished(t *testing.T) {
|
||||||
|
set, o, p, _ := listingCachePartsBackend(t, 1)
|
||||||
|
putListingCachePart(t, set, o, 0, "a/", false)
|
||||||
|
checked := make(chan struct{})
|
||||||
|
p.onCheck = func(n int) {
|
||||||
|
if n == 1 {
|
||||||
|
close(checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The first selected replica is absent even after the producer publishes
|
||||||
|
// the block elsewhere. Rotation must reach a readable replica.
|
||||||
|
p.onProbe = func(index int) bool { return index == 0 }
|
||||||
|
done, _ := startListingCacheParts(t, set, o)
|
||||||
|
select {
|
||||||
|
case <-checked:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("missing block did not check producer state")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case result := <-done:
|
||||||
|
t.Fatalf("still-generating cache finished early: %+v", result)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
putListingCachePart(t, set, o, 1, "b/", true)
|
||||||
|
result := receiveListingCacheParts(t, done)
|
||||||
|
if !errors.Is(result.err, io.EOF) || result.entries.len() != 2 || result.entries.o[0].name != "a/" || result.entries.o[1].name != "b/" {
|
||||||
|
t.Fatalf("published second block lost: entries=%v err=%v", result.entries.o, result.err)
|
||||||
|
}
|
||||||
|
probes, checks := p.snapshot()
|
||||||
|
if len(probes) < 2 || probes[0] != 0 || probes[1] != 1 {
|
||||||
|
t.Fatalf("retry did not rotate: probes=%v checks=%d", probes, len(checks))
|
||||||
|
}
|
||||||
|
t.Logf("later block recovered: state RPCs=%d probes=%v target ReadXL=%d", len(checks), probes, p.reads.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMetadataPartsRPCFailure(t *testing.T) {
|
||||||
|
set, o, p, _ := listingCachePartsBackend(t, 1)
|
||||||
|
putListingCachePart(t, set, o, 0, "a/", false)
|
||||||
|
p.rpcFails.Store(true)
|
||||||
|
entries, err := set.streamMetadataParts(t.Context(), o)
|
||||||
|
if err == nil || errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "cache state RPC failed") || entries.len() != 1 {
|
||||||
|
t.Fatalf("RPC failure became successful EOF: entries=%v err=%v", entries.o, err)
|
||||||
|
}
|
||||||
|
probes, checks := p.snapshot()
|
||||||
|
if len(checks) != 1 || len(probes) != 0 {
|
||||||
|
t.Fatalf("state RPC failure continued retrying: probes=%v checks=%d", probes, len(checks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMetadataPartsMissingIndex(t *testing.T) {
|
||||||
|
set, o, p, _ := listingCachePartsBackend(t, 0)
|
||||||
|
putListingCachePart(t, set, o, 0, "a/", false)
|
||||||
|
o.Marker = "b/"
|
||||||
|
p.setState(o, true)
|
||||||
|
entries, err := set.streamMetadataParts(t.Context(), o)
|
||||||
|
if err == nil || errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "cache producer failed") || entries.len() != 0 {
|
||||||
|
t.Fatalf("unpublished index became successful EOF: entries=%v err=%v", entries.o, err)
|
||||||
|
}
|
||||||
|
probes, checks := p.snapshot()
|
||||||
|
if len(checks) != 1 || len(probes) != 0 {
|
||||||
|
t.Fatalf("missing index delayed terminal state: probes=%v checks=%d", probes, len(checks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMetadataPartsOtherErrorRetriesOnce(t *testing.T) {
|
||||||
|
set, o, p, _ := listingCachePartsBackend(t, 0)
|
||||||
|
p.readErr = errVolumeNotFound
|
||||||
|
entries, err := set.streamMetadataParts(t.Context(), o)
|
||||||
|
if err == nil || errors.Is(err, io.EOF) || !strings.Contains(err.Error(), "reading first part metadata:") || entries.len() != 0 {
|
||||||
|
t.Fatalf("unexpected metadata error result: entries=%v err=%v", entries.o, err)
|
||||||
|
}
|
||||||
|
probes, checks := p.snapshot()
|
||||||
|
if p.reads.Load() != 32 || len(probes) != 0 || len(checks) != 1 {
|
||||||
|
t.Fatalf("other metadata error did not get exactly one full retry: ReadXL=%d probes=%v checks=%d", p.reads.Load(), probes, len(checks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMetadataPartsMissingBlockWorkBound(t *testing.T) {
|
||||||
|
set, o, p, _ := listingCachePartsBackend(t, 0)
|
||||||
|
checked := make(chan struct{})
|
||||||
|
p.onCheck = func(n int) {
|
||||||
|
if n == 3 {
|
||||||
|
close(checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
done, cancel := startListingCacheParts(t, set, o)
|
||||||
|
select {
|
||||||
|
case <-checked:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("missing cache stopped checking producer state")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
result := receiveListingCacheParts(t, done)
|
||||||
|
probes, checks := p.snapshot()
|
||||||
|
if !errors.Is(result.err, context.Canceled) || result.entries.len() != 0 {
|
||||||
|
t.Fatalf("generating cache became successful EOF: %+v", result)
|
||||||
|
}
|
||||||
|
if len(probes) != 3 || !slices.Equal(probes, []int{0, 1, 2}) || p.reads.Load() != 16 || len(checks) != 3 {
|
||||||
|
t.Fatalf("missing block exceeded work bound: probes=%v target ReadXL=%d checks=%d", probes, p.reads.Load(), len(checks))
|
||||||
|
}
|
||||||
|
for i := 1; i < len(checks); i++ {
|
||||||
|
// Receive timestamps allow a small amount of peer scheduling jitter.
|
||||||
|
if checks[i].Sub(checks[i-1]) < 240*time.Millisecond {
|
||||||
|
t.Fatalf("cache state queried too often: %v", checks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("still missing for %v: state RPCs=%d probes=%v target ReadXL=%d", time.Since(start), len(checks), probes, p.reads.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMetadataPartsCancelRetryWait(t *testing.T) {
|
||||||
|
set, o, p, _ := listingCachePartsBackend(t, 1)
|
||||||
|
putListingCachePart(t, set, o, 0, "a/", false)
|
||||||
|
probed := make(chan struct{})
|
||||||
|
p.onProbe = func(index int) bool {
|
||||||
|
if index == 1 {
|
||||||
|
close(probed)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
done, cancel := startListingCacheParts(t, set, o)
|
||||||
|
select {
|
||||||
|
case <-probed:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("retry probe did not run")
|
||||||
|
}
|
||||||
|
// Let the probe return and enter the regular 250 ms wait before canceling.
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
start := time.Now()
|
||||||
|
cancel()
|
||||||
|
result := receiveListingCacheParts(t, done)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if !errors.Is(result.err, context.Canceled) || elapsed > 150*time.Millisecond {
|
||||||
|
t.Fatalf("retry wait ignored cancellation: elapsed=%v err=%v", elapsed, result.err)
|
||||||
|
}
|
||||||
|
t.Logf("cancel during retry wait: elapsed=%v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamMetadataPartsChangedDisks(t *testing.T) {
|
||||||
|
for _, empty := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("empty=%v", empty), func(t *testing.T) {
|
||||||
|
testStreamMetadataPartsChangedDisks(t, empty)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStreamMetadataPartsChangedDisks(t *testing.T, empty bool) {
|
||||||
|
set, o, p, switchDisks := listingCachePartsBackend(t, 1)
|
||||||
|
putListingCachePart(t, set, o, 0, "a/", false)
|
||||||
|
disks := set.getDisks()
|
||||||
|
probeDone, restore := make(chan struct{}), make(chan struct{})
|
||||||
|
p.onProbe = func(index int) bool {
|
||||||
|
if index == 0 {
|
||||||
|
// A later probe must safely skip an empty/offline disk snapshot.
|
||||||
|
if empty {
|
||||||
|
switchDisks([]StorageAPI{})
|
||||||
|
} else {
|
||||||
|
switchDisks([]StorageAPI{nil})
|
||||||
|
}
|
||||||
|
close(probeDone)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p.onCheck = func(n int) {
|
||||||
|
if n == 2 {
|
||||||
|
close(restore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done, _ := startListingCacheParts(t, set, o)
|
||||||
|
select {
|
||||||
|
case <-probeDone:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("initial retry probe did not run")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-restore:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("empty disk snapshot prevented cache state checks")
|
||||||
|
}
|
||||||
|
switchDisks(disks)
|
||||||
|
putListingCachePart(t, set, o, 1, "b/", true)
|
||||||
|
result := receiveListingCacheParts(t, done)
|
||||||
|
if !errors.Is(result.err, io.EOF) || result.entries.len() != 2 {
|
||||||
|
t.Fatalf("changed disks did not recover: entries=%v err=%v", result.entries.o, result.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+225
-13
@@ -191,19 +191,19 @@ func (z *erasureServerPools) listPath(ctx context.Context, o *listPathOptions) (
|
|||||||
return entries, err
|
return entries, err
|
||||||
}
|
}
|
||||||
entries.truncate(0)
|
entries.truncate(0)
|
||||||
go func() {
|
|
||||||
rpc := globalNotificationSys.restClientFromHash(pathJoin(o.Bucket, o.Prefix))
|
rpc := globalNotificationSys.restClientFromHash(pathJoin(o.Bucket, o.Prefix))
|
||||||
|
go func(o listPathOptions) {
|
||||||
if rpc != nil {
|
if rpc != nil {
|
||||||
ctx, cancel := context.WithTimeout(GlobalContext, 5*time.Second)
|
ctx, cancel := context.WithTimeout(GlobalContext, 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
c, err := rpc.GetMetacacheListing(ctx, *o)
|
c, err := rpc.GetMetacacheListing(ctx, o)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
c.error = "no longer used"
|
c.error = "no longer used"
|
||||||
c.status = scanStateError
|
c.status = scanStateError
|
||||||
rpc.UpdateMetacacheListing(ctx, *c)
|
rpc.UpdateMetacacheListing(ctx, *c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}(*o)
|
||||||
o.ID = ""
|
o.ID = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +264,176 @@ func (z *erasureServerPools) listPathShortcutEOF(ctx context.Context, bucket str
|
|||||||
return io.EOF
|
return io.EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listingRLock covers the lock domains used by normal same-key mutations.
|
||||||
|
// Multi-pool writes use the pools lock. In a single pool, DELETE/COPY use the
|
||||||
|
// first set while PUT and metadata updates use the destination set, so recovery
|
||||||
|
// takes both in that order. No writer routing or wire protocol changes here.
|
||||||
|
func (z *erasureServerPools) listingRLock(ctx context.Context, bucket, object string, source *erasureObjects) (LockContext, func(), error) {
|
||||||
|
firstSet := z.serverPools[0].sets[0]
|
||||||
|
first := z.NewNSLock(bucket, object)
|
||||||
|
firstCtx, err := first.GetRLock(ctx, globalListingOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return LockContext{}, nil, err
|
||||||
|
}
|
||||||
|
if !z.SinglePool() || source == firstSet || (!source.nsMutex.isDistErasure && source.nsMutex == firstSet.nsMutex) {
|
||||||
|
return firstCtx, func() { first.RUnlock(firstCtx) }, nil
|
||||||
|
}
|
||||||
|
// Shared nsMutex pointers do not prove shared distributed locker sets.
|
||||||
|
second := source.NewNSLock(bucket, object)
|
||||||
|
secondCtx, err := second.GetRLock(firstCtx.Context(), globalListingOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
first.RUnlock(firstCtx)
|
||||||
|
return LockContext{}, nil, err
|
||||||
|
}
|
||||||
|
return secondCtx, func() {
|
||||||
|
second.RUnlock(secondCtx)
|
||||||
|
first.RUnlock(firstCtx)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveListingGroup recovers a complete same-name group, never a private
|
||||||
|
// per-set contribution. A strictly absent source requires current reads from
|
||||||
|
// every pool, since another pool's earlier walk may predate a migration copy.
|
||||||
|
func (z *erasureServerPools) resolveListingGroup(ctx context.Context, bucket string, group []*metaCacheEntry) (*metaCacheEntry, error) {
|
||||||
|
var source *erasureObjects
|
||||||
|
for _, entry := range group {
|
||||||
|
if entry.listingSet != nil {
|
||||||
|
source = entry.listingSet
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if source == nil {
|
||||||
|
return nil, errInvalidArgument
|
||||||
|
}
|
||||||
|
// Recovery consumes this group. Return reusable walk buffers only after
|
||||||
|
// their contents have been copied into the newly encoded result.
|
||||||
|
defer func() {
|
||||||
|
for _, entry := range group {
|
||||||
|
if entry.reusable {
|
||||||
|
metaDataPoolPut(entry.metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
name := group[0].name
|
||||||
|
object := encodeDirObject(name)
|
||||||
|
if z.SinglePool() {
|
||||||
|
target := z.serverPools[0].getHashedSet(object)
|
||||||
|
for _, entry := range group {
|
||||||
|
if entry.listingSet != nil && entry.listingSet != target {
|
||||||
|
// Unexpected placement is not evidence of absence at the
|
||||||
|
// current write target. Do not extend recovery to arbitrary sets.
|
||||||
|
return nil, listingReadQuorumError(bucket, object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type readResult struct {
|
||||||
|
fi FileInfo
|
||||||
|
absent bool
|
||||||
|
}
|
||||||
|
reads := make(map[*erasureObjects]readResult)
|
||||||
|
currentOnly := false
|
||||||
|
var current *FileInfo
|
||||||
|
err := func() error {
|
||||||
|
lockCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
lkctx, unlock, err := z.listingRLock(lockCtx, bucket, object, source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
read := func(set *erasureObjects) (readResult, error) {
|
||||||
|
if result, ok := reads[set]; ok {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
fi, absent, err := set.readListingFileInfo(lkctx.Context(), bucket, object)
|
||||||
|
result := readResult{fi: fi, absent: absent}
|
||||||
|
if err == nil {
|
||||||
|
reads[set] = result
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
for _, entry := range group {
|
||||||
|
if entry.listingSet == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := read(entry.listingSet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
currentOnly = currentOnly || result.absent
|
||||||
|
}
|
||||||
|
if !currentOnly {
|
||||||
|
return lkctx.Context().Err()
|
||||||
|
}
|
||||||
|
// Migration publishes the replacement through the pooled write lock
|
||||||
|
// before source cleanup. While this read lock is held, new publication
|
||||||
|
// cannot pass these reads; any concurrent cleanup already has a copy.
|
||||||
|
// Unknown pools still fail rather than being counted as absent.
|
||||||
|
for _, pool := range z.serverPools {
|
||||||
|
result, err := read(pool.getHashedSet(object))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.absent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Match latest GET: newest ModTime, then lowest pool index.
|
||||||
|
if current == nil || result.fi.ModTime.After(current.ModTime) {
|
||||||
|
fi := result.fi
|
||||||
|
current = &fi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lkctx.Context().Err()
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// All FileInfo values above are owned decoded results. Encoding and merge
|
||||||
|
// allocation happen after RUnlock, as do all stream sends and cache RPCs.
|
||||||
|
encode := func(fi FileInfo) (*metaCacheEntry, error) {
|
||||||
|
xl := &xlMetaV2{}
|
||||||
|
if err := xl.AddVersion(fi); err != nil {
|
||||||
|
return nil, listingReadQuorumError(bucket, object)
|
||||||
|
}
|
||||||
|
entry := &metaCacheEntry{name: name, cached: xl, reusable: true}
|
||||||
|
var err error
|
||||||
|
entry.metadata, err = xl.AppendTo(metaDataPoolGet())
|
||||||
|
return entry, err
|
||||||
|
}
|
||||||
|
if currentOnly {
|
||||||
|
if current == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// Replace the whole group: no stale live copy or marker can override
|
||||||
|
// this current global decision.
|
||||||
|
return encode(*current)
|
||||||
|
}
|
||||||
|
versions := make([][]xlMetaV2ShallowVersion, 0, len(group))
|
||||||
|
for _, entry := range group {
|
||||||
|
if entry.listingSet != nil {
|
||||||
|
fi := reads[entry.listingSet].fi
|
||||||
|
xl := &xlMetaV2{}
|
||||||
|
if err := xl.AddVersion(fi); err != nil {
|
||||||
|
return nil, listingReadQuorumError(bucket, object)
|
||||||
|
}
|
||||||
|
versions = append(versions, xl.versions)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
xl, err := entry.xlmeta()
|
||||||
|
if err != nil {
|
||||||
|
return nil, listingReadQuorumError(bucket, object)
|
||||||
|
}
|
||||||
|
versions = append(versions, xl.versions)
|
||||||
|
}
|
||||||
|
// Preserve the existing cross-pool merge rule for recovered live metadata
|
||||||
|
// and delete markers when no source was strictly absent.
|
||||||
|
xl := &xlMetaV2{versions: mergeXLV2Versions(1, true, 0, versions...)}
|
||||||
|
// Do not retain shallow versions referencing reusable input buffers.
|
||||||
|
resolved := &metaCacheEntry{name: name, reusable: true}
|
||||||
|
resolved.metadata, err = xl.AppendTo(metaDataPoolGet())
|
||||||
|
return resolved, err
|
||||||
|
}
|
||||||
|
|
||||||
// listMerged will list across all sets and return a merged results stream.
|
// listMerged will list across all sets and return a merged results stream.
|
||||||
// The result channel is closed when no more results are expected.
|
// The result channel is closed when no more results are expected.
|
||||||
func (z *erasureServerPools) listMerged(ctx context.Context, o listPathOptions, results chan<- metaCacheEntry) error {
|
func (z *erasureServerPools) listMerged(ctx context.Context, o listPathOptions, results chan<- metaCacheEntry) error {
|
||||||
@@ -285,11 +455,14 @@ func (z *erasureServerPools) listMerged(ctx context.Context, o listPathOptions,
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
err := set.listPath(listCtx, o, innerResults)
|
err := set.listPath(listCtx, o, innerResults)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
allAtEOF = false
|
allAtEOF = false
|
||||||
}
|
}
|
||||||
errs[i] = err
|
errs[i] = err
|
||||||
|
mu.Unlock()
|
||||||
|
// The merger must observe the terminal error before treating
|
||||||
|
// this stream as exhausted and advancing to another pool.
|
||||||
|
xioutil.SafeClose(innerResults)
|
||||||
}(len(errs), set)
|
}(len(errs), set)
|
||||||
errs = append(errs, nil)
|
errs = append(errs, nil)
|
||||||
}
|
}
|
||||||
@@ -298,7 +471,18 @@ func (z *erasureServerPools) listMerged(ctx context.Context, o listPathOptions,
|
|||||||
|
|
||||||
// Gather results to a single channel.
|
// Gather results to a single channel.
|
||||||
// Quorum is one since we are merging across sets.
|
// Quorum is one since we are merging across sets.
|
||||||
err := mergeEntryChannels(ctx, inputs, results, 1)
|
err := mergeEntryChannelsWithResolver(ctx, inputs, results, 1, func(i int) error {
|
||||||
|
mu.Lock()
|
||||||
|
err := errs[i]
|
||||||
|
mu.Unlock()
|
||||||
|
// Keep the existing aggregate handling of absent paths and buckets.
|
||||||
|
if errors.Is(err, io.EOF) || isAllNotFound([]error{err}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}, func(group []*metaCacheEntry) (*metaCacheEntry, error) {
|
||||||
|
return z.resolveListingGroup(ctx, o.Bucket, group)
|
||||||
|
})
|
||||||
|
|
||||||
cancelList()
|
cancelList()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
@@ -415,13 +599,24 @@ func (z *erasureServerPools) listAndSave(ctx context.Context, o *listPathOptions
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Do listing...
|
// Publish the typed terminal result before either consumer observes EOF.
|
||||||
|
// mergeEntryChannels closes inCh before listMerged has inspected set errors.
|
||||||
|
listingDone := make(chan struct{})
|
||||||
|
var cacheErrorDone chan struct{}
|
||||||
|
var listingErr error
|
||||||
go func(o listPathOptions) {
|
go func(o listPathOptions) {
|
||||||
err := z.listMerged(listCtx, o, inCh)
|
listingErr = z.listMerged(listCtx, o, inCh)
|
||||||
if err != nil {
|
failed := listingErr != nil && !errors.Is(listingErr, io.EOF)
|
||||||
meta.setErr(err.Error())
|
if failed {
|
||||||
|
cacheErrorDone = make(chan struct{})
|
||||||
}
|
}
|
||||||
o.debugln("listAndSave: listing", o.ID, "finished with ", err)
|
close(listingDone)
|
||||||
|
if failed {
|
||||||
|
meta.setErr(listingErr.Error())
|
||||||
|
cancel()
|
||||||
|
close(cacheErrorDone)
|
||||||
|
}
|
||||||
|
o.debugln("listAndSave: listing", o.ID, "finished with ", listingErr)
|
||||||
}(*o)
|
}(*o)
|
||||||
|
|
||||||
// Keep track of when we return since we no longer have to send entries to output.
|
// Keep track of when we return since we no longer have to send entries to output.
|
||||||
@@ -451,16 +646,33 @@ func (z *erasureServerPools) listAndSave(ctx context.Context, o *listPathOptions
|
|||||||
entry.reusable = returned
|
entry.reusable = returned
|
||||||
saveCh <- entry
|
saveCh <- entry
|
||||||
}
|
}
|
||||||
|
// The typed scan result is enough to finish the HTTP response.
|
||||||
|
<-listingDone
|
||||||
if !returned {
|
if !returned {
|
||||||
xioutil.SafeClose(outCh)
|
xioutil.SafeClose(outCh)
|
||||||
}
|
}
|
||||||
|
// Closing saveCh writes an end-of-stream cache block. On failure,
|
||||||
|
// wait until the error is recorded and the writer is canceled.
|
||||||
|
if cacheErrorDone != nil {
|
||||||
|
<-cacheErrorDone
|
||||||
|
}
|
||||||
xioutil.SafeClose(saveCh)
|
xioutil.SafeClose(saveCh)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
entries, err = filteredResults()
|
entries, err = filteredResults()
|
||||||
if err == nil {
|
if err == nil || errors.Is(err, io.EOF) {
|
||||||
// Check if listing recorded an error.
|
// Full pages may return before the scan ends. A final page has
|
||||||
err = meta.getErr()
|
// crossed the completion barrier above and must preserve scan errors.
|
||||||
|
select {
|
||||||
|
case <-listingDone:
|
||||||
|
if listingErr != nil && !errors.Is(listingErr, io.EOF) {
|
||||||
|
return entries, listingErr
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if cacheErr := meta.getErr(); cacheErr != nil {
|
||||||
|
return entries, cacheErr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return entries, err
|
return entries, err
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-78
@@ -39,7 +39,6 @@ import (
|
|||||||
"github.com/minio/minio/internal/bucket/versioning"
|
"github.com/minio/minio/internal/bucket/versioning"
|
||||||
"github.com/minio/minio/internal/color"
|
"github.com/minio/minio/internal/color"
|
||||||
"github.com/minio/minio/internal/hash"
|
"github.com/minio/minio/internal/hash"
|
||||||
xioutil "github.com/minio/minio/internal/ioutil"
|
|
||||||
"github.com/pgsty/silo-pkg/v3/console"
|
"github.com/pgsty/silo-pkg/v3/console"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -423,63 +422,89 @@ func (er *erasureObjects) streamMetadataParts(ctx context.Context, o listPathOpt
|
|||||||
retryDelay = 50 * time.Millisecond
|
retryDelay = 50 * time.Millisecond
|
||||||
retryDelay250 = 250 * time.Millisecond
|
retryDelay250 = 250 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
var lastStateCheck time.Time
|
||||||
|
retryWait := func() error {
|
||||||
|
if contextCanceled(ctx) {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
// A failed producer cannot publish another block. Check on the first
|
||||||
|
// failure, then at most once per retry interval while it is still running.
|
||||||
|
if time.Since(lastStateCheck) >= retryDelay250 {
|
||||||
|
lastStateCheck = time.Now()
|
||||||
|
if err := o.checkMetacacheState(ctx, rpc); err != nil {
|
||||||
|
return fmt.Errorf("remote listing canceled: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retries++
|
||||||
|
delay := retryDelay250
|
||||||
|
if retries == 1 {
|
||||||
|
delay = retryDelay
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Probe one online disk per retry, rotating so a missing replica cannot
|
||||||
|
// pin the reader to one disk. A miss only delays the next probe: it never
|
||||||
|
// establishes absence or replaces the quorum read after a probe succeeds.
|
||||||
|
// This bounds missing-block I/O without sleeping once for every disk.
|
||||||
|
nextProbeDisk := 0
|
||||||
|
probePart := func(part int) bool {
|
||||||
|
disks := er.getDisks()
|
||||||
|
for range disks {
|
||||||
|
if contextCanceled(ctx) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
i := nextProbeDisk % len(disks)
|
||||||
|
nextProbeDisk = i + 1
|
||||||
|
disk := disks[i]
|
||||||
|
if disk == nil || !disk.IsOnline() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err := disk.ReadVersion(ctx, "", minioMetaBucket, o.objectPath(part), "", ReadOptions{})
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
retryOtherError := false
|
||||||
for {
|
for {
|
||||||
if contextCanceled(ctx) {
|
if contextCanceled(ctx) {
|
||||||
return entries, ctx.Err()
|
return entries, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If many failures, check the cache state.
|
|
||||||
if retries > 10 {
|
|
||||||
err := o.checkMetacacheState(ctx, rpc)
|
|
||||||
if err != nil {
|
|
||||||
return entries, fmt.Errorf("remote listing canceled: %w", err)
|
|
||||||
}
|
|
||||||
retries = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// All operations are performed without locks, so we must be careful and allow for failures.
|
// All operations are performed without locks, so we must be careful and allow for failures.
|
||||||
// Read metadata associated with the object from a disk.
|
if retries > 0 && !retryOtherError && !probePart(0) {
|
||||||
if retries > 0 {
|
if err := retryWait(); err != nil {
|
||||||
for _, disk := range er.getDisks() {
|
return entries, err
|
||||||
if disk == nil {
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !disk.IsOnline() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, err := disk.ReadVersion(ctx, "", minioMetaBucket,
|
|
||||||
o.objectPath(0), "", ReadOptions{})
|
|
||||||
if err != nil {
|
|
||||||
time.Sleep(retryDelay250)
|
|
||||||
retries++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
retryWait := func() {
|
|
||||||
retries++
|
|
||||||
if retries == 1 {
|
|
||||||
time.Sleep(retryDelay)
|
|
||||||
} else {
|
|
||||||
time.Sleep(retryDelay250)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Load first part metadata...
|
// Load first part metadata...
|
||||||
// Read metadata associated with the object from all disks.
|
// Read metadata associated with the object from all disks.
|
||||||
|
retryOtherError = false
|
||||||
fi, metaArr, onlineDisks, err := er.getObjectFileInfo(ctx, minioMetaBucket, o.objectPath(0), ObjectOptions{}, true)
|
fi, metaArr, onlineDisks, err := er.getObjectFileInfo(ctx, minioMetaBucket, o.objectPath(0), ObjectOptions{}, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch toObjectErr(err, minioMetaBucket, o.objectPath(0)).(type) {
|
switch toObjectErr(err, minioMetaBucket, o.objectPath(0)).(type) {
|
||||||
case ObjectNotFound, InsufficientReadQuorum:
|
case ObjectNotFound, InsufficientReadQuorum:
|
||||||
retryWait()
|
if err := retryWait(); err != nil {
|
||||||
|
return entries, err
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Allow one fast retry for other errors.
|
// Allow one fast retry for other errors.
|
||||||
if retries > 0 {
|
if retries > 0 {
|
||||||
return entries, fmt.Errorf("reading first part metadata: %v", err)
|
return entries, fmt.Errorf("reading first part metadata: %v", err)
|
||||||
}
|
}
|
||||||
retryWait()
|
retryOtherError = true
|
||||||
|
if err := retryWait(); err != nil {
|
||||||
|
return entries, err
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,14 +512,9 @@ func (er *erasureObjects) streamMetadataParts(ctx context.Context, o listPathOpt
|
|||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||||
if retries == 10 {
|
if err := retryWait(); err != nil {
|
||||||
err := o.checkMetacacheState(ctx, rpc)
|
return entries, err
|
||||||
if err != nil {
|
|
||||||
return entries, fmt.Errorf("remote listing canceled: %w", err)
|
|
||||||
}
|
}
|
||||||
retries = -1
|
|
||||||
}
|
|
||||||
retryWait()
|
|
||||||
continue
|
continue
|
||||||
case errors.Is(err, io.EOF):
|
case errors.Is(err, io.EOF):
|
||||||
return entries, io.EOF
|
return entries, io.EOF
|
||||||
@@ -508,39 +528,19 @@ func (er *erasureObjects) streamMetadataParts(ctx context.Context, o listPathOpt
|
|||||||
}
|
}
|
||||||
|
|
||||||
if partN != loadedPart {
|
if partN != loadedPart {
|
||||||
if retries > 10 {
|
if retries > 0 && !probePart(partN) {
|
||||||
err := o.checkMetacacheState(ctx, rpc)
|
if err := retryWait(); err != nil {
|
||||||
if err != nil {
|
return entries, err
|
||||||
return entries, fmt.Errorf("waiting for next part %d: %w", partN, err)
|
|
||||||
}
|
}
|
||||||
retries = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if retries > 0 {
|
|
||||||
// Load from one disk only
|
|
||||||
for _, disk := range er.getDisks() {
|
|
||||||
if disk == nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !disk.IsOnline() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, err := disk.ReadVersion(ctx, "", minioMetaBucket,
|
|
||||||
o.objectPath(partN), "", ReadOptions{})
|
|
||||||
if err != nil {
|
|
||||||
time.Sleep(retryDelay250)
|
|
||||||
retries++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load partN metadata...
|
// Load partN metadata...
|
||||||
fi, metaArr, onlineDisks, err = er.getObjectFileInfo(ctx, minioMetaBucket, o.objectPath(partN), ObjectOptions{}, true)
|
fi, metaArr, onlineDisks, err = er.getObjectFileInfo(ctx, minioMetaBucket, o.objectPath(partN), ObjectOptions{}, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
time.Sleep(retryDelay250)
|
if err := retryWait(); err != nil {
|
||||||
retries++
|
return entries, err
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
loadedPart = partN
|
loadedPart = partN
|
||||||
@@ -576,13 +576,15 @@ func (er *erasureObjects) streamMetadataParts(ctx context.Context, o listPathOpt
|
|||||||
if err != nil && !errors.Is(err, io.EOF) {
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
switch toObjectErr(err, minioMetaBucket, o.objectPath(partN)).(type) {
|
switch toObjectErr(err, minioMetaBucket, o.objectPath(partN)).(type) {
|
||||||
case ObjectNotFound:
|
case ObjectNotFound:
|
||||||
retries++
|
if err := retryWait(); err != nil {
|
||||||
time.Sleep(retryDelay250)
|
return entries, err
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
case InsufficientReadQuorum:
|
case InsufficientReadQuorum:
|
||||||
retries++
|
|
||||||
loadedPart = -1
|
loadedPart = -1
|
||||||
time.Sleep(retryDelay250)
|
if err := retryWait(); err != nil {
|
||||||
|
return entries, err
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
default:
|
default:
|
||||||
internalLogIf(ctx, err)
|
internalLogIf(ctx, err)
|
||||||
@@ -700,9 +702,46 @@ func getQuorumDisks(disks []StorageAPI, infos []DiskInfo, readQuorum int) (newDi
|
|||||||
return newDisks
|
return newDisks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveListEntry keeps the existing walk resolution and fallback eligibility.
|
||||||
|
// Eligible latest-only disagreements are resolved at the pool merger, where a
|
||||||
|
// current absence can replace the entire same-name group, including stale walks.
|
||||||
|
func (er *erasureObjects) resolveListEntry(entries metaCacheEntries, errs []error, resolver *metadataResolutionParams) *metaCacheEntry {
|
||||||
|
entry, ok := entries.resolve(resolver)
|
||||||
|
if ok {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
if resolver.requestedVersions != 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
valid := 0
|
||||||
|
for i := range entries {
|
||||||
|
if !entries[i].isObject() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := entries[i].xlmeta(); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valid++
|
||||||
|
if entry == nil {
|
||||||
|
entry = &entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
valid++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry == nil || valid < resolver.objQuorum {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// This entry exists only in the internal set-to-pool stream. Do not retain
|
||||||
|
// an arbitrary walk snapshot or encode the pending state as metadata.
|
||||||
|
return &metaCacheEntry{name: entry.name, listingSet: er}
|
||||||
|
}
|
||||||
|
|
||||||
// Will return io.EOF if continuing would not yield more results.
|
// Will return io.EOF if continuing would not yield more results.
|
||||||
func (er *erasureObjects) listPath(ctx context.Context, o listPathOptions, results chan<- metaCacheEntry) (err error) {
|
func (er *erasureObjects) listPath(ctx context.Context, o listPathOptions, results chan<- metaCacheEntry) (err error) {
|
||||||
defer xioutil.SafeClose(results)
|
// The caller records the terminal error before closing results.
|
||||||
o.debugf(color.Green("listPath:")+" with options: %#v", o)
|
o.debugf(color.Green("listPath:")+" with options: %#v", o)
|
||||||
|
|
||||||
// get prioritized non-healing disks for listing
|
// get prioritized non-healing disks for listing
|
||||||
@@ -778,8 +817,8 @@ func (er *erasureObjects) listPath(ctx context.Context, o listPathOptions, resul
|
|||||||
},
|
},
|
||||||
partial: func(entries metaCacheEntries, errs []error) {
|
partial: func(entries metaCacheEntries, errs []error) {
|
||||||
// Results Disagree :-(
|
// Results Disagree :-(
|
||||||
entry, ok := entries.resolve(&resolver)
|
entry := er.resolveListEntry(entries, errs, &resolver)
|
||||||
if ok {
|
if entry != nil {
|
||||||
select {
|
select {
|
||||||
case <-ctxDone:
|
case <-ctxDone:
|
||||||
case results <- *entry:
|
case results <- *entry:
|
||||||
@@ -809,7 +848,9 @@ func (m *metaCacheRPC) setErr(err string) {
|
|||||||
// An error is already set.
|
// An error is already set.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
meta, _ = m.o.updateMetacacheListing(meta, m.rpc)
|
if updated, err := m.o.updateMetacacheListing(meta, m.rpc); err == nil {
|
||||||
|
meta = updated
|
||||||
|
}
|
||||||
*m.meta = meta
|
*m.meta = meta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ func (w *metacacheWriter) write(objs ...metaCacheEntry) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, o := range objs {
|
for _, o := range objs {
|
||||||
|
if o.listingSet != nil {
|
||||||
|
return errors.New("metacacheWriter: unresolved listing entry")
|
||||||
|
}
|
||||||
if len(o.name) == 0 {
|
if len(o.name) == 0 {
|
||||||
return errors.New("metacacheWriter: no name provided")
|
return errors.New("metacacheWriter: no name provided")
|
||||||
}
|
}
|
||||||
@@ -170,6 +173,9 @@ func (w *metacacheWriter) stream() (chan<- metaCacheEntry, error) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer w.streamWg.Done()
|
defer w.streamWg.Done()
|
||||||
for o := range objs {
|
for o := range objs {
|
||||||
|
if o.listingSet != nil && w.streamErr == nil {
|
||||||
|
w.streamErr = errors.New("metacacheWriter: unresolved listing entry")
|
||||||
|
}
|
||||||
if len(o.name) == 0 || w.streamErr != nil {
|
if len(o.name) == 0 || w.streamErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ func toObjectErr(oerr error, params ...string) error {
|
|||||||
apiErr.Type = v.Type
|
apiErr.Type = v.Type
|
||||||
}
|
}
|
||||||
return apiErr
|
return apiErr
|
||||||
case errErasureWriteQuorum.Error():
|
case errErasureWriteQuorum.Error(), errCommitMetaMissing.Error():
|
||||||
apiErr := InsufficientWriteQuorum{}
|
apiErr := InsufficientWriteQuorum{}
|
||||||
if len(params) >= 1 {
|
if len(params) >= 1 {
|
||||||
apiErr.Bucket = params[0]
|
apiErr.Bucket = params[0]
|
||||||
@@ -834,6 +834,9 @@ func (de DataMovementOverwriteErr) Error() string {
|
|||||||
if de.VersionID != "" {
|
if de.VersionID != "" {
|
||||||
objInfoStr = fmt.Sprintf("%s version-id=%s", objInfoStr, de.VersionID)
|
objInfoStr = fmt.Sprintf("%s version-id=%s", objInfoStr, de.VersionID)
|
||||||
}
|
}
|
||||||
|
if de.Err == errDataMovementSuperseded {
|
||||||
|
return fmt.Sprintf("data movement skipped, another pool holds a newer copy of %s", objInfoStr)
|
||||||
|
}
|
||||||
return fmt.Sprintf("invalid data movement operation, source and destination pool are the same for %s", objInfoStr)
|
return fmt.Sprintf("invalid data movement operation, source and destination pool are the same for %s", objInfoStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,3 +844,11 @@ func isDataMovementOverWriteErr(err error) bool {
|
|||||||
var de DataMovementOverwriteErr
|
var de DataMovementOverwriteErr
|
||||||
return errors.As(err, &de)
|
return errors.As(err, &de)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDataMovementSuperseded reports a version that was not moved because
|
||||||
|
// another pool already holds a newer copy, which reads return. The source copy
|
||||||
|
// is a stale duplicate: the move is complete, and the source may be removed.
|
||||||
|
func isDataMovementSuperseded(err error) bool {
|
||||||
|
var de DataMovementOverwriteErr
|
||||||
|
return errors.As(err, &de) && de.Err == errDataMovementSuperseded
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,12 +62,13 @@ func testAPIPutObjectChunkedChecksum(obj ObjectLayer, instanceType, bucketName s
|
|||||||
// A well-formed 4-byte value that does not match the content.
|
// A well-formed 4-byte value that does not match the content.
|
||||||
wrongCRC := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04})
|
wrongCRC := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03, 0x04})
|
||||||
|
|
||||||
apiCode := func(rec *httptest.ResponseRecorder) string {
|
apiErrOf := func(rec *httptest.ResponseRecorder) APIErrorResponse {
|
||||||
var apiErr APIErrorResponse
|
var apiErr APIErrorResponse
|
||||||
b, _ := io.ReadAll(rec.Body)
|
b, _ := io.ReadAll(rec.Body)
|
||||||
_ = xml.Unmarshal(b, &apiErr)
|
_ = xml.Unmarshal(b, &apiErr)
|
||||||
return apiErr.Code
|
return apiErr
|
||||||
}
|
}
|
||||||
|
apiCode := func(rec *httptest.ResponseRecorder) string { return apiErrOf(rec).Code }
|
||||||
|
|
||||||
// newChunkedJavaForm builds a non-trailer signed chunked PutObject request that
|
// newChunkedJavaForm builds a non-trailer signed chunked PutObject request that
|
||||||
// mirrors the AWS Java SDK v2 wire form: the checksum value sits in the header
|
// mirrors the AWS Java SDK v2 wire form: the checksum value sits in the header
|
||||||
@@ -131,8 +132,12 @@ func testAPIPutObjectChunkedChecksum(obj ObjectLayer, instanceType, bucketName s
|
|||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("%s: chunked+wrong CRC32: expected 400, got %d", instanceType, rec.Code)
|
t.Fatalf("%s: chunked+wrong CRC32: expected 400, got %d", instanceType, rec.Code)
|
||||||
}
|
}
|
||||||
if code := apiCode(rec); code != "XAmzContentChecksumMismatch" {
|
gotErr := apiErrOf(rec)
|
||||||
t.Fatalf("%s: chunked+wrong CRC32: want XAmzContentChecksumMismatch, got %q", instanceType, code)
|
if gotErr.Code != "XAmzContentChecksumMismatch" {
|
||||||
|
t.Fatalf("%s: chunked+wrong CRC32: want XAmzContentChecksumMismatch, got %q", instanceType, gotErr.Code)
|
||||||
|
}
|
||||||
|
if gotErr.Message != "The CRC32 you specified did not match the calculated checksum." {
|
||||||
|
t.Fatalf("%s: chunked+wrong CRC32: message = %q, want S3 algorithm-specific wording", instanceType, gotErr.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ var errDiskAccessDenied = StorageErr("drive access denied")
|
|||||||
// errFileNotFound - cannot find the file.
|
// errFileNotFound - cannot find the file.
|
||||||
var errFileNotFound = StorageErr("file not found")
|
var errFileNotFound = StorageErr("file not found")
|
||||||
|
|
||||||
|
// errCommitMetaMissing - the staged xl.meta disappeared between the
|
||||||
|
// data rename and the meta-file commit; a write-quorum failure, not
|
||||||
|
// a missing object.
|
||||||
|
var errCommitMetaMissing = StorageErr("commit metadata is missing")
|
||||||
|
|
||||||
// errFileNotFound - cannot find requested file version.
|
// errFileNotFound - cannot find requested file version.
|
||||||
var errFileVersionNotFound = StorageErr("file version not found")
|
var errFileVersionNotFound = StorageErr("file version not found")
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ func toStorageErr(err error) error {
|
|||||||
return errVolumeExists
|
return errVolumeExists
|
||||||
case errFileNotFound.Error():
|
case errFileNotFound.Error():
|
||||||
return errFileNotFound
|
return errFileNotFound
|
||||||
|
case errCommitMetaMissing.Error():
|
||||||
|
return errCommitMetaMissing
|
||||||
case errFileVersionNotFound.Error():
|
case errFileVersionNotFound.Error():
|
||||||
return errFileVersionNotFound
|
return errFileVersionNotFound
|
||||||
case errFileNameTooLong.Error():
|
case errFileNameTooLong.Error():
|
||||||
|
|||||||
@@ -290,6 +290,9 @@ func (s *storageRESTServer) NSScannerHandler(ctx context.Context, params *nsScan
|
|||||||
for update := range updates {
|
for update := range updates {
|
||||||
resp := storageNSScannerRPC.NewResponse()
|
resp := storageNSScannerRPC.NewResponse()
|
||||||
resp.Update = &update
|
resp.Update = &update
|
||||||
|
// Pooled responses may carry fields from a previous
|
||||||
|
// scan, always clear the fields not being set.
|
||||||
|
resp.Final = nil
|
||||||
out <- resp
|
out <- resp
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -301,6 +304,7 @@ func (s *storageRESTServer) NSScannerHandler(ctx context.Context, params *nsScan
|
|||||||
// Send final response.
|
// Send final response.
|
||||||
resp := storageNSScannerRPC.NewResponse()
|
resp := storageNSScannerRPC.NewResponse()
|
||||||
resp.Final = &ui
|
resp.Final = &ui
|
||||||
|
resp.Update = nil
|
||||||
out <- resp
|
out <- resp
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2026 Julien Laurenceau and 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 (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/minio/madmin-go/v3"
|
||||||
|
"github.com/minio/minio/internal/grid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scannerResponseTestDisk struct {
|
||||||
|
StorageAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scannerResponseTestDisk) NSScanner(_ context.Context, cache dataUsageCache, updates chan<- dataUsageEntry, _ madmin.HealScanMode, _ func() bool) (dataUsageCache, error) {
|
||||||
|
defer close(updates)
|
||||||
|
updates <- dataUsageEntry{Size: int64(cache.Info.NextCycle)}
|
||||||
|
cache.Info.NextCycle++
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSScannerHandlerClearsStaleResponses(t *testing.T) {
|
||||||
|
endpoint, err := NewEndpoint(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
globalLocalDrivesMu.Lock()
|
||||||
|
oldDrives, oldSets := globalLocalDrivesMap, globalLocalSetDrives
|
||||||
|
globalLocalDrivesMap = map[string]StorageAPI{endpoint.String(): scannerResponseTestDisk{}}
|
||||||
|
globalLocalSetDrives = nil
|
||||||
|
globalLocalDrivesMu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
globalLocalDrivesMu.Lock()
|
||||||
|
globalLocalDrivesMap, globalLocalSetDrives = oldDrives, oldSets
|
||||||
|
globalLocalDrivesMu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
oldRPC := storageNSScannerRPC
|
||||||
|
t.Cleanup(func() { storageNSScannerRPC = oldRPC })
|
||||||
|
// Every new allocation is dirty too: the check must not depend on
|
||||||
|
// sync.Pool returning a previously used object.
|
||||||
|
storageNSScannerRPC = grid.NewStream[*nsScannerOptions, grid.NoPayload, *nsScannerResp](grid.HandlerNSScanner,
|
||||||
|
func() *nsScannerOptions { return &nsScannerOptions{} }, nil,
|
||||||
|
func() *nsScannerResp {
|
||||||
|
return &nsScannerResp{Update: &dataUsageEntry{Size: -1}, Final: &dataUsageCache{}}
|
||||||
|
})
|
||||||
|
server := storageRESTServer{endpoint: endpoint}
|
||||||
|
for cycle := uint32(1); cycle <= 3; cycle++ {
|
||||||
|
out := make(chan *nsScannerResp, 2)
|
||||||
|
params := &nsScannerOptions{Cache: &dataUsageCache{Info: dataUsageCacheInfo{Name: "bucket", NextCycle: cycle}}}
|
||||||
|
if err := server.NSScannerHandler(t.Context(), params, out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("cycle %d: got %d responses, want an update and a final cache", cycle, len(out))
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
resp := <-out
|
||||||
|
wire, err := resp.MarshalMsg(nil)
|
||||||
|
storageNSScannerRPC.PutResponse(resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decoded nsScannerResp
|
||||||
|
if _, err := decoded.UnmarshalMsg(wire); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
if decoded.Update == nil || decoded.Final != nil || decoded.Update.Size != int64(cycle) {
|
||||||
|
t.Errorf("cycle %d: invalid update response: %+v", cycle, decoded)
|
||||||
|
}
|
||||||
|
} else if decoded.Update != nil || decoded.Final == nil || decoded.Final.Info.NextCycle != cycle+1 {
|
||||||
|
t.Errorf("cycle %d: invalid final response: %+v", cycle, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ import (
|
|||||||
"github.com/minio/minio-go/v7/pkg/tags"
|
"github.com/minio/minio-go/v7/pkg/tags"
|
||||||
xhttp "github.com/minio/minio/internal/http"
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
"github.com/pgsty/silo-pkg/v3/ldap"
|
"github.com/pgsty/silo-pkg/v3/ldap"
|
||||||
|
"github.com/pgsty/silo-pkg/v3/policy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runAllIAMSTSTests(suite *TestSuiteIAM, c *check) {
|
func runAllIAMSTSTests(suite *TestSuiteIAM, c *check) {
|
||||||
@@ -2384,8 +2385,10 @@ func (s *TestSuiteIAM) TestIAMImport(c *check, exportedContent []byte, caseNum i
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
c.Fatalf("import %d: Unable to list policies: %v", caseNum, err)
|
c.Fatalf("import %d: Unable to list policies: %v", caseNum, err)
|
||||||
}
|
}
|
||||||
defaultCannedPolicies := set.CreateStringSet("consoleAdmin", "readwrite", "readonly",
|
defaultCannedPolicies := set.CreateStringSet()
|
||||||
"diagnostics", "writeonly")
|
for _, p := range policy.DefaultPolicies {
|
||||||
|
defaultCannedPolicies.Add(p.Name)
|
||||||
|
}
|
||||||
for policy, policyBytes := range policyContentMap {
|
for policy, policyBytes := range policyContentMap {
|
||||||
if defaultCannedPolicies.Contains(policy) {
|
if defaultCannedPolicies.Contains(policy) {
|
||||||
continue
|
continue
|
||||||
@@ -2991,6 +2994,21 @@ func (s *TestSuiteIAM) TestLDAPSTSServiceAccounts(c *check) {
|
|||||||
// 6. Check that service account cannot be created for some other user.
|
// 6. Check that service account cannot be created for some other user.
|
||||||
c.mustNotCreateSvcAccount(ctx, globalActiveCred.AccessKey, userAdmClient)
|
c.mustNotCreateSvcAccount(ctx, globalActiveCred.AccessKey, userAdmClient)
|
||||||
|
|
||||||
|
// An empty policy must not contaminate an expiring LDAP child credential.
|
||||||
|
emptyPolicyCred, err := userAdmClient.AddServiceAccountLDAP(ctx, madmin.AddServiceAccountReq{Policy: []byte("null")})
|
||||||
|
if err != nil {
|
||||||
|
c.Fatalf("Unable to create empty-policy LDAP service account: %v", err)
|
||||||
|
}
|
||||||
|
emptyPolicyAdmin := s.getAdminClient(c, emptyPolicyCred.AccessKey, emptyPolicyCred.SecretKey, "")
|
||||||
|
expires := UTCNow().Add(time.Hour)
|
||||||
|
for _, childPolicy := range [][]byte{nil, []byte("null")} {
|
||||||
|
child, err := emptyPolicyAdmin.AddServiceAccountLDAP(ctx, madmin.AddServiceAccountReq{Expiration: &expires, Policy: childPolicy})
|
||||||
|
if err != nil {
|
||||||
|
c.Fatalf("Unable to create expiring LDAP child: %v", err)
|
||||||
|
}
|
||||||
|
c.assertSvcAccS3Access(ctx, s, child, bucket)
|
||||||
|
}
|
||||||
|
|
||||||
// Detach the policy from the user
|
// Detach the policy from the user
|
||||||
if _, err = s.adm.DetachPolicyLDAP(ctx, userReq); err != nil {
|
if _, err = s.adm.DetachPolicyLDAP(ctx, userReq); err != nil {
|
||||||
c.Fatalf("Unable to detach user policy: %v", err)
|
c.Fatalf("Unable to detach user policy: %v", err)
|
||||||
|
|||||||
@@ -18,10 +18,16 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSysErrors(t *testing.T) {
|
func TestSysErrors(t *testing.T) {
|
||||||
@@ -56,3 +62,120 @@ func TestSysErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCommitMetaMissingMapping verifies the RPC and object-layer mapping
|
||||||
|
// of errCommitMetaMissing, returned when the staged xl.meta disappears
|
||||||
|
// between the data rename and the meta-file commit in RenameData().
|
||||||
|
func TestCommitMetaMissingMapping(t *testing.T) {
|
||||||
|
// The sentinel must survive a storage REST/grid round-trip; errors
|
||||||
|
// cross the wire as strings and are remapped by toStorageErr().
|
||||||
|
if got := toStorageErr(errors.New(errCommitMetaMissing.Error())); !errors.Is(got, errCommitMetaMissing) {
|
||||||
|
t.Fatalf("expected %s, got %s", errCommitMetaMissing, got)
|
||||||
|
}
|
||||||
|
if got := toStorageErr(errCommitMetaMissing); got != errCommitMetaMissing {
|
||||||
|
t.Fatalf("expected %s, got %s", errCommitMetaMissing, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toObjectErr() must map the sentinel to InsufficientWriteQuorum
|
||||||
|
// (503), never to ObjectNotFound (404).
|
||||||
|
err := toObjectErr(errCommitMetaMissing, "bucket", "object")
|
||||||
|
qerr, ok := err.(InsufficientWriteQuorum)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected InsufficientWriteQuorum, got %s (%T)", err, err)
|
||||||
|
}
|
||||||
|
if qerr.Bucket != "bucket" || qerr.Object != "object" {
|
||||||
|
t.Fatalf("unexpected bucket/object %s/%s", qerr.Bucket, qerr.Object)
|
||||||
|
}
|
||||||
|
apiErr := toAPIError(t.Context(), err)
|
||||||
|
if apiErr.Code != "SlowDownWrite" || apiErr.HTTPStatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("expected SlowDownWrite/503, got %+v", apiErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingCommitMetaContext removes staged metadata at an existing cancellation
|
||||||
|
// check, after WriteAll and (for non-inline objects) the data rename succeeded.
|
||||||
|
// This deterministically models concurrent cleanup without changing production
|
||||||
|
// code or relying on a filesystem race. The context itself remains live.
|
||||||
|
type missingCommitMetaContext struct {
|
||||||
|
context.Context
|
||||||
|
mu sync.Mutex
|
||||||
|
meta, dataDest string
|
||||||
|
removed bool
|
||||||
|
removeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *missingCommitMetaContext) Done() <-chan struct{} {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if !c.removed {
|
||||||
|
if _, err := os.Stat(c.meta); err == nil {
|
||||||
|
dataMoved := c.dataDest == ""
|
||||||
|
if !dataMoved {
|
||||||
|
_, err = os.Stat(c.dataDest)
|
||||||
|
dataMoved = err == nil
|
||||||
|
}
|
||||||
|
if dataMoved {
|
||||||
|
c.removeErr = os.Remove(c.meta)
|
||||||
|
c.removed = c.removeErr == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.Context.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXLStorageRenameDataCommitMetaMissing(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
dataFile bool
|
||||||
|
}{
|
||||||
|
{"empty_object", false},
|
||||||
|
{"data_directory", true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
disk, root, err := newXLStorageTestSetup(t)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer disk.Close()
|
||||||
|
s := disk.storage
|
||||||
|
if err := s.MakeVol(t.Context(), "bucket"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fi := newFileInfo("object", 2, 2)
|
||||||
|
fi.DataDir, fi.VersionID = mustGetUUID(), mustGetUUID()
|
||||||
|
fi.Erasure.Index = 1
|
||||||
|
fi.ModTime = time.Now().UTC()
|
||||||
|
fi.Fresh = true
|
||||||
|
ctx := &missingCommitMetaContext{
|
||||||
|
Context: t.Context(),
|
||||||
|
meta: filepath.Join(root, minioMetaTmpBucket, "staged", xlStorageFormatFile),
|
||||||
|
}
|
||||||
|
if tc.dataFile {
|
||||||
|
fi.Size = 1
|
||||||
|
fi.AddObjectPart(1, "", 1, 1, fi.ModTime, nil, nil)
|
||||||
|
part := filepath.Join(root, minioMetaTmpBucket, "staged", fi.DataDir, "part.1")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(part), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(part, []byte("a"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx.dataDest = filepath.Join(root, "bucket", "object", fi.DataDir)
|
||||||
|
}
|
||||||
|
_, err = s.RenameData(ctx, minioMetaTmpBucket, "staged", fi, "bucket", "object", RenameOptions{})
|
||||||
|
if !ctx.removed {
|
||||||
|
t.Fatalf("metadata removal did not execute: removal error = %v, RenameData error = %v", ctx.removeErr, err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errCommitMetaMissing) {
|
||||||
|
t.Fatalf("expected commit metadata missing, got %v", err)
|
||||||
|
}
|
||||||
|
apiErr := toAPIError(t.Context(), toObjectErr(err, "bucket", "object"))
|
||||||
|
if apiErr.Code != "SlowDownWrite" || apiErr.HTTPStatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("expected SlowDownWrite/503, got %+v", apiErr)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "bucket", "object", xlStorageFormatFile)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("failed commit must not produce destination metadata: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// 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"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func crossParityNullVersion(t *testing.T, generation int64, data, parity int) xlMetaV2ShallowVersion {
|
||||||
|
t.Helper()
|
||||||
|
fi := newFileInfo("bucket/object", data, parity)
|
||||||
|
fi.Erasure.Index = 1
|
||||||
|
fi.DataDir = "11111111-1111-1111-1111-111111111111"
|
||||||
|
fi.ModTime = time.Unix(generation, 0).UTC()
|
||||||
|
fi.Size = 1024
|
||||||
|
fi.Parts = []ObjectPartInfo{{Number: 1, Size: fi.Size, ActualSize: fi.Size}}
|
||||||
|
fi.Metadata = map[string]string{"etag": fmt.Sprintf("%032d", generation)}
|
||||||
|
var xl xlMetaV2
|
||||||
|
if err := xl.AddVersion(fi); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return xl.versions[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// A restarted node can retain four 12+4 shards while a successful degraded
|
||||||
|
// write leaves twelve newer 8+8 shards. Neither disk order nor the minority's
|
||||||
|
// EC must hide the independently sufficient newer group.
|
||||||
|
func TestMergeXLV2LatestNullCrossParity(t *testing.T) {
|
||||||
|
versions := []xlMetaV2ShallowVersion{
|
||||||
|
crossParityNullVersion(t, 100, 12, 4),
|
||||||
|
crossParityNullVersion(t, 200, 8, 8),
|
||||||
|
crossParityNullVersion(t, 300, 10, 6),
|
||||||
|
}
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
counts [3]int
|
||||||
|
empty int
|
||||||
|
quorum int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"new-twelve-old-four", [3]int{4, 12}, 0, 8, 1},
|
||||||
|
{"old-twelve-new-four", [3]int{12, 4}, 0, 8, 0},
|
||||||
|
{"new-nine-old-seven", [3]int{7, 9}, 0, 8, 1},
|
||||||
|
{"eight-meets-eight", [3]int{4, 8}, 4, 8, 1},
|
||||||
|
{"eight-does-not-meet-nine", [3]int{8, 8}, 0, 9, -1},
|
||||||
|
{"three-subquorums", [3]int{7, 5, 4}, 0, 8, -1},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var input [][]xlMetaV2ShallowVersion
|
||||||
|
for group, count := range tt.counts {
|
||||||
|
for range count {
|
||||||
|
input = append(input, []xlMetaV2ShallowVersion{versions[group]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input = append(input, make([][]xlMetaV2ShallowVersion, tt.empty)...)
|
||||||
|
for order, streams := range quorumVersionOrders(input) {
|
||||||
|
got := mergeXLV2Versions(tt.quorum, false, 1, streams...)
|
||||||
|
if tt.want < 0 {
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("order %d: subquorum groups were combined: %v", order, got)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].header != versions[tt.want].header {
|
||||||
|
t.Fatalf("order %d: got %v, want group %d", order, got, tt.want)
|
||||||
|
}
|
||||||
|
xl := xlMetaV2{versions: got}
|
||||||
|
fi, err := xl.ToFileInfo("bucket", "object", "", false, true)
|
||||||
|
if err != nil || !fi.IsValid() || fi.Erasure.DataBlocks != int(versions[tt.want].header.EcM) {
|
||||||
|
t.Fatalf("order %d: selected metadata is invalid: %+v, %v", order, fi, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeXLV2LatestNullCrossParityScope(t *testing.T) {
|
||||||
|
old := crossParityNullVersion(t, 100, 12, 4)
|
||||||
|
newer := crossParityNullVersion(t, 200, 8, 8)
|
||||||
|
for _, kind := range []string{"strict", "all-versions", "uuid", "delete", "free", "history", "legacy-modern-ec", "partial-ec"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
a, b := old, newer
|
||||||
|
strict, requested := false, 1
|
||||||
|
switch kind {
|
||||||
|
case "strict":
|
||||||
|
strict = true
|
||||||
|
case "all-versions":
|
||||||
|
requested = 0
|
||||||
|
case "uuid":
|
||||||
|
a.header.VersionID, b.header.VersionID = [16]byte{1}, [16]byte{1}
|
||||||
|
case "delete":
|
||||||
|
b.header.Type = DeleteType
|
||||||
|
case "free":
|
||||||
|
b.header.Flags |= xlFlagFreeVersion
|
||||||
|
case "legacy-modern-ec":
|
||||||
|
b.header.EcN, b.header.EcM = 0, 0
|
||||||
|
case "partial-ec":
|
||||||
|
b.header.EcN = 0
|
||||||
|
}
|
||||||
|
// These previously unresolved inputs remain outside the repair.
|
||||||
|
// Strict and legacy-header cases use an older majority followed by
|
||||||
|
// a newer minority; the other cases use the opposite grouping.
|
||||||
|
input := make([][]xlMetaV2ShallowVersion, 16)
|
||||||
|
for i := range input {
|
||||||
|
input[i] = []xlMetaV2ShallowVersion{b}
|
||||||
|
if i >= 12 {
|
||||||
|
input[i] = []xlMetaV2ShallowVersion{a}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == "strict" || kind == "legacy-modern-ec" || kind == "partial-ec" {
|
||||||
|
for i := range input {
|
||||||
|
input[i] = []xlMetaV2ShallowVersion{a}
|
||||||
|
if i >= 12 {
|
||||||
|
input[i] = []xlMetaV2ShallowVersion{b}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == "history" {
|
||||||
|
history := old
|
||||||
|
history.header.VersionID = [16]byte{1}
|
||||||
|
input[0] = append(input[0], history)
|
||||||
|
}
|
||||||
|
if got := mergeXLV2Versions(8, strict, requested, input...); len(got) != 0 {
|
||||||
|
t.Fatalf("excluded input changed: %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountXLV2MixedECGroups(t *testing.T) {
|
||||||
|
a := crossParityNullVersion(t, 100, 12, 4)
|
||||||
|
b := crossParityNullVersion(t, 100, 8, 8)
|
||||||
|
c := crossParityNullVersion(t, 200, 10, 6)
|
||||||
|
// Same-time groups remain separate, and equal counts have a deterministic
|
||||||
|
// final EC tiebreak even though sortsBefore does not compare EC itself.
|
||||||
|
input := [][]xlMetaV2ShallowVersion{{a}, {a}, {a}, {b}, {b}, {b}, {c}}
|
||||||
|
for order, streams := range quorumVersionOrders(input) {
|
||||||
|
tops := make([]xlMetaV2ShallowVersion, len(streams))
|
||||||
|
for i := range streams {
|
||||||
|
tops[i] = streams[i][0]
|
||||||
|
}
|
||||||
|
for range 20 {
|
||||||
|
got, count := countXLV2Versions(tops, c.header, c, false, true)
|
||||||
|
if count != 3 || got.header != a.header {
|
||||||
|
t.Fatalf("order %d: got %v, count %d; want distinct group of three", order, got.header, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
differentSig := a
|
||||||
|
differentSig.header.Signature[0]++
|
||||||
|
if _, count := countXLV2Versions([]xlMetaV2ShallowVersion{a, a, differentSig}, a.header, a, false, true); count != 3 {
|
||||||
|
t.Fatalf("non-strict signature groups did not combine: count %d", count)
|
||||||
|
}
|
||||||
|
// Same EC with different time or flags must not accumulate votes either.
|
||||||
|
flags := a
|
||||||
|
flags.header.Flags ^= xlFlagInlineData
|
||||||
|
for _, other := range []xlMetaV2ShallowVersion{crossParityNullVersion(t, 200, 12, 4), flags} {
|
||||||
|
got, count := countXLV2Versions([]xlMetaV2ShallowVersion{a, a, other, other}, a.header, a, false, true)
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("incompatible header groups combined: %v, count %d", got.header, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise the regular full-metadata read and HTTP LIST with actual on-disk
|
||||||
|
// object metadata, not merely the metadata selection helper.
|
||||||
|
func TestListCrossParityReadableMajority(t *testing.T) {
|
||||||
|
for _, size := range []int{1024, 256 << 10} {
|
||||||
|
t.Run(fmt.Sprintf("bytes-%d", size), func(t *testing.T) {
|
||||||
|
z, bucket, router := nullQuorumBackend(t)
|
||||||
|
set := z.serverPools[0].sets[0]
|
||||||
|
disks := set.getDisks()
|
||||||
|
const object = "object"
|
||||||
|
mtime := time.Now().UTC().Add(-time.Hour)
|
||||||
|
oldBody := bytes.Repeat([]byte{'a'}, size)
|
||||||
|
newBody := bytes.Repeat([]byte{'b'}, size)
|
||||||
|
nullQuorumPut(t, z, bucket, object, oldBody, ObjectOptions{MTime: mtime})
|
||||||
|
old := make([][]byte, len(disks))
|
||||||
|
for i, disk := range disks {
|
||||||
|
old[i] = mustReadNullQuorumMeta(t, disk, bucket, object)
|
||||||
|
}
|
||||||
|
nullQuorumPut(t, z, bucket, object, newBody, ObjectOptions{MTime: mtime.Add(time.Minute), MaxParity: true})
|
||||||
|
for i := 12; i < len(disks); i++ {
|
||||||
|
if err := disks[i].WriteAll(t.Context(), bucket, object+"/"+xlStorageFormatFile, old[i]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, readData := range []bool{false, true} {
|
||||||
|
fi, _, _, err := set.getObjectFileInfo(t.Context(), bucket, object, ObjectOptions{}, readData)
|
||||||
|
if err != nil || !fi.ModTime.Equal(mtime.Add(time.Minute)) || fi.Erasure.DataBlocks != 8 {
|
||||||
|
t.Fatalf("readData=%v: full metadata read lost readable majority: %+v, %v", readData, fi, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if get := nullQuorumRequest(t, router, http.MethodGet, getGetObjectURL("", bucket, object)); get.Code != http.StatusOK || !bytes.Equal(get.Body.Bytes(), newBody) {
|
||||||
|
t.Fatalf("GET lost readable majority: status %d", get.Code)
|
||||||
|
}
|
||||||
|
if code, keys := nullQuorumListV2Keys(t, router, bucket); code != http.StatusOK || !slices.Equal(keys, []string{object}) {
|
||||||
|
t.Fatalf("LIST lost readable majority: %d %v", code, keys)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,10 @@ func (x *xlMetaV2VersionHeader) unmarshalV(v uint8, bts []byte) (o []byte, err e
|
|||||||
|
|
||||||
// unmarshalV1 decodes version 1, never released.
|
// unmarshalV1 decodes version 1, never released.
|
||||||
func (x *xlMetaV2VersionHeader) unmarshalV1(bts []byte) (o []byte, err error) {
|
func (x *xlMetaV2VersionHeader) unmarshalV1(bts []byte) (o []byte, err error) {
|
||||||
|
// Fields absent from the V1 wire format must be reset since the
|
||||||
|
// header may be reused across decodes.
|
||||||
|
x.Signature = [4]byte{}
|
||||||
|
x.EcN, x.EcM = 0, 0
|
||||||
var zb0001 uint32
|
var zb0001 uint32
|
||||||
zb0001, bts, err = msgp.ReadArrayHeaderBytes(bts)
|
zb0001, bts, err = msgp.ReadArrayHeaderBytes(bts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -134,7 +138,7 @@ type xlMetaV2VersionHeaderV2 struct {
|
|||||||
|
|
||||||
// UnmarshalMsg implements msgp.Unmarshaler
|
// UnmarshalMsg implements msgp.Unmarshaler
|
||||||
func (z *xlMetaV2VersionHeaderV2) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
func (z *xlMetaV2VersionHeaderV2) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||||
z.EcN, z.EcN = 0, 0
|
z.EcN, z.EcM = 0, 0
|
||||||
var zb0001 uint32
|
var zb0001 uint32
|
||||||
zb0001, bts, err = msgp.ReadArrayHeaderBytes(bts)
|
zb0001, bts, err = msgp.ReadArrayHeaderBytes(bts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,7 +188,7 @@ func (z *xlMetaV2VersionHeaderV2) UnmarshalMsg(bts []byte) (o []byte, err error)
|
|||||||
|
|
||||||
// DecodeMsg implements msgp.Decodable
|
// DecodeMsg implements msgp.Decodable
|
||||||
func (z *xlMetaV2VersionHeaderV2) DecodeMsg(dc *msgp.Reader) (err error) {
|
func (z *xlMetaV2VersionHeaderV2) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||||
z.EcN, z.EcN = 0, 0
|
z.EcN, z.EcM = 0, 0
|
||||||
var zb0001 uint32
|
var zb0001 uint32
|
||||||
zb0001, err = dc.ReadArrayHeader()
|
zb0001, err = dc.ReadArrayHeader()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+34
-13
@@ -1944,7 +1944,7 @@ func mergeXLV2Versions(quorum int, strict bool, requestedVersions int, versions
|
|||||||
// Keep the original stream shapes: pruning must not make a versioned object
|
// Keep the original stream shapes: pruning must not make a versioned object
|
||||||
// eligible for the single null-version recount below.
|
// eligible for the single null-version recount below.
|
||||||
originalVersions := versions
|
originalVersions := versions
|
||||||
var checkedSingleNull, singleNull bool
|
var checkedSingleNull, singleNull, mixedECNull bool
|
||||||
// Shallow copy input
|
// Shallow copy input
|
||||||
versions = append(make([][]xlMetaV2ShallowVersion, 0, len(versions)), versions...)
|
versions = append(make([][]xlMetaV2ShallowVersion, 0, len(versions)), versions...)
|
||||||
|
|
||||||
@@ -2019,19 +2019,24 @@ func mergeXLV2Versions(quorum int, strict bool, requestedVersions int, versions
|
|||||||
// Version IDs match, but otherwise unable to resolve.
|
// Version IDs match, but otherwise unable to resolve.
|
||||||
// We are either strict, or don't have enough information to match.
|
// We are either strict, or don't have enough information to match.
|
||||||
// Switch to a pure counting algo.
|
// Switch to a pure counting algo.
|
||||||
latest, latestCount = countXLV2Versions(tops, ver.header, latest, strict)
|
latest, latestCount = countXLV2Versions(tops, ver.header, latest, strict, false)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if latestCount < quorum {
|
if latestCount < quorum {
|
||||||
if !checkedSingleNull {
|
if !checkedSingleNull {
|
||||||
singleNull = singleNullVersionStreams(originalVersions)
|
singleNull = singleNullVersionStreams(originalVersions, false)
|
||||||
|
// Availability-optimized writes can change EC while a drive
|
||||||
|
// is offline. Only latest-only non-strict reads may recount
|
||||||
|
// these ordinary null versions across separate modern EC groups.
|
||||||
|
mixedECNull = !singleNull && !strict && requestedVersions == 1 &&
|
||||||
|
singleNullVersionStreams(originalVersions, true)
|
||||||
checkedSingleNull = true
|
checkedSingleNull = true
|
||||||
}
|
}
|
||||||
if singleNull {
|
if singleNull || mixedECNull {
|
||||||
// A newer minority at the end can hide an older quorum from
|
// A newer minority at the end can hide an older quorum from
|
||||||
// the selection loop. Recount before discarding the null ID.
|
// the selection loop. Recount before discarding the null ID.
|
||||||
if candidate, count := countXLV2Versions(tops, latest.header, latest, strict); count >= quorum {
|
if candidate, count := countXLV2Versions(tops, latest.header, latest, strict, mixedECNull); count >= quorum {
|
||||||
latest, latestCount = candidate, count
|
latest, latestCount = candidate, count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2098,7 +2103,8 @@ func mergeXLV2Versions(quorum int, strict bool, requestedVersions int, versions
|
|||||||
|
|
||||||
// singleNullVersionStreams excludes histories and other version types from the
|
// singleNullVersionStreams excludes histories and other version types from the
|
||||||
// additional recount. Check the original inputs, before any stream is pruned.
|
// additional recount. Check the original inputs, before any stream is pruned.
|
||||||
func singleNullVersionStreams(versions [][]xlMetaV2ShallowVersion) bool {
|
// Mixed EC groups require known modern EC headers on every non-empty stream.
|
||||||
|
func singleNullVersionStreams(versions [][]xlMetaV2ShallowVersion, allowMixedEC bool) bool {
|
||||||
var ec xlMetaV2VersionHeader
|
var ec xlMetaV2VersionHeader
|
||||||
var haveEC bool
|
var haveEC bool
|
||||||
for _, stream := range versions {
|
for _, stream := range versions {
|
||||||
@@ -2112,7 +2118,10 @@ func singleNullVersionStreams(versions [][]xlMetaV2ShallowVersion) bool {
|
|||||||
if h.VersionID != [16]byte{} || h.Type != ObjectType || h.FreeVersion() {
|
if h.VersionID != [16]byte{} || h.Type != ObjectType || h.FreeVersion() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if haveEC && (h.EcN != ec.EcN || h.EcM != ec.EcM) {
|
if allowMixedEC && !h.hasEC() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !allowMixedEC && haveEC && (h.EcN != ec.EcN || h.EcM != ec.EcM) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
ec, haveEC = h, true
|
ec, haveEC = h, true
|
||||||
@@ -2121,16 +2130,18 @@ func singleNullVersionStreams(versions [][]xlMetaV2ShallowVersion) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// countXLV2Versions selects the most frequent compatible header for reference's
|
// countXLV2Versions selects the most frequent compatible header for reference's
|
||||||
// VersionID, retaining the existing sort tiebreak and last matching entry.
|
// VersionID and the last matching entry. The mixed-EC null-version recount adds
|
||||||
func countXLV2Versions(tops []xlMetaV2ShallowVersion, reference xlMetaV2VersionHeader, latest xlMetaV2ShallowVersion, strict bool) (xlMetaV2ShallowVersion, int) {
|
// a stable EC tiebreak without changing the ordinary counting path.
|
||||||
|
func countXLV2Versions(tops []xlMetaV2ShallowVersion, reference xlMetaV2VersionHeader, latest xlMetaV2ShallowVersion, strict, allowMixedEC bool) (xlMetaV2ShallowVersion, int) {
|
||||||
x := make(map[xlMetaV2VersionHeader]int, len(tops))
|
x := make(map[xlMetaV2VersionHeader]int, len(tops))
|
||||||
for _, a := range tops {
|
for _, a := range tops {
|
||||||
if a.header.VersionID != reference.VersionID {
|
if a.header.VersionID != reference.VersionID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strict {
|
if !strict {
|
||||||
// we must match EC, when we are not strict.
|
// The narrow null-version recount can consider other EC groups,
|
||||||
if !a.header.matchesEC(reference) {
|
// but EcN/EcM stay in the map key: their votes never combine.
|
||||||
|
if !allowMixedEC && !a.header.matchesEC(reference) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
a.header.Signature = [4]byte{}
|
a.header.Signature = [4]byte{}
|
||||||
@@ -2142,10 +2153,20 @@ func countXLV2Versions(tops []xlMetaV2ShallowVersion, reference xlMetaV2VersionH
|
|||||||
if v < latestCount {
|
if v < latestCount {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if v == latestCount && latest.header.sortsBefore(k) {
|
if v == latestCount {
|
||||||
// Tiebreak, use sort.
|
if allowMixedEC {
|
||||||
|
current := latest.header
|
||||||
|
current.Signature = [4]byte{}
|
||||||
|
// Signatures are not part of these groups. Use EC counts as
|
||||||
|
// the final tiebreak when the existing sort fields are equal.
|
||||||
|
if current.sortsBefore(k) || !k.sortsBefore(current) &&
|
||||||
|
(current.EcM > k.EcM || current.EcM == k.EcM && current.EcN > k.EcN) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
} else if latest.header.sortsBefore(k) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, a := range tops {
|
for _, a := range tops {
|
||||||
hdr := a.header
|
hdr := a.header
|
||||||
if !strict {
|
if !strict {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import (
|
|||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||||
xhttp "github.com/minio/minio/internal/http"
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
|
"github.com/tinylib/msgp/msgp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestReadXLMetaNoData(t *testing.T) {
|
func TestReadXLMetaNoData(t *testing.T) {
|
||||||
@@ -1190,3 +1191,92 @@ func BenchmarkToFileInfoNoParts(b *testing.B) {
|
|||||||
func BenchmarkToFileInfoWithParts(b *testing.B) {
|
func BenchmarkToFileInfoWithParts(b *testing.B) {
|
||||||
benchmarkManyPartsOptionally(b, true)
|
benchmarkManyPartsOptionally(b, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestXLMetaV2HeaderLegacyUnmarshalResetsEc checks that decoding legacy
|
||||||
|
// version headers, which carry no erasure metadata fields, cannot leak
|
||||||
|
// stale EcN/EcM values from a previously decoded object into the same
|
||||||
|
// structure.
|
||||||
|
func TestXLMetaV2HeaderLegacyUnmarshalResetsEc(t *testing.T) {
|
||||||
|
base := xlMetaV2VersionHeader{
|
||||||
|
VersionID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
|
||||||
|
ModTime: time.Now().Unix(),
|
||||||
|
Signature: [4]byte{1, 2, 3, 4},
|
||||||
|
Type: ObjectType,
|
||||||
|
Flags: xlFlagInlineData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy V2 header wire format: 5 fields, no EcN/EcM.
|
||||||
|
var legacy []byte
|
||||||
|
legacy = msgp.AppendArrayHeader(legacy, 5)
|
||||||
|
legacy = msgp.AppendBytes(legacy, base.VersionID[:])
|
||||||
|
legacy = msgp.AppendInt64(legacy, base.ModTime)
|
||||||
|
legacy = msgp.AppendBytes(legacy, base.Signature[:])
|
||||||
|
legacy = msgp.AppendUint8(legacy, uint8(base.Type))
|
||||||
|
legacy = msgp.AppendUint8(legacy, uint8(base.Flags))
|
||||||
|
|
||||||
|
for _, decoder := range []string{"UnmarshalMsg", "DecodeMsg", "loadIndexed"} {
|
||||||
|
t.Run(decoder, func(t *testing.T) {
|
||||||
|
header := base
|
||||||
|
header.EcN, header.EcM = 4, 8
|
||||||
|
x2 := xlMetaV2VersionHeaderV2{xlMetaV2VersionHeader: &header}
|
||||||
|
var err error
|
||||||
|
switch decoder {
|
||||||
|
case "UnmarshalMsg":
|
||||||
|
_, err = x2.UnmarshalMsg(legacy)
|
||||||
|
case "DecodeMsg":
|
||||||
|
err = x2.DecodeMsg(msgp.NewReader(bytes.NewReader(legacy)))
|
||||||
|
case "loadIndexed":
|
||||||
|
indexed := msgp.AppendUint(nil, 2)
|
||||||
|
indexed = msgp.AppendUint(indexed, xlMetaVersion)
|
||||||
|
indexed = msgp.AppendInt(indexed, 1)
|
||||||
|
indexed = msgp.AppendBytes(indexed, legacy)
|
||||||
|
indexed = msgp.AppendBytes(indexed, nil)
|
||||||
|
xl := xlMetaV2{versions: []xlMetaV2ShallowVersion{{header: header}}}
|
||||||
|
err = xl.loadIndexed(indexed, nil)
|
||||||
|
header = xl.versions[0].header
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if header != base {
|
||||||
|
t.Fatalf("legacy header: got %+v, want %+v", header, base)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// V1 headers (4 fields, never released) must zero the fields too.
|
||||||
|
var v1 []byte
|
||||||
|
v1 = msgp.AppendArrayHeader(v1, 4)
|
||||||
|
v1 = msgp.AppendBytes(v1, base.VersionID[:])
|
||||||
|
v1 = msgp.AppendInt64(v1, base.ModTime)
|
||||||
|
v1 = msgp.AppendUint8(v1, uint8(base.Type))
|
||||||
|
v1 = msgp.AppendUint8(v1, uint8(base.Flags))
|
||||||
|
|
||||||
|
header := base
|
||||||
|
header.EcN, header.EcM = 4, 8
|
||||||
|
header.Signature = [4]byte{9, 9, 9, 9}
|
||||||
|
if _, err := header.unmarshalV1(v1); err != nil {
|
||||||
|
t.Fatalf("legacy V1 header unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if header.EcN != 0 || header.EcM != 0 {
|
||||||
|
t.Fatalf("stale erasure metadata leaked on V1 decode: EcN %d, EcM %d", header.EcN, header.EcM)
|
||||||
|
}
|
||||||
|
if header.Signature != ([4]byte{}) {
|
||||||
|
t.Fatalf("stale signature leaked on V1 decode: %v", header.Signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current headers must keep their erasure metadata.
|
||||||
|
current := base
|
||||||
|
current.EcN, current.EcM = 4, 8
|
||||||
|
wire, err := current.MarshalMsg(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("header marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
decoded := base
|
||||||
|
if _, err := decoded.UnmarshalMsg(wire); err != nil {
|
||||||
|
t.Fatalf("header unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if decoded.EcN != 4 || decoded.EcM != 8 {
|
||||||
|
t.Fatalf("erasure metadata lost on current decode: EcN %d, EcM %d", decoded.EcN, decoded.EcM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+9
-1
@@ -2925,7 +2925,15 @@ func (s *xlStorage) RenameData(ctx context.Context, srcVolume, srcPath string, f
|
|||||||
// if its a partial rename() do not attempt to delete recursively.
|
// if its a partial rename() do not attempt to delete recursively.
|
||||||
// this can be healed since all parts are available.
|
// this can be healed since all parts are available.
|
||||||
s.deleteFile(dstVolumeDir, dstDataPath, false, false)
|
s.deleteFile(dstVolumeDir, dstDataPath, false, false)
|
||||||
return res, osErrToFileErr(err)
|
err = osErrToFileErr(err)
|
||||||
|
if errors.Is(err, errFileNotFound) {
|
||||||
|
// The staged xl.meta disappeared between staging and commit.
|
||||||
|
// This is a write-quorum failure, not a missing object; use a
|
||||||
|
// dedicated error so the object layer returns 503 instead of
|
||||||
|
// mapping it to ObjectNotFound (404).
|
||||||
|
err = errCommitMetaMissing
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if srcVolume != minioMetaMultipartBucket {
|
if srcVolume != minioMetaMultipartBucket {
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ module github.com/minio/minio/docs/debugging/reorder-disks
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require github.com/pgsty/silo-pkg/v3 v3.13.2
|
require github.com/pgsty/silo-pkg/v3 v3.14.3
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
github.com/pgsty/silo-pkg/v3 v3.13.2 h1:Clw11c/J54Tx6pijNCWtXiC7e0fwP/f5Tgeb6fsXg2w=
|
github.com/pgsty/silo-pkg/v3 v3.14.3 h1:hMDxOo36F1whGRkYScrUGGwn98FvsmJ8hVM6pE4i8Bs=
|
||||||
github.com/pgsty/silo-pkg/v3 v3.13.2/go.mod h1:0GmaDA0ArQ8bkAI/obiSNTBQzdgBu6W0e0olDCbyXXo=
|
github.com/pgsty/silo-pkg/v3 v3.14.3/go.mod h1:zqIm3FMETmwPHRKuUExCDGfYkCNXt+vpfBnPM821RdI=
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ go 1.27.2
|
|||||||
|
|
||||||
// Console and MC retain their historical module paths for best-effort upstream
|
// Console and MC retain their historical module paths for best-effort upstream
|
||||||
// compatibility. Pin the maintained PGSTY implementations used by SILO.
|
// compatibility. Pin the maintained PGSTY implementations used by SILO.
|
||||||
replace github.com/minio/console => github.com/pgsty/silo-console v0.0.0-20260916075814-1360e26d976d
|
replace github.com/minio/console => github.com/pgsty/silo-console v0.0.0-20261017112417-ef2dd2c41d21
|
||||||
|
|
||||||
replace github.com/minio/mc => github.com/pgsty/mc v0.0.0-20260916070421-e952aa78f10a
|
replace github.com/minio/mc => github.com/pgsty/mc v0.0.0-20261017101407-fa3b8bf72458
|
||||||
|
|
||||||
// v22.7.0 does not compile on NetBSD because its unix implementation uses
|
// v22.7.0 does not compile on NetBSD because its unix implementation uses
|
||||||
// CLOCK_MONOTONIC, which is unavailable there. Keep the last portable release
|
// CLOCK_MONOTONIC, which is unavailable there. Keep the last portable release
|
||||||
@@ -41,7 +41,7 @@ require (
|
|||||||
github.com/fatih/color v1.19.0
|
github.com/fatih/color v1.19.0
|
||||||
github.com/felixge/fgprof v0.9.5
|
github.com/felixge/fgprof v0.9.5
|
||||||
github.com/fraugster/parquet-go v0.12.0
|
github.com/fraugster/parquet-go v0.12.0
|
||||||
github.com/go-ldap/ldap/v3 v3.4.14
|
github.com/go-ldap/ldap/v3 v3.4.15
|
||||||
github.com/go-openapi/loads v0.25.2
|
github.com/go-openapi/loads v0.25.2
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
github.com/gobwas/ws v1.4.0
|
github.com/gobwas/ws v1.4.0
|
||||||
@@ -50,8 +50,8 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/inconshreveable/mousetrap v1.1.0
|
github.com/inconshreveable/mousetrap v1.1.0
|
||||||
github.com/json-iterator/go v1.1.12
|
github.com/json-iterator/go v1.1.12
|
||||||
github.com/klauspost/compress v1.20.0
|
github.com/klauspost/compress v1.20.3
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0
|
github.com/klauspost/cpuid/v2 v2.4.1
|
||||||
github.com/klauspost/filepathx v1.1.1
|
github.com/klauspost/filepathx v1.1.1
|
||||||
github.com/klauspost/pgzip v1.2.6
|
github.com/klauspost/pgzip v1.2.6
|
||||||
github.com/klauspost/readahead v1.4.0
|
github.com/klauspost/readahead v1.4.0
|
||||||
@@ -68,7 +68,7 @@ require (
|
|||||||
github.com/minio/kms-go/kes v0.3.1
|
github.com/minio/kms-go/kes v0.3.1
|
||||||
github.com/minio/kms-go/kms v0.6.0
|
github.com/minio/kms-go/kms v0.6.0
|
||||||
github.com/minio/madmin-go/v3 v3.0.110
|
github.com/minio/madmin-go/v3 v3.0.110
|
||||||
github.com/minio/minio-go/v7 v7.3.1-0.20260915093545-32e1f32cb176
|
github.com/minio/minio-go/v7 v7.4.1
|
||||||
github.com/minio/mux v1.10.1
|
github.com/minio/mux v1.10.1
|
||||||
github.com/minio/selfupdate v0.6.0
|
github.com/minio/selfupdate v0.6.0
|
||||||
github.com/minio/simdjson-go v0.4.5
|
github.com/minio/simdjson-go v0.4.5
|
||||||
@@ -81,8 +81,8 @@ require (
|
|||||||
github.com/nats-io/stan.go v0.10.4
|
github.com/nats-io/stan.go v0.10.4
|
||||||
github.com/ncw/directio v1.0.5
|
github.com/ncw/directio v1.0.5
|
||||||
github.com/nsqio/go-nsq v1.1.0
|
github.com/nsqio/go-nsq v1.1.0
|
||||||
github.com/pgsty/silo-pkg/v3 v3.14.1
|
github.com/pgsty/silo-pkg/v3 v3.14.3
|
||||||
github.com/philhofer/fwd v1.2.0
|
github.com/philhofer/fwd v1.2.1
|
||||||
github.com/pierrec/lz4/v4 v4.1.30
|
github.com/pierrec/lz4/v4 v4.1.30
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/pkg/sftp v1.13.11
|
github.com/pkg/sftp v1.13.11
|
||||||
@@ -97,21 +97,21 @@ require (
|
|||||||
github.com/rs/cors v1.11.1
|
github.com/rs/cors v1.11.1
|
||||||
github.com/secure-io/sio-go v0.3.1
|
github.com/secure-io/sio-go v0.3.1
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/tinylib/msgp v1.6.4
|
github.com/tinylib/msgp v1.6.5
|
||||||
github.com/valyala/bytebufferpool v1.0.0
|
github.com/valyala/bytebufferpool v1.0.0
|
||||||
github.com/xdg/scram v1.0.5
|
github.com/xdg/scram v1.0.5
|
||||||
github.com/zeebo/xxh3 v1.1.0
|
github.com/zeebo/xxh3 v1.1.0
|
||||||
go.etcd.io/etcd/api/v3 v3.7.1
|
go.etcd.io/etcd/api/v3 v3.7.2
|
||||||
go.etcd.io/etcd/client/v3 v3.7.1
|
go.etcd.io/etcd/client/v3 v3.7.2
|
||||||
go.uber.org/atomic v1.11.0
|
go.uber.org/atomic v1.11.0
|
||||||
go.uber.org/zap v1.28.0
|
go.uber.org/zap v1.28.0
|
||||||
go.yaml.in/yaml/v3 v3.0.5
|
go.yaml.in/yaml/v3 v3.0.5
|
||||||
goftp.io/server/v2 v2.0.3
|
goftp.io/server/v2 v2.0.3
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.58.0
|
||||||
golang.org/x/oauth2 v0.37.0
|
golang.org/x/oauth2 v0.37.0
|
||||||
golang.org/x/sync v0.23.0
|
golang.org/x/sync v0.24.0
|
||||||
golang.org/x/sys v0.48.0
|
golang.org/x/sys v0.49.0
|
||||||
golang.org/x/term v0.46.0
|
golang.org/x/term v0.47.0
|
||||||
golang.org/x/time v0.16.0
|
golang.org/x/time v0.16.0
|
||||||
google.golang.org/api v0.290.0
|
google.golang.org/api v0.290.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
@@ -164,7 +164,7 @@ require (
|
|||||||
github.com/fatih/structs v1.1.0 // indirect
|
github.com/fatih/structs v1.1.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
|
||||||
github.com/go-logr/logr v1.4.4 // indirect
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
@@ -282,7 +282,7 @@ require (
|
|||||||
github.com/xdg/stringprep v1.0.3 // indirect
|
github.com/xdg/stringprep v1.0.3 // indirect
|
||||||
github.com/xo/terminfo v1.0.0 // indirect
|
github.com/xo/terminfo v1.0.0 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.7.1 // indirect
|
go.etcd.io/etcd/client/pkg/v3 v3.7.2 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
|
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 // indirect
|
||||||
@@ -293,10 +293,10 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/mod v0.41.0 // indirect
|
golang.org/x/mod v0.42.0 // indirect
|
||||||
golang.org/x/net v0.60.0 // indirect
|
golang.org/x/net v0.61.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.43.0 // indirect
|
||||||
golang.org/x/tools v0.50.0 // indirect
|
golang.org/x/tools v0.52.0 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect
|
||||||
|
|||||||
@@ -186,10 +186,10 @@ github.com/fraugster/parquet-go v0.12.0/go.mod h1:dGzUxdNqXsAijatByVgbAWVPlFirnh
|
|||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
|
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
|
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -374,11 +374,11 @@ github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXw
|
|||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.14.4/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
github.com/klauspost/compress v1.14.4/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U=
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
github.com/klauspost/cpuid/v2 v2.4.1/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||||
github.com/klauspost/filepathx v1.1.1 h1:201zvAsL1PhZvmXTP+QLer3AavWrO3U1NILWpniHK4w=
|
github.com/klauspost/filepathx v1.1.1 h1:201zvAsL1PhZvmXTP+QLer3AavWrO3U1NILWpniHK4w=
|
||||||
@@ -474,8 +474,8 @@ github.com/minio/madmin-go/v3 v3.0.110 h1:FIYekj7YPc430ffpXFWiUtyut3qBt/unIAcDzJ
|
|||||||
github.com/minio/madmin-go/v3 v3.0.110/go.mod h1:WOe2kYmYl1OIlY2DSRHVQ8j1v4OItARQ6jGyQqcCud8=
|
github.com/minio/madmin-go/v3 v3.0.110/go.mod h1:WOe2kYmYl1OIlY2DSRHVQ8j1v4OItARQ6jGyQqcCud8=
|
||||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
github.com/minio/minio-go/v7 v7.3.1-0.20260915093545-32e1f32cb176 h1:TIrbAkJoMN/kbtV4LAitf/eidrbvvYcujXPbSe+4j/0=
|
github.com/minio/minio-go/v7 v7.4.1 h1:PtU/zIOfQRWZ+QydXAZysc+xZYHdG+BBR4Z6TUvy73w=
|
||||||
github.com/minio/minio-go/v7 v7.3.1-0.20260915093545-32e1f32cb176/go.mod h1:KUPWdecEO1LWyUz+sTGXAuf2jZHrPh5fCsRH86QbPfk=
|
github.com/minio/minio-go/v7 v7.4.1/go.mod h1:nQnQi7zTfXR4lREtYXZQWqo5aw0Rqv+ZqCGrTORygRk=
|
||||||
github.com/minio/mux v1.10.1 h1:grrK8SwRKbkNFE6qG7WAvFGH09bB46d5teOOtKfQ14s=
|
github.com/minio/mux v1.10.1 h1:grrK8SwRKbkNFE6qG7WAvFGH09bB46d5teOOtKfQ14s=
|
||||||
github.com/minio/mux v1.10.1/go.mod h1:INYT4sMSTJy0QWUEA/E2DZNxJ5sAxIwbnyZjkzNFRfE=
|
github.com/minio/mux v1.10.1/go.mod h1:INYT4sMSTJy0QWUEA/E2DZNxJ5sAxIwbnyZjkzNFRfE=
|
||||||
github.com/minio/pkg/v3 v3.6.1 h1:gaNT80BS/iuIany5ylTkVmfN4s6UYY30OtImFv4GQA8=
|
github.com/minio/pkg/v3 v3.6.1 h1:gaNT80BS/iuIany5ylTkVmfN4s6UYY30OtImFv4GQA8=
|
||||||
@@ -545,14 +545,14 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzb
|
|||||||
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||||
github.com/pgsty/mc v0.0.0-20260916070421-e952aa78f10a h1:FY1vcTQT67HGBe23ozlhOiQnWCzUCGmeSN5x6DT/kFs=
|
github.com/pgsty/mc v0.0.0-20261017101407-fa3b8bf72458 h1:f3kbkjtjj6D7PXmsIeKrv8phXncq1OVrxKM2e4I6rVk=
|
||||||
github.com/pgsty/mc v0.0.0-20260916070421-e952aa78f10a/go.mod h1:6z9lX8kOvaWz2vLlP7BjjF3JWHnIH86XDA2HwcmpZH8=
|
github.com/pgsty/mc v0.0.0-20261017101407-fa3b8bf72458/go.mod h1:n8NDi0J3hPUGCIM9tOxlhYXkYS9xiTVIxlxa3Rwx1cw=
|
||||||
github.com/pgsty/silo-console v0.0.0-20260916075814-1360e26d976d h1:A9ou1GkUckGZd3BOgEXeZ393nbYST967SKcI4Lgr/Vs=
|
github.com/pgsty/silo-console v0.0.0-20261017112417-ef2dd2c41d21 h1:PHxPswGiEwU9MVisu0/Ab7AcNX4IveiX1thQzUXOsM4=
|
||||||
github.com/pgsty/silo-console v0.0.0-20260916075814-1360e26d976d/go.mod h1:JS165CF2yoTBIx1X8k1IQ6vunJcZFG3pRZlWHJ8UT8o=
|
github.com/pgsty/silo-console v0.0.0-20261017112417-ef2dd2c41d21/go.mod h1:HFNaafu5iCO9bWS86PIyTbgF+yPD4phFt+gxML/1jJU=
|
||||||
github.com/pgsty/silo-pkg/v3 v3.14.1 h1:8MONT3Hky9EOnsuPk29590pEBE6MILaOnJtaejPPi7M=
|
github.com/pgsty/silo-pkg/v3 v3.14.3 h1:hMDxOo36F1whGRkYScrUGGwn98FvsmJ8hVM6pE4i8Bs=
|
||||||
github.com/pgsty/silo-pkg/v3 v3.14.1/go.mod h1:jxKWxi52jSsDaDhXXPqjOyPDYdxx2iqdBXyLfM/xSIw=
|
github.com/pgsty/silo-pkg/v3 v3.14.3/go.mod h1:zqIm3FMETmwPHRKuUExCDGfYkCNXt+vpfBnPM821RdI=
|
||||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pierrec/lz4/v4 v4.1.30 h1:cchX8N2DVP668WkElI9QMwVyoNabLkq1LofDHFeIrdg=
|
github.com/pierrec/lz4/v4 v4.1.30 h1:cchX8N2DVP668WkElI9QMwVyoNabLkq1LofDHFeIrdg=
|
||||||
github.com/pierrec/lz4/v4 v4.1.30/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
github.com/pierrec/lz4/v4 v4.1.30/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
@@ -650,8 +650,8 @@ github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
|||||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
|
||||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||||
@@ -686,12 +686,12 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
|||||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
||||||
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
||||||
go.etcd.io/etcd/api/v3 v3.7.1 h1:KJG0/DcWGfe3Y1otDf/fsBf0TSSgpxZ5RO/L8SFt73E=
|
go.etcd.io/etcd/api/v3 v3.7.2 h1:xgt/6el1LsPWWYNLkhMAK4tZm6dF+1sCqDecpE5gdbk=
|
||||||
go.etcd.io/etcd/api/v3 v3.7.1/go.mod h1:8bXIpCMeV7E3/XL0Ix123ATn3dB+0V7d9zklHbB0m78=
|
go.etcd.io/etcd/api/v3 v3.7.2/go.mod h1:RoRCBRt9BfBff1pIGZLUVMiz7wu3bY+b2qLysGu1HY4=
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.7.1 h1:rKYsj3pRkR0eK3yjT3XOgrhqfmIfj9pzNgxjh7mfFv4=
|
go.etcd.io/etcd/client/pkg/v3 v3.7.2 h1:SVtlR7tiSVAYOQ4nWPIyFXb4RMgEcnzeAG9RQ8MoNDU=
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.7.1/go.mod h1:cnzZGIUzSfjEwLC6UBVsSXlEK1eepS/JUD7wE6PLRT0=
|
go.etcd.io/etcd/client/pkg/v3 v3.7.2/go.mod h1:HsSux/B3ahgyw/D5+d4YbZqicOi0mEbuxm6lIUdjAoI=
|
||||||
go.etcd.io/etcd/client/v3 v3.7.1 h1:0PEMMC0KuZmVIN+RAbdqfkZ45pYTgKVtmBEbRCvZFUg=
|
go.etcd.io/etcd/client/v3 v3.7.2 h1:Z66GqDQDI7zPDfVSsIBqGSK4mJYLtv8ESwXa4mPf+wY=
|
||||||
go.etcd.io/etcd/client/v3 v3.7.1/go.mod h1:ffNqALa8tRCYhYo1F9oR489y23K39Gz+BSR3ApAGYq0=
|
go.etcd.io/etcd/client/v3 v3.7.2/go.mod h1:x03t1qMs4tGZirCDJlMuzPBJdQffXJImIyEjLhNBCsY=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
|
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
|
||||||
@@ -740,15 +740,15 @@ golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0
|
|||||||
golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||||
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
|
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A=
|
||||||
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
|
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
@@ -762,8 +762,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||||
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -773,8 +773,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -805,23 +805,23 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||||
@@ -831,8 +831,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
|||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ imagePullSecrets: []
|
|||||||
##
|
##
|
||||||
mcImage:
|
mcImage:
|
||||||
repository: pgsty/mc
|
repository: pgsty/mc
|
||||||
tag: RELEASE.2026-09-16T00-00-00Z
|
tag: RELEASE.2026-10-18T00-00-00Z
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
## Silo mode, i.e. standalone or distributed.
|
## Silo mode, i.e. standalone or distributed.
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const (
|
|||||||
|
|
||||||
EnvAPIStaleUploadsCleanupInterval = "MINIO_API_STALE_UPLOADS_CLEANUP_INTERVAL"
|
EnvAPIStaleUploadsCleanupInterval = "MINIO_API_STALE_UPLOADS_CLEANUP_INTERVAL"
|
||||||
EnvAPIStaleUploadsExpiry = "MINIO_API_STALE_UPLOADS_EXPIRY"
|
EnvAPIStaleUploadsExpiry = "MINIO_API_STALE_UPLOADS_EXPIRY"
|
||||||
EnvAPIMultipartListing = "MINIO_API_MULTIPART_LISTING"
|
EnvAPIMultipartListing = "MINIO_API_MULTIPART_LISTING" // Retired; used only for a startup warning.
|
||||||
EnvAPIDeleteCleanupInterval = "MINIO_API_DELETE_CLEANUP_INTERVAL"
|
EnvAPIDeleteCleanupInterval = "MINIO_API_DELETE_CLEANUP_INTERVAL"
|
||||||
EnvDeleteCleanupInterval = "MINIO_DELETE_CLEANUP_INTERVAL"
|
EnvDeleteCleanupInterval = "MINIO_DELETE_CLEANUP_INTERVAL"
|
||||||
EnvAPIODirect = "MINIO_API_ODIRECT"
|
EnvAPIODirect = "MINIO_API_ODIRECT"
|
||||||
@@ -180,7 +180,6 @@ type Config struct {
|
|||||||
TransitionWorkers int `json:"transition_workers"`
|
TransitionWorkers int `json:"transition_workers"`
|
||||||
StaleUploadsCleanupInterval time.Duration `json:"stale_uploads_cleanup_interval"`
|
StaleUploadsCleanupInterval time.Duration `json:"stale_uploads_cleanup_interval"`
|
||||||
StaleUploadsExpiry time.Duration `json:"stale_uploads_expiry"`
|
StaleUploadsExpiry time.Duration `json:"stale_uploads_expiry"`
|
||||||
MultipartListing string `json:"multipart_listing"`
|
|
||||||
DeleteCleanupInterval time.Duration `json:"delete_cleanup_interval"`
|
DeleteCleanupInterval time.Duration `json:"delete_cleanup_interval"`
|
||||||
EnableODirect bool `json:"enable_odirect"`
|
EnableODirect bool `json:"enable_odirect"`
|
||||||
GzipObjects bool `json:"gzip_objects"`
|
GzipObjects bool `json:"gzip_objects"`
|
||||||
@@ -209,7 +208,7 @@ func LookupConfig(kvs config.KVS) (cfg Config, err error) {
|
|||||||
apiReplicationWorkers,
|
apiReplicationWorkers,
|
||||||
apiReplicationFailedWorkers,
|
apiReplicationFailedWorkers,
|
||||||
"expiry_workers",
|
"expiry_workers",
|
||||||
apiMultipartListing, // Ignore the retired shared-config key during migration.
|
apiMultipartListing, // Tolerate the retired key without changing other API settings.
|
||||||
}
|
}
|
||||||
|
|
||||||
disableODirect := env.Get(EnvAPIDisableODirect, kvs.Get(apiDisableODirect)) == config.EnableOn
|
disableODirect := env.Get(EnvAPIDisableODirect, kvs.Get(apiDisableODirect)) == config.EnableOn
|
||||||
@@ -344,11 +343,5 @@ func LookupConfig(kvs config.KVS) (cfg Config, err error) {
|
|||||||
cfg.ObjectMaxVersions = math.MaxInt64
|
cfg.ObjectMaxVersions = math.MaxInt64
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.MultipartListing = env.Get(EnvAPIMultipartListing, "legacy")
|
|
||||||
if cfg.MultipartListing != "strict" && cfg.MultipartListing != "legacy" {
|
|
||||||
cfg.MultipartListing = "legacy"
|
|
||||||
return cfg, fmt.Errorf("%s must be strict or legacy; using legacy", EnvAPIMultipartListing)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,22 +11,21 @@ import (
|
|||||||
"github.com/minio/minio/internal/config"
|
"github.com/minio/minio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMultipartListingMigrationMode(t *testing.T) {
|
func TestMultipartListingRetiredConfiguration(t *testing.T) {
|
||||||
t.Setenv(EnvAPIRequestsMax, "")
|
t.Setenv(EnvAPIRequestsMax, "")
|
||||||
t.Setenv(EnvAPISyncEvents, "")
|
t.Setenv(EnvAPISyncEvents, "")
|
||||||
t.Setenv(EnvAPIObjectMaxVersions, "")
|
t.Setenv(EnvAPIObjectMaxVersions, "")
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name, stored, override, want string
|
name, stored, override string
|
||||||
invalid bool
|
|
||||||
}{
|
}{
|
||||||
{name: "default", want: "legacy"},
|
{name: "default"},
|
||||||
{name: "explicit-legacy", override: "legacy", want: "legacy"},
|
{name: "explicit-legacy", override: "legacy"},
|
||||||
{name: "explicit-strict", override: "strict", want: "strict"},
|
{name: "explicit-strict", override: "strict"},
|
||||||
{name: "retired-stored-strict", stored: "strict", want: "legacy"},
|
{name: "retired-stored-strict", stored: "strict"},
|
||||||
{name: "retired-stored-legacy", stored: "legacy", want: "legacy"},
|
{name: "retired-stored-legacy", stored: "legacy"},
|
||||||
{name: "retired-stored-invalid", stored: "automatic", want: "legacy"},
|
{name: "retired-stored-invalid", stored: "automatic"},
|
||||||
{name: "environment-override", stored: "legacy", override: "strict", want: "strict"},
|
{name: "environment-override", stored: "legacy", override: "strict"},
|
||||||
{name: "invalid-environment", stored: "strict", override: "automatic", want: "legacy", invalid: true},
|
{name: "invalid-environment", stored: "strict", override: "automatic"},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Setenv(EnvAPIMultipartListing, tc.override)
|
t.Setenv(EnvAPIMultipartListing, tc.override)
|
||||||
@@ -38,11 +37,11 @@ func TestMultipartListingMigrationMode(t *testing.T) {
|
|||||||
kvs.Set(apiMultipartListing, tc.stored)
|
kvs.Set(apiMultipartListing, tc.stored)
|
||||||
}
|
}
|
||||||
cfg, err := LookupConfig(kvs)
|
cfg, err := LookupConfig(kvs)
|
||||||
if (err != nil) != tc.invalid || cfg.MultipartListing != tc.want {
|
if err != nil {
|
||||||
t.Fatalf("mode=%q err=%v, want %q invalid=%v", cfg.MultipartListing, err, tc.want, tc.invalid)
|
t.Fatalf("retired setting must be ignored: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.RequestsMax != 17 || !cfg.SyncEvents || cfg.ObjectMaxVersions != 71 {
|
if cfg.RequestsMax != 17 || !cfg.SyncEvents || cfg.ObjectMaxVersions != 71 {
|
||||||
t.Fatalf("migration setting discarded unrelated config: %+v", cfg)
|
t.Fatalf("retired setting discarded unrelated config: %+v", cfg)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -58,7 +57,7 @@ func TestMultipartListingConfigPersistence(t *testing.T) {
|
|||||||
t.Cleanup(func() { config.DefaultKVS = previous })
|
t.Cleanup(func() { config.DefaultKVS = previous })
|
||||||
for _, help := range Help {
|
for _, help := range Help {
|
||||||
if help.Key == apiMultipartListing {
|
if help.Key == apiMultipartListing {
|
||||||
t.Fatal("process-only setting advertised as shared config")
|
t.Fatal("retired setting advertised as shared config")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, input := range []string{
|
for _, input := range []string{
|
||||||
@@ -82,11 +81,11 @@ func TestMultipartListingConfigPersistence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
kvs := loaded.Merge()[config.APISubSys][config.Default]
|
kvs := loaded.Merge()[config.APISubSys][config.Default]
|
||||||
if _, present := kvs.Lookup(apiMultipartListing); present {
|
if _, present := kvs.Lookup(apiMultipartListing); present {
|
||||||
t.Fatal("process-only setting persisted in shared config")
|
t.Fatal("retired setting persisted in shared config")
|
||||||
}
|
}
|
||||||
cfg, err := LookupConfig(kvs)
|
cfg, err := LookupConfig(kvs)
|
||||||
if err != nil || cfg.MultipartListing != "strict" {
|
if err != nil {
|
||||||
t.Fatalf("environment mode lost: %+v %v", cfg, err)
|
t.Fatalf("retired environment setting caused failure: %+v %v", cfg, err)
|
||||||
}
|
}
|
||||||
if input != "" && cfg.RequestsMax != 17 {
|
if input != "" && cfg.RequestsMax != 17 {
|
||||||
t.Fatalf("unrelated setting lost: %+v", cfg)
|
t.Fatalf("unrelated setting lost: %+v", cfg)
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// 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 dsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// retryTestLocker deliberately leaves unexpected methods unimplemented.
|
||||||
|
// These tests exercise the real DRWMutex retry and cleanup code without HTTP.
|
||||||
|
type retryTestLocker struct {
|
||||||
|
NetLocker
|
||||||
|
acquire func(context.Context, LockArgs) (bool, error)
|
||||||
|
release func(context.Context, LockArgs) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l retryTestLocker) Lock(ctx context.Context, args LockArgs) (bool, error) {
|
||||||
|
return l.acquire(ctx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l retryTestLocker) RLock(ctx context.Context, args LockArgs) (bool, error) {
|
||||||
|
return l.acquire(ctx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l retryTestLocker) Unlock(ctx context.Context, args LockArgs) (bool, error) {
|
||||||
|
return l.release(ctx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l retryTestLocker) RUnlock(ctx context.Context, args LockArgs) (bool, error) {
|
||||||
|
return l.release(ctx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l retryTestLocker) String() string { return "retry-test-locker" }
|
||||||
|
|
||||||
|
func newRetryTestMutex(lockers []NetLocker) *DRWMutex {
|
||||||
|
dm := NewDRWMutex(&Dsync{
|
||||||
|
GetLockers: func() ([]NetLocker, string) { return lockers, "retry-test-owner" },
|
||||||
|
Timeouts: DefaultTimeouts,
|
||||||
|
}, "retry-test-resource")
|
||||||
|
dm.rng = rand.New(&lockedRandSource{src: rand.NewSource(1)})
|
||||||
|
return dm
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryTestAcquire(ctx context.Context, dm *DRWMutex, read bool, opts Options) bool {
|
||||||
|
if read {
|
||||||
|
return dm.GetRLock(ctx, nil, "retry-test-uid", "retry-test", opts)
|
||||||
|
}
|
||||||
|
return dm.GetLock(ctx, nil, "retry-test-uid", "retry-test", opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDRWMutexRetryCancellation(t *testing.T) {
|
||||||
|
for _, read := range []bool{false, true} {
|
||||||
|
for _, opts := range []Options{
|
||||||
|
{Timeout: time.Hour},
|
||||||
|
{Timeout: time.Hour, RetryInterval: time.Hour},
|
||||||
|
} {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
var calls atomic.Int64
|
||||||
|
lockers := make([]NetLocker, 4)
|
||||||
|
for i := range lockers {
|
||||||
|
lockers[i] = retryTestLocker{acquire: func(context.Context, LockArgs) (bool, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return false, nil
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
dm := newRetryTestMutex(lockers)
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
done <- retryTestAcquire(ctx, dm, read, opts)
|
||||||
|
}()
|
||||||
|
synctest.Wait()
|
||||||
|
if got := calls.Load(); got != 4 {
|
||||||
|
t.Fatalf("first attempt made %d RPCs, want 4", got)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
synctest.Wait()
|
||||||
|
select {
|
||||||
|
case locked := <-done:
|
||||||
|
if locked {
|
||||||
|
t.Fatal("canceled waiter acquired lock")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Errorf("read=%v opts=%+v: cancellation did not interrupt retry wait", read, opts)
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
synctest.Wait()
|
||||||
|
if got := calls.Load(); got != 4 {
|
||||||
|
t.Fatalf("canceled waiter issued a new RPC attempt: %d calls", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDRWMutexRetryDeadline(t *testing.T) {
|
||||||
|
for _, interval := range []time.Duration{0, time.Hour} {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
locker := retryTestLocker{acquire: func(context.Context, LockArgs) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}}
|
||||||
|
dm := newRetryTestMutex([]NetLocker{locker})
|
||||||
|
start := time.Now()
|
||||||
|
if retryTestAcquire(t.Context(), dm, false, Options{Timeout: 10 * time.Millisecond, RetryInterval: interval}) {
|
||||||
|
t.Fatal("unavailable lock was acquired")
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed != 10*time.Millisecond {
|
||||||
|
t.Fatalf("retry wait overran deadline: %v", elapsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDRWMutexCanceledPartialAndLateGrants(t *testing.T) {
|
||||||
|
for _, read := range []bool{false, true} {
|
||||||
|
for _, slowRelease := range []bool{false, true} {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
lateGrant := make(chan struct{})
|
||||||
|
releaseGate := make(chan struct{})
|
||||||
|
if !slowRelease {
|
||||||
|
close(releaseGate)
|
||||||
|
}
|
||||||
|
var calls, releases atomic.Int64
|
||||||
|
var held [4]atomic.Bool
|
||||||
|
lockers := make([]NetLocker, len(held))
|
||||||
|
for i := range lockers {
|
||||||
|
lockers[i] = retryTestLocker{
|
||||||
|
acquire: func(ctx context.Context, _ LockArgs) (bool, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
if i > 0 {
|
||||||
|
<-lateGrant
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
t.Error("NetLocker acquisition unexpectedly inherits caller cancellation")
|
||||||
|
}
|
||||||
|
held[i].Store(true)
|
||||||
|
return true, nil
|
||||||
|
},
|
||||||
|
release: func(context.Context, LockArgs) (bool, error) {
|
||||||
|
if i == 0 {
|
||||||
|
<-releaseGate
|
||||||
|
}
|
||||||
|
held[i].Store(false)
|
||||||
|
releases.Add(1)
|
||||||
|
return true, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dm := newRetryTestMutex(lockers)
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() { done <- retryTestAcquire(ctx, dm, read, Options{Timeout: time.Hour}) }()
|
||||||
|
synctest.Wait()
|
||||||
|
if !held[0].Load() || calls.Load() != 4 {
|
||||||
|
t.Fatal("partial acquisition was not established")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
synctest.Wait()
|
||||||
|
if slowRelease {
|
||||||
|
// Cancellation of retry sleep is not a whole-call bound:
|
||||||
|
// synchronous cleanup of a partial grant can still block.
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
t.Fatal("acquisition returned before partial-grant cleanup completed")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
close(releaseGate)
|
||||||
|
synctest.Wait()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case locked := <-done:
|
||||||
|
if locked {
|
||||||
|
t.Fatal("canceled partial acquisition returned success")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Error("acquisition did not return after partial-grant cleanup")
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
if held[0].Load() || releases.Load() != 1 {
|
||||||
|
t.Fatal("partial grant was not released")
|
||||||
|
}
|
||||||
|
close(lateGrant)
|
||||||
|
synctest.Wait()
|
||||||
|
for i := range held {
|
||||||
|
if held[i].Load() {
|
||||||
|
t.Errorf("late grant on node %d was not released", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls.Load() != 4 || releases.Load() != 4 {
|
||||||
|
t.Fatalf("calls=%d releases=%d, want 4/4", calls.Load(), releases.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -260,14 +260,22 @@ func (dm *DRWMutex) lockBlocking(ctx context.Context, lockLossCallback func(), i
|
|||||||
return locked
|
return locked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var retryInterval time.Duration
|
||||||
switch {
|
switch {
|
||||||
case opts.RetryInterval < 0:
|
case opts.RetryInterval < 0:
|
||||||
return false
|
return false
|
||||||
case opts.RetryInterval > 0:
|
case opts.RetryInterval > 0:
|
||||||
time.Sleep(opts.RetryInterval)
|
retryInterval = opts.RetryInterval
|
||||||
default:
|
default:
|
||||||
attempt++
|
attempt++
|
||||||
time.Sleep(lockRetryBackOff(dm.rng, attempt))
|
retryInterval = lockRetryBackOff(dm.rng, attempt)
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(retryInterval)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return false
|
||||||
|
case <-timer.C:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// 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 hash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChecksumMismatchAlgorithm(t *testing.T) {
|
||||||
|
content := []byte("actual content")
|
||||||
|
for _, typ := range []ChecksumType{ChecksumCRC32, ChecksumCRC32C, ChecksumSHA1, ChecksumSHA256, ChecksumCRC64NVME} {
|
||||||
|
t.Run(typ.String(), func(t *testing.T) {
|
||||||
|
wrong := NewChecksumFromData(typ, []byte("wrong content"))
|
||||||
|
check := func(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
var mismatch ChecksumMismatch
|
||||||
|
if !errors.As(err, &mismatch) || mismatch.Algorithm != typ.String() {
|
||||||
|
t.Fatalf("error=%+v, want %s checksum mismatch", err, typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(t, wrong.Matches(content, 0))
|
||||||
|
parts := NewChecksumFromData(typ, content)
|
||||||
|
parts.WantParts = 2
|
||||||
|
check(t, parts.Matches(content, 1))
|
||||||
|
for _, mode := range []string{"header", "trailer", "malformed-trailer"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
reader, err := NewReader(t.Context(), bytes.NewReader(content), int64(len(content)), "", "", int64(len(content)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "http://localhost/bucket/object", nil)
|
||||||
|
if mode == "header" {
|
||||||
|
req.Header.Set(typ.Key(), wrong.Encoded)
|
||||||
|
} else {
|
||||||
|
req.Header.Set("x-amz-trailer", typ.Key())
|
||||||
|
req.Trailer = make(http.Header)
|
||||||
|
value := wrong.Encoded
|
||||||
|
if mode == "malformed-trailer" {
|
||||||
|
value = "!invalid-base64!"
|
||||||
|
}
|
||||||
|
req.Trailer.Set(typ.Key(), value)
|
||||||
|
}
|
||||||
|
if err = reader.AddChecksum(req, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = io.ReadAll(reader)
|
||||||
|
check(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -584,6 +584,7 @@ func (c Checksum) Matches(content []byte, parts int) error {
|
|||||||
sum := hasher.Sum(nil)
|
sum := hasher.Sum(nil)
|
||||||
if c.WantParts > 0 && c.WantParts != parts {
|
if c.WantParts > 0 && c.WantParts != parts {
|
||||||
return ChecksumMismatch{
|
return ChecksumMismatch{
|
||||||
|
Algorithm: c.Type.String(),
|
||||||
Want: fmt.Sprintf("%s-%d", c.Encoded, c.WantParts),
|
Want: fmt.Sprintf("%s-%d", c.Encoded, c.WantParts),
|
||||||
Got: fmt.Sprintf("%s-%d", base64.StdEncoding.EncodeToString(sum), parts),
|
Got: fmt.Sprintf("%s-%d", base64.StdEncoding.EncodeToString(sum), parts),
|
||||||
}
|
}
|
||||||
@@ -591,6 +592,7 @@ func (c Checksum) Matches(content []byte, parts int) error {
|
|||||||
|
|
||||||
if !bytes.Equal(sum, c.Raw) {
|
if !bytes.Equal(sum, c.Raw) {
|
||||||
return ChecksumMismatch{
|
return ChecksumMismatch{
|
||||||
|
Algorithm: c.Type.String(),
|
||||||
Want: c.Encoded,
|
Want: c.Encoded,
|
||||||
Got: base64.StdEncoding.EncodeToString(sum),
|
Got: base64.StdEncoding.EncodeToString(sum),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ func (e SizeMismatch) Error() string {
|
|||||||
|
|
||||||
// ChecksumMismatch - when content checksum does not match with what was sent from client.
|
// ChecksumMismatch - when content checksum does not match with what was sent from client.
|
||||||
type ChecksumMismatch struct {
|
type ChecksumMismatch struct {
|
||||||
|
// Algorithm carries the requested checksum type (e.g. "CRC32") so API
|
||||||
|
// error rendering can name it, matching S3's wording.
|
||||||
|
Algorithm string
|
||||||
Want string
|
Want string
|
||||||
Got string
|
Got string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,11 +302,12 @@ func (r *Reader) Read(p []byte) (int, error) {
|
|||||||
r.contentHash.Encoded = r.trailer.Get(r.contentHash.Type.Key())
|
r.contentHash.Encoded = r.trailer.Get(r.contentHash.Type.Key())
|
||||||
r.contentHash.Raw, err = base64.StdEncoding.DecodeString(r.contentHash.Encoded)
|
r.contentHash.Raw, err = base64.StdEncoding.DecodeString(r.contentHash.Encoded)
|
||||||
if err != nil || len(r.contentHash.Raw) == 0 {
|
if err != nil || len(r.contentHash.Raw) == 0 {
|
||||||
return 0, ChecksumMismatch{Got: r.contentHash.Encoded}
|
return 0, ChecksumMismatch{Algorithm: r.contentHash.Type.String(), Got: r.contentHash.Encoded}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sum := r.contentHasher.Sum(nil); !bytes.Equal(r.contentHash.Raw, sum) {
|
if sum := r.contentHasher.Sum(nil); !bytes.Equal(r.contentHash.Raw, sum) {
|
||||||
err := ChecksumMismatch{
|
err := ChecksumMismatch{
|
||||||
|
Algorithm: r.contentHash.Type.String(),
|
||||||
Want: r.contentHash.Encoded,
|
Want: r.contentHash.Encoded,
|
||||||
Got: base64.StdEncoding.EncodeToString(sum),
|
Got: base64.StdEncoding.EncodeToString(sum),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// 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 lsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLRWMutexRetryCancellation(t *testing.T) {
|
||||||
|
for _, read := range []bool{false, true} {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
lm := NewLRWMutex()
|
||||||
|
lm.Lock()
|
||||||
|
defer lm.Unlock()
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
if read {
|
||||||
|
done <- lm.GetRLock(ctx, "waiter", "retry-test", time.Hour)
|
||||||
|
} else {
|
||||||
|
done <- lm.GetLock(ctx, "waiter", "retry-test", time.Hour)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
synctest.Wait()
|
||||||
|
cancel()
|
||||||
|
synctest.Wait()
|
||||||
|
select {
|
||||||
|
case locked := <-done:
|
||||||
|
if locked {
|
||||||
|
t.Fatal("canceled waiter acquired lock")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Error("cancellation did not interrupt local retry wait")
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLRWMutexRetryDeadline(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
lm := NewLRWMutex()
|
||||||
|
lm.Lock()
|
||||||
|
defer lm.Unlock()
|
||||||
|
start := time.Now()
|
||||||
|
if lm.GetLock(t.Context(), "waiter", "retry-test", time.Millisecond) {
|
||||||
|
t.Fatal("unavailable lock was acquired")
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed != time.Millisecond {
|
||||||
|
t.Fatalf("local retry wait overran deadline: %v", elapsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -115,7 +115,13 @@ func (lm *LRWMutex) lockLoop(ctx context.Context, id, source string, timeout tim
|
|||||||
if lm.lock(id, source, isWriteLock) {
|
if lm.lock(id, source, isWriteLock) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
time.Sleep(time.Duration(r.Float64() * float64(lockRetryInterval)))
|
timer := time.NewTimer(time.Duration(r.Float64() * float64(lockRetryInterval)))
|
||||||
|
select {
|
||||||
|
case <-retryCtx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return false
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user