Compare commits

...

49 Commits

Author SHA1 Message Date
Feng Ruohang 41822710e4 deps: embed the Console bucket-search navigation repair
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-25 22:13:27 +08:00
Feng Ruohang 79eca5e920 deps: select the Console candidate with native keyboard regression coverage
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-25 21:50:10 +08:00
Feng Ruohang 475640e7e1 deps: embed the repaired Console 2.5.0 candidate
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-25 21:33:34 +08:00
Feng Ruohang 62124c779c docs: include coordinated Console dependency attributions
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-25 21:06:21 +08:00
Feng Ruohang 030c6d740e deps: integrate pkg 3.15.1 and Console 2.5.0 candidate for acceptance
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-25 21:01:22 +08:00
Feng Ruohang 5f1f4178ae fix: return current bucket inventory from account info
Signed-off-by: Feng Ruohang <rh@vonng.com>
(cherry picked from commit 6921f8c186)
2026-10-25 21:01:09 +08:00
Feng Ruohang 0828861bd0 iam: restore native policy immediately after resetting overrides
Signed-off-by: Feng Ruohang <rh@vonng.com>
(cherry picked from commit 1e3ab56b06)
2026-10-25 21:01:09 +08:00
Feng Ruohang d16f5ed9c8 fix(ilm): keep tier data when expiry only adds a delete marker (#253)
Preserve transitioned version data when lifecycle expiration only creates a delete marker, and apply suspended-versioning semantics consistently.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-24 17:46:10 +08:00
Feng Ruohang 6d890fc860 Merge pull request #255: preserve FULL_OBJECT checksum type in replication
Forward FULL_OBJECT checksum type for multipart CRC replication so the target validates the whole-object checksum correctly. Restore the existing single-PUT replication path for server-side copies retaining these checksums.

Fixes #254

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-24 17:39:38 +08:00
Feng Ruohang aafe4f8886 fix(pools): preserve authoritative copies across failed deletes (#257)
Preserve the latest logical object when cleanup of another pool fails, report each bulk error with its original replication identity, and support retries after partial physical deletion. Reuse existing tier-aware reconciliation to avoid premature remote-object collection.

Includes the original contribution and signed-off maintainer fixes.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-24 16:04:09 +08:00
Feng Ruohang a2073d2514 fix(pools): preserve shared tier references during failed deletes
Reuse deleteObjectReconciled for tiered bulk entries and unversioned single deletes. Its reference-aware cleanup avoids scheduling remote contents for garbage collection while another copy still needs them, including restored copies. Keep the normal local-data bulk path unchanged.

Extend existing tier GC regressions to bulk and unversioned deletes and include them in the multi-pool race CI step.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-24 15:47:51 +08:00
Feng Ruohang 3c82748e14 fix(pools): retain authoritative copies until bulk cleanup succeeds
Resolve each addressed version under the shared write lock, keep bulk I/O parallel within cleanup and authoritative-deletion stages, and preserve replication response identity on errors. Treat reliable absence as successful physical cleanup on retries without accepting it for marker or replication-state writes.

Cover execution-time faults, partial cleanup recovery, mixed authoritative pools, response identity, null marker ordering, metadata uncertainty, cancellation, and concurrent PUT. Run the delete consistency regressions under race in CI.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-24 15:36:07 +08:00
Feng Ruohang 1e94c5a30d Merge remote-tracking branch 'origin/main' into codex/pr257-delete-safety
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-24 15:13:21 +08:00
Feng Ruohang 000e653038 ci: use runner Python for repository card checks
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-24 12:42:50 +08:00
Feng Ruohang 945dc16060 Update SFTP and Go dependencies with object-operation regression coverage
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-24 11:50:39 +08:00
Denis Babchuk a30270d9ba fix(pools): put a delete marker into a pool that can take it
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
2026-10-23 22:29:49 +03:00
Denis Babchuk 1841fce4c4 fix(pools): keep the latest copy when a delete fails in one pool
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
2026-10-23 21:28:46 +03:00
Denis Babchuk f2b8d4b9cb fix(replication): forward FULL_OBJECT checksum type for multipart replicas
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
2026-10-23 17:52:58 +03:00
Denis Babchuk 8ed5c90dcd fix(ilm): keep tier data when expiry only adds a delete marker
Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
2026-10-23 17:36:21 +03:00
Feng Ruohang 789a2327ba docs: replace the single-node report with the maintained migration entry point
Keep the original PR #228 report at a fixed commit link with contributor credit. Direct operators to the bilingual migration guide and its recovery requirements, and record the merged contribution.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 19:27:20 +08:00
Feng Ruohang e3e0c6215d Merge remote-tracking branch 'origin/main'
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 19:16:00 +08:00
Feng Ruohang b91e1c6f2a Merge pull request #228 from yupoet/docs/single-node-upgrade-from-archived-minio
Preserve the single-node migration report contributed by @yupoet.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 19:15:50 +08:00
Feng Ruohang 969a6a42d3 build: package Windows SILO archives as ZIP
Use ZIP for Windows amd64 and arm64. Validate the platform archive matrix, executable names, documentation, SBOMs and checksum coverage in the release pipeline.

Validated with local GoReleaser snapshots, six SPDX SBOMs and packaging regression checks.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 16:13:15 +08:00
Feng Ruohang 1b5477f3df fix(iam): isolate policy reloads and propagate KMS cancellation
Release the authentication-cache lock while retaining IAM write ordering. Carry request cancellation through IAM and SSE-S3 list decryption, and publish policy replacements only after a complete successful load.

Preserve caller deadlines for slow policy queries and keep the existing etcd read budget scoped to its RPC. Cover concurrent refresh, updates, revocation, cancellation, and deadline compatibility.

Validation: 36 focused and 23 adversarial scenarios each passed three race runs; real 32-second object/etcd probes passed. Full suite passed with the Go DNS resolver after reproducing native DNS failures on the baseline; existing etcd suites, make verifiers, and make build also passed.
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 15:50:12 +08:00
Feng Ruohang a07d655dc6 Merge PR #250 while preserving local dependency updates
Retain the merged upstream LDAP fix and original contributor history. Keep the existing Go/dependency refresh and published-release documentation; combine the changelog credit without repeating the Go 1.27.3 note.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 11:22:52 +08:00
Feng Ruohang d43adc0f2b Merge pull request #250 from 910patin/fix/ldap-long-dn
Restore long-DN LDAP login while preserving IAM revision read errors.

Retain the original contribution by @910patin, add the bounded ASCII component guard and regression tests, and require the patched Go 1.27.3 toolchain. DCO, Go CI, VulnCheck and release snapshot validation passed on the final PR head.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 11:21:50 +08:00
Feng Ruohang 3df51549bb build: require Go 1.27.3 for patched certificate verification
The PR CI found GO-2026-6809 in the remote main baseline using Go 1.27.2. Require the patched toolchain already used for local acceptance; keep module versions and the separate dependency refresh unchanged.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 11:04:31 +08:00
Feng Ruohang 324bb308dd docs: credit LDAP long-DN fix contributor
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 10:53:41 +08:00
Feng Ruohang 625c11e7e1 fix(iam): preserve revision errors for storable record names
Only classify a valid IAM key as absent when a length error accompanies an ASCII path component longer than 255 bytes. Normalized LDAP DNs satisfy this condition, while total-path errors and Unicode names can refer to existing revocation records.

Cover component boundaries, wrapped and storage errors, and real revocation records moved beneath a deep Darwin drive path.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 10:35:31 +08:00
Feng Ruohang 4059c25e22 docs: reconcile published releases and unreleased dependencies
Replace stale draft status with the published 20261018 source boundary and
summarize the subsequent Go, dependency, and compatibility changes. Keep
historical details in the canonical English and Chinese release records.

Validated new links and anchors and the rebrand compatibility guard.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 02:15:22 +08:00
Feng Ruohang 011b0df5f5 build: update Go to 1.27.3 and refresh dependencies
Update the server and five debugging modules, regenerate dependency credits,
and refresh static curl and the native Red Hat CA mitigation.

Preserve Kafka's default broker protocol and terminal widths. Copy the
returned DARE plaintext when unsealing legacy keys, and cover old encrypted
keys, Kafka producer exchanges, and MinKMS TLS identity with regression tests.
Keep the published PGSTY component references for reproducible builds.

Validated with make test, targeted race tests, module integrity and tidy
checks, and govulncheck v1.9.0 source analysis. Rechecked the frozen source
and artifact hashes behind the earlier whole-stack compatibility evidence.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-23 02:15:16 +08:00
GL 9faeb7a114 fix(iam): treat IAM record names too long for the drive as absent
Since RELEASE.2026-09-16T00-00-00Z, LDAP users whose normalized DN is
longer than 255 bytes cannot log in. NormDN hex-escapes every non-ASCII
byte (go-ldap DN.String()), so one Cyrillic letter takes six characters.
IAM looks up records named after the DN: the parent revision check added
with cmd/iam-revision.go reads config/iam/users/<DN>/identity.json, and
PolicyDBGet reads config/iam/policydb/sts-users/<DN>.json when the
mapping is not cached.

A drive rejects such an element in one of two ways. checkPathLength
refuses more than 255 counted characters, but outside Windows it never
counts a backslash, so an escaped DN usually passes it. The filesystem
then fails with ENAMETOOLONG once the parent directory exists.
toObjectErr reports both as ObjectNameInvalid, and the callers treat
only errConfigNotFound as "no record". As a result,
AssumeRoleWithLDAPIdentity, access-key creation and STS loading fail.

Such a record cannot have been stored. When the object layer's own name
validation (checkBucketAndObjectNames) accepts the path,
ObjectNameInvalid can only mean a length rejection, so report it as
errConfigNotFound. Invalid names and storage errors are returned
unchanged, and saving such a record still fails. The etcd store has no
such limit and is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: GL <g@lopatin.info>
2026-10-19 18:49:20 +03:00
Feng Ruohang 856f8f4c55 docs: refresh release and contributor acknowledgments
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-19 07:52:26 +08:00
Feng Ruohang 0cd388c666 fix(iam): persist and propagate scoped STS revocation
Enumerate durable STS identities for parent/type revocation, retain the
operation cutoff, and notify sibling caches for each committed deletion.
Repair missed notifications during periodic object/etcd IAM refresh.

Carry immutable STS tombstones and expiry through the existing IAM revision
protocol and deletion healing. Delayed creation cannot undo revocation,
while unrelated and newly issued keys stay valid. Keep partial failures
visible while propagating the successfully committed deletions.

Cover uncached credentials, warm sibling caches, dropped notifications,
object/etcd backends, reordered replay, concurrent issuance and partial
persistence failure. No new dependencies or authentication-path I/O.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 22:39:12 +08:00
Feng Ruohang 1748f62c7e fix(pools): preserve delete markers and recheck migration cleanup
Move solitary and null delete markers instead of dropping them, including
idempotent UUID marker retries. Check that a reader's source version still
exists before moving it, and preserve the established read winner for
conflicting timestamp ties.

Retire only the versions that were successfully moved while holding the
pooled object lock. Recheck the source and retained copies before cleanup,
so concurrent deletes and uncertain destination reads cannot revive data.
Count remaining markers when verifying decommission completion.

Add two/three-pool walker, history, purge, concurrent-delete, read-fault,
and timestamp-tie regressions.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 22:39:12 +08:00
Denis Babchuk 60c7179ddb fix(pools): do not let data movement overwrite a newer copy
Apply PR #248 from bca40a4ac9.

Signed-off-by: Denis Babchuk <babchukdenis@icloud.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 22:39:07 +08:00
Feng Ruohang 7007c164a5 build: remove ripgrep from rebrand verification
Use the existing Git tool to scan source files, including untracked files and exported source trees. Preserve hidden-file exclusions and worktree ignore rules, and reject scanner diagnostics before filtering matches. Remove the CI installation step.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 09:50:18 +08:00
Feng Ruohang 51b7de6a0d ci: install ripgrep for rebrand verification
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 08:46:30 +08:00
Feng Ruohang 4733e1423c fix(multipart): defer persistent listing from public requests
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 08:36:58 +08:00
Feng Ruohang 4189869e43 Merge multipart parent cleanup fix
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 08:22:31 +08:00
Feng Ruohang 0f9199e0c3 fix(multipart): remove empty upload parents promptly
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 08:21:35 +08:00
Feng Ruohang bb60871f57 build: update silo-pkg to v3.14.3
Adopt the published policy-evaluation optimization and record the source update separately from existing component releases.

Verified with repository checks, builds and relevant race tests. No component release is created.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 07:52:13 +08:00
Feng Ruohang 2c820895d7 fix(buildscripts): fail rebrand verification when ripgrep is missing
Check the existing ripgrep prerequisite before searches whose conditional
status handling would otherwise hide command-not-found failures. Preserve
untracked-file coverage and verification from a source archive without Git.

Addresses the missing-tool finding recorded in commit
4fb3a7fa3b175727db987f3ba2523c84f74d4168 without its scan-scope changes.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 07:23:05 +08:00
Feng Ruohang 2080b3d031 test(listing): cover mixed object and directory recovery groups
Extend the pending-directory collision test with absent recovery and a smaller
key. Add all six normal-object, directory, and pending-object channel orders,
checking complete-group recovery and preservation of trailing entries.

Test changes adapted from commit 557bc274a32330321fa5e5ac7636068c7bd98eb0.
Retain the complete-group directory handling from 3dd53eb81f.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 07:23:05 +08:00
Feng Ruohang 9954392605 fix(iam): normalize bulk self access-key listings
Use the existing self-service authorization helper for policyless regular
users and normalize explicit service-account and STS aliases to their parent.
Keep explicit denials and cross-user authorization intact. Assert decrypted
identity contents and negative boundaries in the existing handler tests.

Adapted from commit 5d0b692d8cd387622a3219e72430dffaaf5a25ce.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-18 07:23:05 +08:00
Feng Ruohang 3dd53eb81f fix(listing): preserve directories after absent object recovery
Signed-off-by: Feng Ruohang <rh@vonng.com>
(cherry picked from commit 1445fc1d85d5166de24c7a378cee3479cfc95a43)
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-17 21:25:01 +08:00
Feng Ruohang 86fb2b833f Prepare SILO 20261018 with the coordinated October dependency stack
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-17 19:28:33 +08:00
Feng Ruohang fbcb511978 Merge pull request #239: confirm unresolved LIST groups
Confirm eligible unresolved LIST groups without changing S3 parameters, storage formats or ordinary writer routing. Preserve original PR #221 commits and authorship.

All eight PR checks, local full/race tests and the nonpublishing snapshot pipeline pass. Current-source normal and rolling correctness pass. The strict 5% performance qualification remains inconclusive and is documented separately from the source-merge decision; no production release is authorized.

Closes #218.

Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-10-17 11:57:20 +08:00
Paris Yu fec1e0804a docs: add field-validated single-node upgrade guide from archived MinIO community edition
The compatibility audit does not cover the single-node/single-drive (FS mode)
case explicitly. This documents a production upgrade from the final community
release (RELEASE.2025-10-15T17-29-55Z) to Silo RELEASE.2026-09-16T00-00-00Z:
why standalone deployments are low-risk (schema retention, no multi-node REST
concern, IAM tightening not applicable), the validated procedure with layered
verification, field results (~5.9k objects, zero migration, zero hardening
rejections, full app regression passed), and the post-upgrade behavior changes
to review per client (TLS defaults, If-Match on DeleteObject, multipart listing
cap, TRUSTED_PROXIES default).
2026-09-26 19:41:43 +08:00
91 changed files with 7575 additions and 1549 deletions
+4
View File
@@ -27,6 +27,10 @@ archives:
ids: ids:
- silo - silo
name_template: "silo_{{ .Env.PKG_VERSION }}_{{ .Os }}_{{ .Arch }}" name_template: "silo_{{ .Env.PKG_VERSION }}_{{ .Os }}_{{ .Arch }}"
formats: ["tar.gz"]
format_overrides:
- goos: windows
formats: ["zip"]
# Explicit so the license materials cannot silently drop out of the # Explicit so the license materials cannot silently drop out of the
# binary archives: GoReleaser's default file globs would miss NOTICE. # binary archives: GoReleaser's default file globs would miss NOTICE.
# CREDITS stays out deliberately -- at 1.8MB it would dominate the # CREDITS stays out deliberately -- at 1.8MB it would dominate the
+7 -2
View File
@@ -93,8 +93,13 @@ 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 multi-pool delete consistency tests under race detector
run: go test -race ./cmd -run '^Test(MultipartListing|MultipartAbort|PaginateMultipartUploads|ListMultipartUploads)' -count=1 -timeout=5m run: go test -race ./cmd -run '^Test(Pools(Delete|BulkDelete|RetiringCopyPreservesSharedTierObject)|BulkDeleteQuorumEvidence)' -count=1 -timeout=5m
- name: Run multipart listing, cancellation and cleanup tests under race detector
env:
MINIO_API_REQUESTS_MAX: "10000"
run: go test -race ./cmd -run '^Test(Multipart|PaginateMultipartUploads|ListMultipartUploads|CleanupStaleUploads|ExtractModTime)' -count=1 -timeout=5m
- name: Run listing metadata recovery, pagination and cancellation tests under race detector - 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 run: go test -race ./cmd -run '^Test(ListFallback|ListingCache|ListingMerge|ListingRecovery|ListingPending|ListingEntry|ListingSecondLock|StreamMetadataParts|LocalLockerListing|DistributedListing|MergeXLV2LatestNullCrossParity|CountXLV2MixedECGroups|ListCrossParity)' -count=1 -timeout=5m
+2 -2
View File
@@ -140,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 }}
@@ -156,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
+10 -8
View File
@@ -34,10 +34,11 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - name: Use the runner's Python in an isolated environment
with: run: |
python-version: "3.13" python3 -m venv "$RUNNER_TEMP/repository-cards-venv"
- run: python -m pip install -r buildscripts/repository-cards/requirements.txt "$RUNNER_TEMP/repository-cards-venv/bin/python" -m pip install -r buildscripts/repository-cards/requirements.txt
echo "$RUNNER_TEMP/repository-cards-venv/bin" >> "$GITHUB_PATH"
- run: python -m unittest discover -s buildscripts/repository-cards -p 'test_*.py' -v - run: python -m unittest discover -s buildscripts/repository-cards -p 'test_*.py' -v
publish: publish:
@@ -54,10 +55,11 @@ jobs:
OUTPUT_BRANCH: codex/repository-cards OUTPUT_BRANCH: codex/repository-cards
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - name: Use the runner's Python in an isolated environment
with: run: |
python-version: "3.13" python3 -m venv "$RUNNER_TEMP/repository-cards-venv"
- run: python -m pip install -r buildscripts/repository-cards/requirements.txt "$RUNNER_TEMP/repository-cards-venv/bin/python" -m pip install -r buildscripts/repository-cards/requirements.txt
echo "$RUNNER_TEMP/repository-cards-venv/bin" >> "$GITHUB_PATH"
- name: Load the generated-assets branch - name: Load the generated-assets branch
shell: bash shell: bash
+28 -7
View File
@@ -60,7 +60,7 @@ jobs:
docker build --build-arg TARGETARCH=${{ matrix.arch }} \ docker build --build-arg TARGETARCH=${{ matrix.arch }} \
--target curl-runtime -f Dockerfile.goreleaser -t silo-curl-test . --target curl-runtime -f Dockerfile.goreleaser -t silo-curl-test .
docker run --rm silo-curl-test --version | tee curl-version.txt docker run --rm silo-curl-test --version | tee curl-version.txt
grep -F 'curl 8.22.0 ' curl-version.txt grep -F 'curl 8.23.0 ' curl-version.txt
grep -F 'HTTP2' curl-version.txt grep -F 'HTTP2' curl-version.txt
docker run --rm silo-curl-test --fail --silent --show-error \ docker run --rm silo-curl-test --fail --silent --show-error \
--connect-timeout 15 --max-time 60 https://curl.se/robots.txt --connect-timeout 15 --max-time 60 https://curl.se/robots.txt
@@ -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.
@@ -120,13 +120,34 @@ jobs:
LDFLAGS: ${{ env.LDFLAGS }} LDFLAGS: ${{ env.LDFLAGS }}
PKG_VERSION: ${{ env.PKG_VERSION }} PKG_VERSION: ${{ env.PKG_VERSION }}
- name: Verify archive SBOM and checksum coverage - name: Verify archive formats, documentation, SBOMs, and checksums
run: | run: |
set -euo pipefail set -euo pipefail
mapfile -t archives < <(find dist -maxdepth 1 -type f -name 'silo_*.tar.gz' | sort) mapfile -t archives < <(find dist -maxdepth 1 -type f \( -name 'silo_*.tar.gz' -o -name 'silo_*.zip' \) | sort)
mapfile -t sboms < <(find dist -maxdepth 1 -type f -name 'silo_*.tar.gz.sbom.json' | sort) mapfile -t sboms < <(find dist -maxdepth 1 -type f \( -name 'silo_*.tar.gz.sbom.json' -o -name 'silo_*.zip.sbom.json' \) | sort)
test "${#archives[@]}" -eq 6 test "${#archives[@]}" -eq 6
test "${#sboms[@]}" -eq 6 test "${#sboms[@]}" -eq 6
for os in linux darwin windows; do
format=tar.gz
binary=silo
if [ "${os}" = windows ]; then
format=zip
binary=silo.exe
fi
for arch in amd64 arm64; do
archive="dist/silo_${PKG_VERSION}_${os}_${arch}.${format}"
test -s "${archive}"
jq -e '.spdxVersion | startswith("SPDX-")' "${archive}.sbom.json" >/dev/null
if [ "${format}" = zip ]; then
listing="$(unzip -Z1 "${archive}")"
else
listing="$(tar -tzf "${archive}")"
fi
for member in "${binary}" LICENSE NOTICE README.md; do
grep -Fx "${member}" <<< "${listing}"
done
done
done
manifest="dist/silo_${PKG_VERSION}_checksums.txt" manifest="dist/silo_${PKG_VERSION}_checksums.txt"
test -s "${manifest}" test -s "${manifest}"
( (
@@ -143,7 +164,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
+1 -1
View File
@@ -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
+112 -204
View File
@@ -2,221 +2,129 @@
## Unreleased ## Unreleased
Preparation target: `RELEASE.2026-09-16T00-00-00Z` (package version - Fix LDAP login for long normalized DNs without hiding unreadable IAM
`20260916000000.0.0`). The entries below describe the candidate changes since revocation records. Only length errors for valid keys with an ASCII path
the latest published Server. component over 255 bytes are treated as absent; long-DN writes still fail.
**The latest published Server remains 20260903.** These changes are not in its Thanks to [@910patin](https://github.com/910patin) for the original fix and
binaries, packages or images. See the [component matrix](https://silo.pgsty.com/compatibility/versions/) reproduction in [#250](https://github.com/pgsty/silo/pull/250).
and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-09-03T13-18-01Z...main). - Build the server and standalone debugging tools with Go 1.27.3 and refresh
Go dependencies, including silo-pkg v3.15.1, the published mcli 20261025
source and upstream minio-go `ab93db7ba435` for multipart, read/seek and
batch-delete fixes. Retain output-format and NetBSD compatibility selections.
- Embed the unreleased Console 2.5.0 candidate `258717be077b`, including its
GUI update, diagnostic WebSocket closing handshake and complete health-report
decoding. This selects a source commit, not a new Console release.
- Read current bucket inventory in account-info responses so Console refreshes
immediately reflect successful creation and deletion. This management endpoint
now performs a live list instead of returning a cached inventory.
- Restore the native built-in policy immediately after resetting an override.
Peer and etcd notifications reload committed state, so delayed delete events
preserve subsequent edits. Keep deletion revisions, policy-in-use checks and
custom-policy deletion behavior; storage failures retain the previous policy.
- Preserve Kafka's default broker protocol 2.1 and terminal-width behavior;
fix legacy DARE v1 key unsealing and verify DARE v1/v2 and MinKMS TLS compatibility.
- Update bundled curl to 8.23.0 and require patched static libraries. Apply
[Red Hat's compromised-CA mitigation](https://access.redhat.com/security/vulnerabilities/RHSB-2026-012);
the root blocklist also rejects legitimate chains under the affected roots.
### Authorization and security These source changes are not included in the published 20261018 artifacts.
- Synchronize CPU metrics reads with resource-metrics updates (#210), preventing ## RELEASE.2026-10-18T00-00-00Z
concurrent map access from terminating the server during Prometheus scraping.
Metric names, values and authentication requirements are unchanged.
- Restrict embedded Console's anonymous sharing proxy to object-content GETs
at the configured S3 origin, and reject every redirect. Internal metrics,
system paths and non-download S3 operations cannot be reached through it.
Normal public, presigned and versioned downloads remain available without a
new setting; a full sharing-disable switch is not introduced. See
[Console #56](https://github.com/pgsty/silo-console/pull/56) and the
[design record](https://github.com/pgsty/silo-console/issues/52).
Thanks to Jiri Pejchal (@jiri-pejchal) for the report.
- Persist IAM deletion revisions and parent revocation boundaries so stale site
events cannot restore deleted identities, policies or their older grants
(#191, #192). Peer deletion notifications reload committed storage; deliberate
recreation requires a newer revision, and credentials issued before the
parent's revocation remain invalid.
**Coordinated upgrade required:** upgrade every participating node and site.
Mixed old/new nodes sharing an IAM backend and rolling downgrade are
unsupported. Back up complete IAM storage and encryption material; an admin
export of live records omits deletion history. Reissue credentials for
recreated parents and explicitly reconcile pre-upgrade revocations whose
history is already lost. Restoring an older backup can lose later revocations;
keep affected sites isolated until reconciliation/rekeying is complete. See
[the operator runbook](https://silo.pgsty.com/operations/replication/iam-upgrade/).
- Enforce an absolute HTTP/1 request-header deadline through the connection
wrapper (#196). Repeated small reads no longer extend that deadline, and
`--read-header-timeout` / `MINIO_READ_HEADER_TIMEOUT` now reaches the HTTP
server. HTTP/1 request bodies retain the rolling idle timeout; this does not
impose a total upload/download duration. A shorter setting also constrains
TLS handshake reads. The wrapper's strict header mode is not applied to HTTP/2.
- Reject unsigned `x-amz-*` request headers that could turn a signed PUT into a
copy of another object accessible to the signer (SN-2026-011). The latest
public Server is affected; the fix is on main. See [the advisory ledger](https://silo.pgsty.com/about/security-advisories/).
- Align signed request fields with policy conditions and enforce header-only
presigned payload checksums. See [the signed-header review](https://silo.pgsty.com/blog/design/signed-header-coverage/).
- **Breaking policy semantics:** separate self-service `admin:ChangeMyPassword`
from `admin:CreateUser`. Built-in read-only policies follow the split. Preserve
both denies if the previous combined restriction must survive upgrades or
rollback. Saved policies are not rewritten. Deploy with the matching Console
and pkg; see [the migration guide](docs/iam/password-permissions.md).
### Object storage and replication Package version: `20261018000000.0.0`; published on 2026-10-18 from source
`0cd388c666f8211f552604ae73448a066e9c25ce`. This section covers the changes
after the public 20260916 source, `2a4d51406b7ed87af5fe6fe0f801f3290f96eb3c`.
- Make durable `ListMultipartUploads` scanning the only listing path (#79): Full [English release notes](https://silo.pgsty.com/blog/release/silo-20261018/)
apply prefix, delimiter, markers, ordering and the 1,000-entry page limit and [Chinese release notes](https://silo.pgsty.com/zh/blog/release/silo-20261018/)
globally across pools and sets, independently of node-local caches. Omit cover the upgrade contract, evidence and remaining limits.
readable historical uploads only when both bucket/key identity fields are
absent; malformed identities, uncertain coverage and resource limits remain
errors. Old uploads still support part upload, completion and cancellation
with a known bucket/key/upload ID. No backfill or new storage layout is added.
`MINIO_API_MULTIPART_LISTING` is retired and ignored with a startup warning;
historical shared `api multipart_listing` keys remain tolerated. The read-only
admin `multipart-preflight` reports `mode=persistent`; `ready` diagnoses the
absence of old records and no longer gates listing. Each page still scans
durable state within the existing 100,000-directory-entry, 30-second and
per-process concurrency budgets. See the
[design and limits](https://silo.pgsty.com/blog/design/list-multipart-uploads/#implementation).
Thanks to mr javad seydi (@mrjavadseydi) for the original metadata-and-scan
implementation (#198).
- Keep the default read-quorum, best-effort multipart cancellation and
first-matching-pool behavior when retiring the listing switch. The optional
strict cancellation branch is removed. Wrong-target protection, upload-ID
path validation and peer notification with the request context remain.
HTTP 204 for an absent upload remains idempotent and does not prove physical
cleanup. **Known boundary:** delayed creation writes can still restore a
readable upload after cancellation; this change adds no creation fence.
- Preserve object tags during multi-pool metadata reconciliation by reading the
resolved tag field together with its revision (#189). Previously, reconciliation
could replace existing tags with an empty value.
- Preserve the tag revision on SSE-KMS metadata replication (#193), and advance
tag revisions monotonically on local PUT/DELETE tagging (#196). Empty tags
participate in reconciliation as an ordered deletion, preventing older
events from restoring removed tags. SSE-C key rotation also retains the tag
revision. Malformed historical revisions can fail and retry; their missing
history is not reconstructed by the upgrade.
- Complete delete-marker version purges and preserve their identity and retry
state through MRF recovery (#196). Recovery accepts a 405 marker response only
when its version, bucket, object name and modification time match the task.
Purge audit status is normalized from `COMPLETE` to `COMPLETED`.
Thanks to Julien Laurenceau (@julienlau) for the investigation and proposed
fix in #184 that helped shape this follow-up.
- Restore only the six replication-specific metadata fields after ordinary
request metadata extraction (#194). This prevents transport-only `aws-chunked`
from being stored as Content-Encoding while preserving the signed-header
protections. Trusted Snowball entries no longer inherit the outer archive's
ordinary metadata. Thanks to Mikhail Khadarenka (@chodorenko) for the fix in #187.
**Existing data:** these repairs prevent new errors; they do not scan or rewrite
historical object metadata, recover lost tags or prove that old purge work has
converged. Follow the [read-only audit procedure](https://silo.pgsty.com/operations/replication/replica-metadata-audit/)
before planning any repair of stored state.
- Make exact-version delete-marker purges converge in replicated buckets - **Object Lock (#234, #235):** require `s3:PutObjectRetention` with request-body
(`eb4f5e5b3`, `254b19ac0`, `358ab38fb`). A purge no longer creates the conditions before object lookup; weakening active GOVERNANCE additionally
marker on drives that lacked it; a retried purge of a missing version is requires bypass intent and permission. Protect caller-addressed write versions,
acknowledged only when a write-quorum majority of drives report it absent; directory null versions and persisted multipart targets under the write lock;
purge results merge removed and reliably absent replies; healing a marker fail closed on unreadable lock state. See SN-2026-015/016 in the
preserves its stored replication and purge metadata; and a queued marker [advisory ledger](https://silo.pgsty.com/about/security-advisories/).
creation is re-checked against the source under the replication lock before - **IAM Admin authorization (#242, `995439260`):** enforce explicit self denies,
it is sent, so a purge that already reached the targets is not undone by a session restrictions and plugin decisions; prevent restricted delegation and
stale task from another frontend, a GET/LIST heal, the scanner or MRF. non-root mutation of root-derived credentials under built-in authorization.
Purging a data version whose earlier purge is still pending reports it as a Preserve empty-policy inheritance and damaged-key cleanup. Normalize bulk
data version. **Known limitations:** creations already in flight or replayed self-list aliases and allow ordinary policyless self inspection. See
from another site, and minority marker copies left by a crash after a [SN-2026-017](https://silo.pgsty.com/blog/security/20261018-release-hardening/#sn-2026-017).
majority-acknowledged purge, are tracked in #217. See - **Scoped STS revocation (`0cd388c66`):** enumerate durable parent/type-selected
[the replication reliability record](https://silo.pgsty.com/blog/design/replication-reliability/). tokens, propagate each committed tombstone to sibling nodes and sites, and
- Keep a null object version that has listing quorum when a newer minority of recover missed notifications through existing refresh/healing. Tokens issued
drives sorts first (`8d06424b1`). The resolver recounts per header only when only at an isolated site are outside the requesting cluster's snapshot;
the original selection lacks quorum, every non-empty drive stream holds propagation is eventual and newly issued keys remain valid.
exactly one ordinary null version and all share the same erasure layout; - **Pool migration (#248, `60c7179dd`, `1748f62c7`):** prevent stale moved
mixed histories keep their previous behavior. **Known limitation:** a payloads from overwriting newer copies, preserve null/UUID delete markers,
successful ListObjects can still omit readable keys during rolling restarts recheck source versions and clean only confirmed moved versions under the
with concurrent overwrites (#218). Do not run destination-deleting sync tools object lock. Added per-pool reads and per-version cleanup have maintenance
against a listing taken during a rolling restart; list again once the costs; earlier data loss is not reconstructed.
cluster is stable.
- Carry object tags through rebalance and decommission for ordinary and
multipart writes (`fced86303`). Both migration entry points restore the tags
and their revision fields when rewriting the object in the destination pool.
Tags dropped by earlier migrations are not recovered; audit tag-dependent
lifecycle and policy rules for pools migrated with an older build.
- Evaluate conditional multipart completion against the logical current object - **LIST recovery (#239, `3dd53eb81`, `2080b3d03`):** confirm eligible uncertain
across all pools while holding the existing object lock. A stale `If-Match` latest-object groups under the appropriate read locks, distinguish absence
can no longer replace newer data in another pool, and the current ETag is no from failure, preserve directory/common-prefix entries and terminal pagination
longer rejected because the upload resides next to an older copy. Conditions errors, and make lock backoff cancelable. Resolved entries keep their fast path;
are evaluated once; a current delete marker counts as an absent object. version history, quorum and disk formats retain their contracts. The strict
**Availability change:** if any pool's metadata cannot be read, conditional sub-5% performance target remains unconfirmed in the recorded comparison.
completion fails even when another pool can still serve GET/HEAD. This also - **Multipart listing (`4733e1423`):** withdraw #243's default persistent scan.
applies when the unreadable pool may not hold the object: absence cannot be Public listing uses node-local cache or exact-key lookup; restart omissions,
verified. Retry after the pool recovers. Unconditional completion and the prefix and cache-pagination limits remain. The retired strict switch is ignored
single-pool path retain their existing behavior. with a startup warning. **Former strict users lose durable listing and strict
cancellation.** Keep identity metadata, path/wrong-target safety, default
best-effort Abort and peer notification. Diagnostic `multipart-preflight`
reports `mode=legacy`; its budgets do not gate public operations. #79 remains
open, including delayed creation after cancellation.
- **Multipart cleanup (#236, `0f9199e0c`):** parse native timestamps before the
Base64 fallback and remove empty hash parents without a second expiry wait.
Keep active/unreadable uploads; default expiry/check intervals stay 24h/6h.
- **Commit errors (#232):** missing staged `xl.meta` at final commit becomes
HTTP 503 `SlowDownWrite`; preserve this classification across storage RPC and
healing. Real incomplete bodies retain their existing errors.
- **Reused metadata (#233):** clear absent legacy `EcM`/`Signature` and locally
reset mutually exclusive scanner response fields without changing disk/RPC formats.
- **Checksum and Console errors (#225, #229, #238):** align Content-MD5 and
algorithm-specific messages, retaining the generic unknown-algorithm message;
embed Console's wrapped-error and `InvalidBucketState` handling.
- **Lambda and erasure documentation (#220, `2fde3cf53`):** fix inverted
`TargetIDSet.IsEmpty`; document parity/quorum per object and erasure set.
- **Coordinated dependencies:** Console v2.4.2 (`ef2dd2c41d21`), mcli 20261018
(`fa3b8bf72458`), silo-pkg v3.14.3, upstream minio-go v7.4.1 and Go 1.27.2.
Standalone Console/mcli still declare pkg v3.14.2. The Server package update
speeds admin-action classification without changing policy behavior. Refresh
Go x/*, go-jose v4.1.6, LDAP v3.4.15, compress v1.20.3, msgp v1.6.5, etcd v3.7.2,
fwd v1.2.1 and cpuid v2.4.1. OIDC RSA JWKs above 4096 bits are rejected.
Keep the NetBSD go-systemd v22.6.0 replacement; parallel SDK GET/Compose remain opt-in.
- **Build and delivery:** gate releases on Object Lock regressions; use
Git for rebrand scanning without a ripgrep dependency (`7007c164a`). Refresh UBI 9 pins,
GoReleaser v2.18.3, nFPM v2.48.0, golangci-lint v2.15.0 and govulncheck v1.9.0.
Update bundled client hashes/helper and the Helm client image to mcli 20261018.
The chart's Server default stays at the public 20260916 image until publication.
- Evaluate ordinary multi-pool conditional PUT against the logical current Upgrade every writer and replication site; follow the
object across all pools, including draining pools, under the existing object [IAM recovery guide](https://silo.pgsty.com/operations/replication/iam-upgrade/).
lock (#207). A stale destination copy no longer accepts a stale ETag or rejects Old strict multipart users must review the capability loss before upgrading.
the current one; a current delete marker is treated as absence. #217's multi-site delete-marker late creation and crash residue remain unresolved.
**Availability change:** if any pool's object metadata cannot be verified, Source tests, draft artifacts, public images and production rollout are separate
the condition fails even when GET can use another pool; read-quorum failures acceptance boundaries; historical 20261015 test counts do not certify this source.
return 503. Restore readability or heal before retrying. Unconditional PUT,
single-pool conditions and internal replication retain their existing behavior.
A public condition with a destination `versionId` compares the current object
while preserving the requested write version. This change does not retire
stale copies in other pools, undo historical accepted overwrites or provide
a new global clock-ordering guarantee. The multipart-completion repair in #190
neither introduced nor repaired this separate PUT defect.
- Reconcile ordinary single-object version DELETE across all pools, including ## RELEASE.2026-09-16T00-00-00Z
null versions, delete markers and unqualified directory-marker DELETE. This
applies the deletion to every resolved pool copy under existing quorum
rules. Pending outbound delete replication retains versions until the
existing replication worker completes their purge; a successful response
does not imply immediate physical removal from every drive. Unreadable
pools now consistently return 503 instead of depending on pool traversal
order; insufficient read quorum returns `SlowDownRead`. This extends the
existing failure surface. Retry after recovery.
Cleanup failures also return an error. Batch deletion already fans out across
pools; replication and scanner cleanup keep their existing contracts. See
[scope and limitations](docs/bucket/lifecycle/access-tiering-removal.md#version-deletion-scope).
- Remove the opt-in GET-frequency pool-tiering feature from PR #60, including Package version: `20260916000000.0.0`; published source:
its tracker, mover, scanner hooks, configuration, XML actions and metrics. `2a4d51406b7ed87af5fe6fe0f801f3290f96eb3c`.
Accept and ignore retired configuration/XML and preserve ordinary statistics
when reading v9 caches. See [migration notes](docs/bucket/lifecycle/access-tiering-removal.md).
The [decision record](https://silo.pgsty.com/compatibility/access-tiering-removal/) preserves
the feature's introduction, subsequent fixes, rollback scope and review history.
- Preserve the independent multi-pool write, metadata, healing and conditional
deletion fixes from PR #178, including shared remote-tier reference protection.
- Enforce `If-Match` on DELETE, preserve retention and independently ordered
Object Lock/tag updates, and correctly retransmit encrypted replicas.
- Preserve plaintext part sizes and raw SSE-C replicas; prevent SSE-C
compression, honor key-rotation checksums, and complete attributes pagination.
- Repair federated CopyObject checksums, destination timestamps, reserved
metadata, encrypted-object forwarding, legal hold and KMS context.
- Make resync counters, target selection, cancellation and worker lifetimes
reflect actual work, and report bounded MRF drops.
- Converge bucket metadata with deterministic source state, deletion tombstones,
creation time recovery and diagnostics. The mixed-version export gate requires
coordinated upgrades before tombstones are exported. See [the #77 record](https://silo.pgsty.com/blog/design/bucket-metadata-convergence/).
- Include per-bucket CORS in metadata export/import, close metadata publication
and logger races, and report effective bucket quotas in metrics.
### Console, dependencies and delivery This release includes signed-header and payload-policy hardening (SN-2026-011/012),
durable IAM revocations (SN-2026-013), the bounded Console sharing proxy
(SN-2026-014), password-permission separation, multi-pool conditions/deletion/tag
repairs, encrypted/federated copy repairs, delete-marker retry fixes, opt-in strict
multipart discovery, Go-default TLS key exchange, absolute HTTP/1 header deadlines
and synchronized CPU metrics. It embeds Console v2.4.1, mcli 20260916 and pkg v3.14.1.
- Restore embedded Console login over loopback TLS, trusted-proxy handling and The complete release, contributor credits, historical artifact acceptance and
all four WebSocket connection limits. Preserve Go TLS defaults across transports. upgrade requirements are preserved in the canonical
- Directly require `github.com/pgsty/silo-pkg/v3` v3.14.1; select released Console [English](https://silo.pgsty.com/blog/release/silo-20260916/) and
v2.4.1 (`v0.0.0-20260916075814-1360e26d976d`) and mcli 20260916 [Chinese](https://silo.pgsty.com/zh/blog/release/silo-20260916/) release records.
(`v0.0.0-20260916070421-e952aa78f10a`) with explicit PGSTY replacements.
The embedded frontend identifies itself as Console v2.4.1.
- Pin upstream minio-go `v7.3.1-0.20260915093545-32e1f32cb176` to handle
CopyObject errors embedded in HTTP 200 responses. Update JWX to v3.3.0 for
JSON field-name escaping, strfmt to v0.27.2 for Go 1.27 hostname validation,
and LZ4 to v4.1.30 for frame-reader, partial-read and concurrency fixes.
Retain the earlier Go x/* and bounded AMQP frame updates, Go 1.27.1, and
go-systemd v22.6.0's NetBSD compatibility replacement.
- Refresh container base digests and build static curl 8.22.0 from verified
source for both Linux architectures. Pin the published mcli 20260916 archives
and hashes in the container and update the client installer default.
- Prepare Helm chart 7.0.3 with Server and client defaults for the September 16
batch. Publish the chart only after the corresponding Server image exists.
- Pin GoReleaser v2.18.1 and its action commit identically in snapshot and release
workflows. Dependency-only PRs now run the Test Release Pipeline too.
Validation of earlier source revisions does not establish acceptance of this
candidate. Final source, package, image and multi-process checks are tracked
separately in [#203](https://github.com/pgsty/silo/issues/203). No Server release
or production rollout is implied by this preparation target.
## RELEASE.2026-09-03T13-18-01Z ## RELEASE.2026-09-03T13-18-01Z
+58 -30
View File
File diff suppressed because one or more lines are too long
+835 -276
View File
File diff suppressed because it is too large Load Diff
+19 -7
View File
@@ -1,4 +1,4 @@
FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS curl-build FROM golang:1.27.3-alpine@sha256:d530cf60723abf1ffae0cd16bb546b379ec0550d2f3815e5b16ed69dbc00fba4 AS curl-build
ARG TARGETARCH ARG TARGETARCH
COPY dockerscripts/build-static-curl.sh /build/build-static-curl COPY dockerscripts/build-static-curl.sh /build/build-static-curl
RUN /bin/sh /build/build-static-curl RUN /bin/sh /build/build-static-curl
@@ -9,7 +9,7 @@ COPY --from=curl-build /go/bin/curl /curl
COPY --from=curl-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=curl-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENTRYPOINT ["/curl"] ENTRYPOINT ["/curl"]
FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build FROM golang:1.27.3-alpine@sha256:d530cf60723abf1ffae0cd16bb546b379ec0550d2f3815e5b16ed69dbc00fba4 AS build
ARG TARGETARCH ARG TARGETARCH
@@ -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,26 @@ 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
# Native root blocklist mitigation: https://access.redhat.com/security/vulnerabilities/RHSB-2026-012
RUN dnf -y install ca-certificates && \ RUN dnf -y install ca-certificates && \
if [ -d /etc/pki/ca-trust/source/blocklist ]; then \
blocklist=/etc/pki/ca-trust/source/blocklist; \
else \
blocklist=/etc/pki/ca-trust/source/blacklist; \
fi && \
trust extract --overwrite --format=pem-bundle \
--filter='pkcs11:object=Comodo%20AAA%20Services%20root;type=cert' \
"$blocklist/Comodo_AAA_Services_root.pem" && \
trust extract --overwrite --format=pem-bundle \
--filter='pkcs11:object=emSign%20ECC%20Root%20CA%20-%20G3;type=cert' \
"$blocklist/emSign_ECC_Root_CA_-_G3.pem" && \
update-ca-trust && \ 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" \
+1 -1
View File
@@ -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
+16 -10
View File
@@ -35,13 +35,19 @@
> [!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.
Current source is unreleased: Go 1.27.3, silo-pkg v3.15.1, mcli 20261025,
upstream minio-go `ab93db7ba435`, and the Console 2.5.0 candidate `258717be077b`.
It includes the Server IAM cache repair needed by Console's **Reset to default**
action. See [CHANGELOG.md](CHANGELOG.md#unreleased); the published artifacts above
retain their original component versions.
## Overview ## Overview
@@ -62,7 +68,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 +107,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 +143,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>
+14 -8
View File
@@ -35,12 +35,18 @@
> [!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)。
当前源码尚未发布:使用 Go 1.27.3、silo-pkg v3.15.1、mcli 20261025、上游
minio-go `ab93db7ba435`,并内嵌 Console 2.5.0 候选提交 `258717be077b`。
其中包含 Console **Reset to default(恢复默认)** 所需的 Server IAM 缓存修复。
详见 [CHANGELOG.md](CHANGELOG.md#unreleased);上述已发布制品仍保留原有组件版本。
## 概述 ## 概述
@@ -61,7 +67,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 +106,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 +125,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>
+1 -1
View File
@@ -7,7 +7,7 @@ _init() {
## Minimum required versions for build dependencies ## Minimum required versions for build dependencies
GIT_VERSION="1.0" GIT_VERSION="1.0"
GO_VERSION="1.27.2" GO_VERSION="1.27.3"
OSX_VERSION="10.8" OSX_VERSION="10.8"
KNAME=$(uname -s) KNAME=$(uname -s)
ARCH=$(uname -m) ARCH=$(uname -m)
+1 -1
View File
@@ -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
+45 -14
View File
@@ -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
+80
View File
@@ -0,0 +1,80 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/cachevalue"
)
func TestAccountInfoReflectsBucketMutations(t *testing.T) {
ctx := t.Context()
bed, err := prepareAdminErasureTestBed(ctx)
if err != nil {
t.Fatal(err)
}
defer bed.TearDown()
previousCache := listBucketsCache
defer func() { listBucketsCache = previousCache }()
// Hold a real pre-mutation snapshot past the assertions so the regression
// cannot disappear merely because a slow test outlasted the cache TTL.
freezeListing := func() {
t.Helper()
buckets, err := bed.objLayer.ListBuckets(ctx, BucketOptions{})
if err != nil {
t.Fatal(err)
}
listBucketsCache = cachevalue.NewFromFunc(time.Hour, cachevalue.Opts{}, func(context.Context) ([]BucketInfo, error) {
return buckets, nil
})
if _, err := listBucketsCache.GetWithCtx(ctx); err != nil {
t.Fatal(err)
}
}
const bucket = "account-info-freshness"
assertPresent := func(want bool) {
t.Helper()
req, err := buildAdminRequest(nil, http.MethodGet, "/accountinfo", 0, nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
bed.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("account info: %d %s", rec.Code, rec.Body.String())
}
var info madmin.AccountInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
found := false
for _, item := range info.Buckets {
if item.Name == bucket {
found = true
}
}
if found != want {
t.Errorf("account info bucket present = %v, want %v", found, want)
}
}
freezeListing()
if err := bed.objLayer.MakeBucket(ctx, bucket, MakeBucketOptions{}); err != nil {
t.Fatal(err)
}
assertPresent(true)
freezeListing()
if err := bed.objLayer.DeleteBucket(ctx, bucket, DeleteBucketOptions{}); err != nil {
t.Fatal(err)
}
assertPresent(false)
}
+50 -1
View File
@@ -11,6 +11,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -248,8 +249,20 @@ func TestIAMAdminPolicylessSelf(t *testing.T) {
ctx, sys := iamAdminFixture(t) ctx, sys := iamAdminFixture(t)
caller := iamAdminUser(ctx, t, sys, "review-policyless", "") caller := iamAdminUser(ctx, t, sys, "review-policyless", "")
svc := iamAdminServiceAccount(ctx, t, sys, caller.AccessKey, "rv-policyless-child", "") 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{} api := adminAPIHandlers{}
assertIAMAdminStatus(t, "list", iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-service-accounts", nil, nil, api.ListServiceAccounts), 200) 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 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, "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, "delete", iamAdminCall(t, caller, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+svc.AccessKey, nil, map[string]string{"accessKey": svc.AccessKey}, api.DeleteServiceAccount), 204)
@@ -384,6 +397,7 @@ func TestIAMAdminSelfDenials(t *testing.T) {
} }
api := adminAPIHandlers{} api := adminAPIHandlers{}
assertIAMAdminStatus(t, "list", iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-service-accounts", nil, nil, api.ListServiceAccounts), 403) 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 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, "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, "delete", iamAdminCall(t, caller, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+target.AccessKey, nil, map[string]string{"accessKey": target.AccessKey}, api.DeleteServiceAccount), 403)
@@ -477,7 +491,10 @@ func TestIAMAdminListSelfAliases(t *testing.T) {
ctx, sys := iamAdminFixture(t) ctx, sys := iamAdminFixture(t)
parent := iamAdminUser(ctx, t, sys, "alias-parent", "readwrite") parent := iamAdminUser(ctx, t, sys, "alias-parent", "readwrite")
target := iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "alias-target", "") target := iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "alias-target", "")
caller := iamAdminServiceAccount(ctx, t, sys, parent.AccessKey, "alias-caller", "") 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" { if kind == "sts" {
var err error var err error
caller, err = auth.GetNewCredentialsWithMetadata(map[string]any{parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix()}, globalActiveCred.SecretKey) caller, err = auth.GetNewCredentialsWithMetadata(map[string]any{parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix()}, globalActiveCred.SecretKey)
@@ -501,6 +518,38 @@ func TestIAMAdminListSelfAliases(t *testing.T) {
if !found { if !found {
t.Fatalf("self alias %q did not list the parent's service accounts", alias) 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")
}
} }
}) })
} }
+7 -4
View File
@@ -1237,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,
@@ -1250,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
@@ -1447,7 +1447,10 @@ func (a adminAPIHandlers) AccountInfoHandler(w http.ResponseWriter, r *http.Requ
return buckets[i].Name < buckets[j].Name return buckets[i].Name < buckets[j].Name
}) })
} else { } else {
buckets, err = objectAPI.ListBuckets(ctx, BucketOptions{Cached: true}) // Console refreshes this inventory immediately after bucket mutations.
// A stale cached list can otherwise hide a creation or retain a deletion
// indefinitely in the client's snapshot.
buckets, err = objectAPI.ListBuckets(ctx, BucketOptions{})
if err != nil { if err != nil {
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL) writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
return return
+19 -7
View File
@@ -632,13 +632,25 @@ func expireTransitionedObject(ctx context.Context, objectAPI ObjectLayer, oi *Ob
} }
// Delete remote object from warm-tier // Delete remote object from warm-tier
err := deleteObjectFromRemoteTier(ctx, oi.TransitionedObject.Name, oi.TransitionedObject.VersionID, oi.TransitionedObject.Tier) //
if err == nil { // Unless the expiry only puts a delete marker on top of the version, as
// Skip adding free version since we successfully deleted the // for an object that is not transitioned: with versioning enabled, or
// remote object // suspended and the version is not the null version, the version stays
opts.SkipFreeVersion = true // as a noncurrent version and must remain readable. Its remote object is
} else { // removed later, through the free version added when the version itself
transitionLogIf(ctx, err) // is deleted.
opts.VersionSuspended = globalBucketVersioningSys.PrefixSuspended(oi.Bucket, oi.Name)
keepRemote := opts.VersionID == "" &&
(opts.Versioned || (opts.VersionSuspended && oi.VersionID != "" && oi.VersionID != nullVersionID))
if !keepRemote {
err := deleteObjectFromRemoteTier(ctx, oi.TransitionedObject.Name, oi.TransitionedObject.VersionID, oi.TransitionedObject.Tier)
if err == nil {
// Skip adding free version since we successfully deleted the
// remote object
opts.SkipFreeVersion = true
} else {
transitionLogIf(ctx, err)
}
} }
// Now, delete object from hot-tier namespace // Now, delete object from hot-tier namespace
+183
View File
@@ -19,11 +19,17 @@ package cmd
import ( import (
"bytes" "bytes"
"context"
"io"
"net/http" "net/http"
"slices"
"strings"
"sync"
"testing" "testing"
"time" "time"
"github.com/minio/minio/internal/bucket/lifecycle" "github.com/minio/minio/internal/bucket/lifecycle"
objectlock "github.com/minio/minio/internal/bucket/object/lock"
xhttp "github.com/minio/minio/internal/http" xhttp "github.com/minio/minio/internal/http"
) )
@@ -245,3 +251,180 @@ func TestValidateTransitionTier(t *testing.T) {
} }
} }
} }
// tierExpiryTestBackend is a remote tier held in memory.
type tierExpiryTestBackend struct {
mu sync.Mutex
objects map[string][]byte
removed []string
}
func (b *tierExpiryTestBackend) Put(ctx context.Context, object string, r io.Reader, length int64) (remoteVersionID, error) {
return b.PutWithMeta(ctx, object, r, length, nil)
}
func (b *tierExpiryTestBackend) PutWithMeta(_ context.Context, object string, r io.Reader, _ int64, _ map[string]string) (remoteVersionID, error) {
data, err := io.ReadAll(r)
if err != nil {
return "", err
}
b.mu.Lock()
defer b.mu.Unlock()
b.objects[object] = data
return "", nil
}
func (b *tierExpiryTestBackend) Get(_ context.Context, object string, _ remoteVersionID, opts WarmBackendGetOpts) (io.ReadCloser, error) {
b.mu.Lock()
defer b.mu.Unlock()
data, ok := b.objects[object]
if !ok {
return nil, ObjectNotFound{Bucket: "tier", Object: object}
}
if opts.length > 0 {
data = data[opts.startOffset : opts.startOffset+opts.length]
}
return io.NopCloser(bytes.NewReader(data)), nil
}
func (b *tierExpiryTestBackend) Remove(_ context.Context, object string, _ remoteVersionID) error {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.objects, object)
b.removed = append(b.removed, object)
return nil
}
func (b *tierExpiryTestBackend) InUse(context.Context) (bool, error) { return false, nil }
// The expiry of a transitioned latest version that only puts a delete marker
// on top of it must keep its remote object, so the version stays readable as
// a noncurrent version. The remote object goes when the version is deleted.
func TestExpireTransitionedObjectKeepsNoncurrentData(t *testing.T) {
z, _ := migrationTestPools(t)
ctx := t.Context()
tier := &tierExpiryTestBackend{objects: map[string][]byte{}}
prevTier := globalTierConfigMgr
globalTierConfigMgr = NewTierConfigMgr()
globalTierConfigMgr.drivercache["WARM"] = tier
t.Cleanup(func() { globalTierConfigMgr = prevTier })
const expire = `<LifecycleConfiguration><Rule><ID>exp</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter>` +
`<Expiration><Date>2020-01-01T00:00:00Z</Date></Expiration></Rule></LifecycleConfiguration>`
testCases := []struct {
name string
versioning string // "", Enabled or Suspended
nullLatest bool // the latest version is the null version
keep bool // the latest version survives as noncurrent
want []string // versions after the expiry, newest first
}{
{name: "versioned", versioning: "Enabled", keep: true, want: []string{"DM", "v2", "v1"}},
{name: "suspended", versioning: "Suspended", keep: true, want: []string{"DM", "v2", "v1"}},
{name: "suspended-null", versioning: "Suspended", nullLatest: true, want: []string{"DM", "v1"}},
{name: "unversioned", nullLatest: true, want: nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
bucket := "tier-expiry-" + tc.name
if err := z.MakeBucket(ctx, bucket, MakeBucketOptions{VersioningEnabled: tc.versioning != ""}); err != nil {
t.Fatal(err)
}
meta, err := globalBucketMetadataSys.Get(bucket)
if err != nil {
t.Fatal(err)
}
if tc.versioning == "Suspended" {
meta.VersioningConfigXML = []byte("<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>")
}
meta.LifecycleConfigXML = []byte(expire)
if err := meta.Save(ctx, z); err != nil {
t.Fatal(err)
}
globalBucketMetadataSys.Set(bucket, meta)
// tiered writes a version whose data is in the remote tier.
labels := map[string]string{}
tiered := func(label string, opts ObjectOptions) ObjectInfo {
t.Helper()
oi := putConsistencyObject(t, z, bucket, "obj", 0, "obj "+label, opts)
vid := migrationTestVersion(oi)
infos, errs := readAllFileInfo(ctx, z.serverPools[0].getHashedSet("obj").getDisks(), "", bucket, "obj", vid, false, false)
if errs[0] != nil {
t.Fatal(errs[0])
}
fi := infos[0]
fi.TransitionStatus, fi.TransitionTier, fi.TransitionedObjName = lifecycle.TransitionComplete, "WARM", bucket+"/"+label
if _, err := tier.Put(ctx, fi.TransitionedObjName, strings.NewReader("obj "+label), -1); err != nil {
t.Fatal(err)
}
if err := z.serverPools[0].DecomTieredObject(ctx, bucket, "obj", fi, ObjectOptions{}); err != nil {
t.Fatal(err)
}
labels[vid] = label
return oi
}
var latest ObjectInfo
if tc.versioning != "" {
tiered("v1", ObjectOptions{Versioned: true})
}
if tc.nullLatest {
latest = tiered("v2", ObjectOptions{})
} else {
latest = tiered("v2", ObjectOptions{Versioned: true})
}
// Expire the latest version as the expiry worker does for the scanner.
getOpts := ObjectOptions{Versioned: tc.versioning == "Enabled", VersionSuspended: tc.versioning == "Suspended"}
oi, err := z.GetObjectInfo(ctx, bucket, "obj", getOpts)
if err != nil {
t.Fatal(err)
}
lc, err := globalLifecycleSys.Get(bucket)
if err != nil {
t.Fatal(err)
}
evt := evalActionFromLifecycle(ctx, *lc, objectlock.Retention{}, nil, oi)
if evt.Action != lifecycle.DeleteAction || oi.TransitionedObject.Status != lifecycle.TransitionComplete {
t.Fatalf("fixture: action %v, transition status %q", evt.Action, oi.TransitionedObject.Status)
}
if err := expireTransitionedObject(ctx, z, &oi, evt, lcEventSrc_Scanner); err != nil {
t.Errorf("expiry: %v", err)
}
var got []string
for _, set := range z.serverPools[0].sets {
err := set.listObjectsToDecommission(ctx, decomBucketInfo{Name: bucket}, func(entry metaCacheEntry) {
fivs, err := entry.fileInfoVersions(bucket)
if err != nil {
t.Error(err)
return
}
for _, fi := range fivs.Versions {
if fi.Deleted {
got = append(got, "DM")
} else {
got = append(got, labels[migrationTestVersion(fi.ToObjectInfo(bucket, "obj", true))])
}
}
})
if err != nil {
t.Fatal(err)
}
}
if !slices.Equal(got, tc.want) {
t.Errorf("versions after expiry: got %v, want %v", got, tc.want)
}
remote := bucket + "/v2"
if removed := slices.Contains(tier.removed, remote); removed == tc.keep {
t.Errorf("remote object of the expired version removed: %v, want %v", removed, !tc.keep)
}
if tc.keep {
body := migrationTestBody(t, z.GetObjectNInfo, bucket, "obj", migrationTestVersion(latest))
if body != "obj v2" {
t.Errorf("v2 reads %q after the expiry, want %q", body, "obj v2")
}
}
})
}
}
+257
View File
@@ -0,0 +1,257 @@
// Copyright (c) 2026 Denis Babchuk
//
// This file is part of Silo Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"bytes"
"context"
"maps"
"net/http"
"strings"
"testing"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/minio/minio/internal/hash"
xhttp "github.com/minio/minio/internal/http"
)
// checksumTestObject builds the xl.meta checksum of an object the way
// PutObject and CompleteMultipartUpload store it.
func checksumTestObject(t *testing.T, alg hash.ChecksumType, full bool, parts ...[]byte) FileInfo {
t.Helper()
fi := FileInfo{Volume: "bucket", Name: "object"}
if len(parts) == 1 {
fi.Size = int64(len(parts[0]))
fi.Checksum = hash.NewChecksumFromData(alg, parts[0]).AppendTo(nil, nil)
return fi
}
var combined, all []byte
for i, p := range parts {
combined = append(combined, hash.NewChecksumFromData(alg, p).Raw...)
all = append(all, p...)
fi.Parts = append(fi.Parts, ObjectPartInfo{Number: i + 1, Size: int64(len(p)), ActualSize: int64(len(p))})
fi.Size += int64(len(p))
}
typ := alg | hash.ChecksumMultipart | hash.ChecksumIncludesMultipart
if full {
typ |= hash.ChecksumFullObject
}
cs := hash.NewChecksumFromData(typ, combined)
if typ.FullObjectRequested() {
// Full object CRC, as merged from the parts on completion.
whole := hash.NewChecksumFromData(alg, all)
cs = &hash.Checksum{Type: typ, Raw: whole.Raw, Encoded: whole.Encoded}
}
fi.Checksum = cs.AppendTo(nil, combined)
return fi
}
// TestGetCRCMetaChecksumType checks the checksum headers that replication
// sends for an object: a FULL_OBJECT checksum type only for multipart full
// object CRCs, where the target needs it to create a matching upload.
func TestGetCRCMetaChecksumType(t *testing.T) {
p1, p2 := []byte("first part"), []byte("second part")
enc := func(alg hash.ChecksumType, b []byte) string { return hash.NewChecksumFromData(alg, b).Encoded }
whole := append(append([]byte{}, p1...), p2...)
composite := func(alg hash.ChecksumType) string {
var combined []byte
combined = append(combined, hash.NewChecksumFromData(alg, p1).Raw...)
combined = append(combined, hash.NewChecksumFromData(alg, p2).Raw...)
return enc(alg, combined) + "-2"
}
full := map[string]string{xhttp.AmzChecksumType: xhttp.AmzChecksumTypeFullObject}
tests := []struct {
name string
fi FileInfo
alg hash.ChecksumType
isMP bool
value string
extra map[string]string
}{
{"single-part SHA256", checksumTestObject(t, hash.ChecksumSHA256, false, p1), hash.ChecksumSHA256, false, enc(hash.ChecksumSHA256, p1), nil},
{"single-part CRC32", checksumTestObject(t, hash.ChecksumCRC32, false, p1), hash.ChecksumCRC32, false, enc(hash.ChecksumCRC32, p1), nil},
{"single-part CRC64NVME", checksumTestObject(t, hash.ChecksumCRC64NVME, false, p1), hash.ChecksumCRC64NVME, false, enc(hash.ChecksumCRC64NVME, p1), nil},
{"multipart COMPOSITE CRC32C", checksumTestObject(t, hash.ChecksumCRC32C, false, p1, p2), hash.ChecksumCRC32C, true, composite(hash.ChecksumCRC32C), nil},
{"multipart COMPOSITE SHA256", checksumTestObject(t, hash.ChecksumSHA256, false, p1, p2), hash.ChecksumSHA256, true, composite(hash.ChecksumSHA256), nil},
{"multipart FULL_OBJECT CRC32", checksumTestObject(t, hash.ChecksumCRC32, true, p1, p2), hash.ChecksumCRC32, true, enc(hash.ChecksumCRC32, whole), full},
{"multipart FULL_OBJECT CRC32C", checksumTestObject(t, hash.ChecksumCRC32C, true, p1, p2), hash.ChecksumCRC32C, true, enc(hash.ChecksumCRC32C, whole), full},
{"multipart CRC64NVME", checksumTestObject(t, hash.ChecksumCRC64NVME, false, p1, p2), hash.ChecksumCRC64NVME, true, enc(hash.ChecksumCRC64NVME, whole), full},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oi := tt.fi.ToObjectInfo("bucket", "object", true)
want := map[string]string{tt.alg.Key(): tt.value, xhttp.AmzChecksumAlgo: tt.alg.String()}
maps.Copy(want, tt.extra)
got, isMP := getCRCMeta(oi, 0, nil)
if isMP != tt.isMP {
t.Errorf("isMP: got %t, want %t", isMP, tt.isMP)
}
if !maps.Equal(got, want) {
t.Errorf("object headers: got %v, want %v", got, want)
}
if !tt.isMP {
return
}
// Part headers carry the part checksum and never a type.
want = map[string]string{tt.alg.Key(): enc(tt.alg, p2), xhttp.AmzChecksumAlgo: tt.alg.String()}
if got, _ := getCRCMeta(oi, 2, nil); !maps.Equal(got, want) {
t.Errorf("part 2 headers: got %v, want %v", got, want)
}
})
}
}
// TestReplicateObjectChecksumTypes replicates objects with checksums through
// the S3 API of a test server, and checks that the replica gets the same
// checksum as the source.
func TestReplicateObjectChecksumTypes(t *testing.T) {
ctx := context.Background()
// Leave no object layer behind: unit tests that run later in the package
// rely on newObjectLayerFn() returning nil.
defer resetTestGlobals()
ts := StartTestServer(t, ErasureSDStr)
defer ts.Stop()
clnt, err := minio.New(strings.TrimPrefix(ts.Server.URL, "http://"), &minio.Options{
Creds: credentials.NewStaticV4(ts.AccessKey, ts.SecretKey, ""),
})
if err != nil {
t.Fatal(err)
}
core := &minio.Core{Client: clnt}
for _, bucket := range []string{"src", "dst"} {
if err := clnt.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil {
t.Fatal(err)
}
if err := clnt.EnableVersioning(ctx, bucket); err != nil {
t.Fatal(err)
}
}
small := []byte("single part object")
p1 := bytes.Repeat([]byte("a"), 5<<20)
p2 := []byte("last part")
tests := []struct {
name string
alg hash.ChecksumType
multipart bool
full bool
copyOf string // server-side copy of an object created above
}{
{"put-SHA256", hash.ChecksumSHA256, false, false, ""},
{"put-CRC32", hash.ChecksumCRC32, false, false, ""},
{"mp-COMPOSITE-CRC32C", hash.ChecksumCRC32C, true, false, ""},
{"mp-COMPOSITE-SHA256", hash.ChecksumSHA256, true, false, ""},
{"mp-FULL_OBJECT-CRC32", hash.ChecksumCRC32, true, true, ""},
{"mp-FULL_OBJECT-CRC32C", hash.ChecksumCRC32C, true, true, ""},
{"mp-CRC64NVME", hash.ChecksumCRC64NVME, true, false, ""},
// The copy keeps the multipart FULL_OBJECT checksum of its source
// but is not multipart, so it replicates with a single PUT.
{"copy-FULL_OBJECT-CRC32", hash.ChecksumCRC32, false, true, "mp-FULL_OBJECT-CRC32"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
object := tt.name
// Upload the source object like an S3 client does.
switch {
case tt.copyOf != "":
_, err := core.CopyObject(ctx, "src", tt.copyOf, "src", object, nil, minio.CopySrcOptions{}, minio.PutObjectOptions{})
if err != nil {
t.Fatal(err)
}
case !tt.multipart:
_, err := core.PutObject(ctx, "src", object, bytes.NewReader(small), int64(len(small)), "", "", minio.PutObjectOptions{
UserMetadata: map[string]string{tt.alg.Key(): hash.NewChecksumFromData(tt.alg, small).Encoded},
})
if err != nil {
t.Fatal(err)
}
default:
meta := map[string]string{xhttp.AmzChecksumAlgo: tt.alg.String()}
if tt.full {
meta[xhttp.AmzChecksumType] = xhttp.AmzChecksumTypeFullObject
}
uploadID, err := core.NewMultipartUpload(ctx, "src", object, minio.PutObjectOptions{UserMetadata: meta})
if err != nil {
t.Fatal(err)
}
var parts []minio.CompletePart
for i, p := range [][]byte{p1, p2} {
h := http.Header{}
h.Set(tt.alg.Key(), hash.NewChecksumFromData(tt.alg, p).Encoded)
pi, err := core.PutObjectPart(ctx, "src", object, uploadID, i+1, bytes.NewReader(p), int64(len(p)), minio.PutObjectPartOptions{CustomHeader: h})
if err != nil {
t.Fatal(err)
}
parts = append(parts, minio.CompletePart{
PartNumber: pi.PartNumber, ETag: pi.ETag,
ChecksumCRC32: pi.ChecksumCRC32, ChecksumCRC32C: pi.ChecksumCRC32C,
ChecksumSHA1: pi.ChecksumSHA1, ChecksumSHA256: pi.ChecksumSHA256,
ChecksumCRC64NVME: pi.ChecksumCRC64NVME,
})
}
if _, err := core.CompleteMultipartUpload(ctx, "src", object, uploadID, parts, minio.PutObjectOptions{}); err != nil {
t.Fatal(err)
}
}
// Replicate it the way replicateObject does.
gr, err := ts.Obj.GetObjectNInfo(ctx, "src", object, nil, http.Header{}, ObjectOptions{Versioned: true, ReplicationRequest: true})
if err != nil {
t.Fatal(err)
}
defer gr.Close()
objInfo := gr.ObjInfo
putOpts, isMP, err := putReplicationOpts(ctx, "", objInfo)
if err != nil {
t.Fatal(err)
}
if isMP != tt.multipart {
t.Fatalf("multipart replication: got %t, want %t", isMP, tt.multipart)
}
if isMP {
err = replicateObjectWithMultipart(ctx, core, "dst", object, gr, objInfo, putOpts)
} else {
size, serr := objInfo.GetActualSize()
if serr != nil {
t.Fatal(serr)
}
_, err = core.PutObject(ctx, "dst", object, gr, size, "", "", putOpts)
}
if err != nil {
t.Fatalf("replication failed: %v", err)
}
src, err := clnt.StatObject(ctx, "src", object, minio.StatObjectOptions{Checksum: true})
if err != nil {
t.Fatal(err)
}
dst, err := clnt.StatObject(ctx, "dst", object, minio.StatObjectOptions{Checksum: true})
if err != nil {
t.Fatal(err)
}
checksums := func(oi minio.ObjectInfo) string {
return strings.Join([]string{oi.ChecksumMode, oi.ChecksumCRC32, oi.ChecksumCRC32C, oi.ChecksumSHA1, oi.ChecksumSHA256, oi.ChecksumCRC64NVME}, "|")
}
if checksums(src) == "|||||" || checksums(dst) != checksums(src) {
t.Errorf("replica checksum %q, source %q", checksums(dst), checksums(src))
}
})
}
}
+5
View File
@@ -4250,6 +4250,11 @@ func getCRCMeta(oi ObjectInfo, partNum int, h http.Header) (cs map[string]string
if cktype.IsSet() { if cktype.IsSet() {
meta[cktype.Key()] = v meta[cktype.Key()] = v
meta[xhttp.AmzChecksumAlgo] = cktype.String() meta[xhttp.AmzChecksumAlgo] = cktype.String()
// Without the type the target creates a COMPOSITE multipart
// upload and rejects the full object CRC on completion.
if isMP && cktype.CanMerge() && cs[xhttp.AmzChecksumType] == xhttp.AmzChecksumTypeFullObject {
meta[xhttp.AmzChecksumType] = xhttp.AmzChecksumTypeFullObject
}
} }
} }
return meta, isMP return meta, isMP
+1 -1
View File
@@ -883,7 +883,7 @@ func serverHandleEnvVars() {
} }
globalInplaceUpdateDisabled = true globalInplaceUpdateDisabled = true
if env.IsSet(api.EnvAPIMultipartListing) { if env.IsSet(api.EnvAPIMultipartListing) {
logger.Warning("%s is ignored: multipart listing always uses persistent metadata; uploads without identity fields are omitted", 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,
+1 -1
View File
@@ -91,7 +91,7 @@ func readConfigWithoutMigrate(ctx context.Context, objAPI ObjectLayer) (config.C
return nil, err return nil, err
} }
data, err = decryptData(data, configFile) data, err = decryptData(ctx, data, configFile)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -68,7 +68,7 @@ func listServerConfigHistory(ctx context.Context, objAPI ObjectLayer, withData b
continue continue
} }
data, err = decryptData(data, obj.Name) data, err = decryptData(ctx, data, obj.Name)
if err != nil { if err != nil {
// ignore history file that cannot be loaded. // ignore history file that cannot be loaded.
continue continue
@@ -110,7 +110,7 @@ func readServerConfigHistory(ctx context.Context, objAPI ObjectLayer, uuidKV str
return nil, err return nil, err
} }
return decryptData(data, historyFile) return decryptData(ctx, data, historyFile)
} }
func saveServerConfigHistory(ctx context.Context, objAPI ObjectLayer, kv []byte) error { func saveServerConfigHistory(ctx context.Context, objAPI ObjectLayer, kv []byte) error {
@@ -162,7 +162,7 @@ func readServerConfig(ctx context.Context, objAPI ObjectLayer, data []byte) (con
return nil, err return nil, err
} }
data, err = decryptData(data, configFile) data, err = decryptData(ctx, data, configFile)
if err != nil { if err != nil {
lookupConfigs(srvCfg, objAPI) lookupConfigs(srvCfg, objAPI)
return nil, err return nil, err
+60 -3
View File
@@ -126,8 +126,8 @@ func (d *multipartCleanupDisk) ReadVersion(context.Context, string, string, stri
return FileInfo{ModTime: d.modTime}, d.readErr return FileInfo{ModTime: d.modTime}, d.readErr
} }
func (d *multipartCleanupDisk) StatVol(context.Context, string) (VolInfo, error) { func (d *multipartCleanupDisk) StatVol(ctx context.Context, volume string) (VolInfo, error) {
return VolInfo{Created: time.Now()}, nil return (&xlStorage{drivePath: d.root}).StatVol(ctx, volume)
} }
func TestCleanupStaleUploadsOnDisk(t *testing.T) { func TestCleanupStaleUploadsOnDisk(t *testing.T) {
@@ -157,7 +157,13 @@ func TestCleanupStaleUploadsOnDisk(t *testing.T) {
} }
disk := &multipartCleanupDisk{root: t.TempDir(), modTime: modTime, readErr: tc.readErr} disk := &multipartCleanupDisk{root: t.TempDir(), modTime: modTime, readErr: tc.readErr}
uploadDir := filepath.Join(disk.root, minioMetaMultipartBucket, "sha", id) uploadDir := filepath.Join(disk.root, minioMetaMultipartBucket, "sha", id)
if err := os.MkdirAll(uploadDir, 0o755); err != nil { 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) t.Fatal(err)
} }
erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk) erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk)
@@ -169,9 +175,60 @@ func TestCleanupStaleUploadsOnDisk(t *testing.T) {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
t.Errorf("expired upload must be removed: %v", 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 { } else if err != nil {
t.Errorf("upload must be preserved: %v", err) 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)
}
}
@@ -14,7 +14,7 @@ import (
// Run explicitly with -benchtime=1x. Fixture creation is excluded from timing; // Run explicitly with -benchtime=1x. Fixture creation is excluded from timing;
// each request still discovers real on-disk metadata. The 16-drive/10k case // each request still discovers real on-disk metadata. The 16-drive/10k case
// deliberately exceeds the existing aggregate directory-entry budget. // deliberately exceeds the existing aggregate directory-entry budget.
func BenchmarkMultipartListingCapacity(b *testing.B) { func BenchmarkMultipartDiagnosticScanCapacity(b *testing.B) {
for _, tc := range []struct { for _, tc := range []struct {
drives, uploads int drives, uploads int
mixed, limited bool mixed, limited bool
@@ -67,7 +67,7 @@ func BenchmarkMultipartListingCapacity(b *testing.B) {
} }
b.ResetTimer() b.ResetTimer()
for range b.N { for range b.N {
got, err := z.ListMultipartUploads(b.Context(), bucket, "dir/", "", "", "", 1000) got, err := z.scanMultipartUploadsForTest(b.Context(), bucket, "dir/", "", "", "", 1000)
if tc.limited { if tc.limited {
var limited SlowDown var limited SlowDown
if !errors.As(err, &limited) || len(got.Uploads) != 0 { if !errors.As(err, &limited) || len(got.Uploads) != 0 {
@@ -83,3 +83,88 @@ func BenchmarkMultipartListingCapacity(b *testing.B) {
}) })
} }
} }
// 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)
})
}
}
+1 -1
View File
@@ -359,7 +359,7 @@ func (z *erasureServerPools) multipartPreflight(ctx context.Context) (multipartP
return multipartPreflightReport{}, err return multipartPreflightReport{}, err
} }
defer s.close() defer s.close()
report := multipartPreflightReport{Complete: true, Mode: "persistent"} report := multipartPreflightReport{Complete: true, 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)
+71 -28
View File
@@ -22,10 +22,53 @@ 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 {
@@ -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)
} }
} }
@@ -149,7 +192,7 @@ func TestMultipartListingLegacyPreflight(t *testing.T) {
} }
z.mpCache.Clear() z.mpCache.Clear()
for _, target := range []string{bucket, other} { for _, target := range []string{bucket, other} {
got, err := z.ListMultipartUploads(t.Context(), target, "", "", "", "", 10) got, err := z.scanMultipartUploadsForTest(t.Context(), target, "", "", "", "", 10)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -159,14 +202,14 @@ func TestMultipartListingLegacyPreflight(t *testing.T) {
requireMultipartUploadKeys(t, got) requireMultipartUploadKeys(t, got)
} }
} }
// Exact-looking prefixes now have the same durable semantics. // Diagnostic discovery cannot recover an unidentified historical key.
exact, err := z.ListMultipartUploads(t.Context(), other, "old", "", "", "", 10) exact, err := z.scanMultipartUploadsForTest(t.Context(), other, "old", "", "", "", 10)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
requireMultipartUploadKeys(t, exact) requireMultipartUploadKeys(t, exact)
report, err := z.multipartPreflight(t.Context()) report, err := z.multipartPreflight(t.Context())
if err != nil || report.Ready || !report.Complete || report.LegacyUploads != 1 || report.Mode != "persistent" { 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 {
@@ -176,14 +219,14 @@ 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", "partial", "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)
@@ -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)
} }
@@ -469,7 +512,7 @@ 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)
@@ -507,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)
} }
@@ -589,7 +632,7 @@ func TestMultipartListingMissingMarkers(t *testing.T) {
} }
} }
func TestMultipartListingCoverage(t *testing.T) { func TestMultipartDiagnosticCoverage(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 {
@@ -605,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)
} }
@@ -621,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)
@@ -651,7 +694,7 @@ 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 {
@@ -673,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())
} }
} }
@@ -705,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 || report.Mode != "persistent" { 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 {
@@ -749,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 {
+106
View File
@@ -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())
}
})
}
}
+30 -30
View File
@@ -4,11 +4,10 @@
package cmd package cmd
import ( import (
"bytes"
"context" "context"
"encoding/xml"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest"
"slices" "slices"
"testing" "testing"
) )
@@ -33,14 +32,10 @@ func removeMultipartIdentity(metadata map[string]string) {
delete(metadata, multipartMetaObject) delete(metadata, multipartMetaObject)
} }
// The actual S3 handler must use the same semantics for every prefix and page, // The retained diagnostic scanner handles mixed records independently of a
// even with old records and a stale, misleading node-local cache. // stale cache. Public HTTP behavior is tested separately.
func TestMultipartListingMixedHTTP(t *testing.T) { func TestMultipartDiagnosticMixed(t *testing.T) {
z, set, _ := multipartListingFixture(t) z, set, bucket := multipartListingFixture(t)
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads"}, MakeBucketOptions{})
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"dir/old", "dir/a", "dir/sub/b", "outside"} { for _, key := range []string{"dir/old", "dir/a", "dir/sub/b", "outside"} {
mp, err := z.NewMultipartUpload(t.Context(), bucket, key, ObjectOptions{}) mp, err := z.NewMultipartUpload(t.Context(), bucket, key, ObjectOptions{})
if err != nil { if err != nil {
@@ -54,22 +49,11 @@ func TestMultipartListingMixedHTTP(t *testing.T) {
z.mpCache.Store("stale", MultipartInfo{Bucket: bucket, Object: "ghost", UploadID: "stale"}) z.mpCache.Store("stale", MultipartInfo{Bucket: bucket, Object: "ghost", UploadID: "stale"})
list := func(prefix, keyMarker, uploadMarker, delimiter string) ListMultipartUploadsResponse { list := func(prefix, keyMarker, uploadMarker, delimiter string) ListMultipartUploadsResponse {
t.Helper() t.Helper()
req, err := newTestSignedRequestV4(http.MethodGet, got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, prefix, keyMarker, uploadMarker, delimiter, 1)
getListMultipartUploadsURLWithParams("", bucket, prefix, keyMarker, uploadMarker, delimiter, "1"),
0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
rec := httptest.NewRecorder() return generateListMultipartUploadsResponse(bucket, got, "")
router.ServeHTTP(rec, req)
var got ListMultipartUploadsResponse
if rec.Code != http.StatusOK {
t.Fatalf("list %q: HTTP %d %s", prefix, rec.Code, rec.Body.String())
}
if err := xml.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
return got
} }
var keys []string var keys []string
keyMarker, uploadMarker := "", "" keyMarker, uploadMarker := "", ""
@@ -103,7 +87,7 @@ func TestMultipartListingMixedHTTP(t *testing.T) {
} }
} }
func TestMultipartListingInvalidIdentity(t *testing.T) { func TestMultipartDiagnosticInvalidIdentity(t *testing.T) {
for _, kind := range []string{"missing-bucket", "missing-object", "empty-bucket", "empty-object", "empty-both", "wrong-hash", "read-failure"} { 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) { t.Run(kind, func(t *testing.T) {
z, set, bucket := multipartListingFixture(t) z, set, bucket := multipartListingFixture(t)
@@ -142,7 +126,7 @@ func TestMultipartListingInvalidIdentity(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 || len(got.Uploads) != 0 || toAPIError(t.Context(), err).HTTPStatusCode != http.StatusServiceUnavailable { 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) t.Fatalf("invalid/unreadable record returned a successful page: %+v %v", got, err)
} }
@@ -185,7 +169,14 @@ func TestMultipartListingRestart(t *testing.T) {
if _, cached := obj.(*erasureServerPools).mpCache.Load(mp.UploadID); cached { if _, cached := obj.(*erasureServerPools).mpCache.Load(mp.UploadID); cached {
t.Fatal("restart unexpectedly preserved the upload cache") t.Fatal("restart unexpectedly preserved the upload cache")
} }
got, err := obj.ListMultipartUploads(t.Context(), bucket, "dir/", "", "", "", 10) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -193,9 +184,18 @@ func TestMultipartListingRestart(t *testing.T) {
if got.Uploads[0].UploadID != mp.UploadID { if got.Uploads[0].UploadID != mp.UploadID {
t.Fatal("restart changed upload identity") 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 TestMultipartListingConflictingIdentityCopies(t *testing.T) { func TestMultipartDiagnosticConflictingIdentityCopies(t *testing.T) {
for _, oldCopies := range []int{1, 3} { for _, oldCopies := range []int{1, 3} {
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{})
@@ -213,14 +213,14 @@ func TestMultipartListingConflictingIdentityCopies(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10) got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
if !errors.Is(err, errMultipartListingIdentity) || len(got.Uploads) != 0 { if !errors.Is(err, errMultipartListingIdentity) || len(got.Uploads) != 0 {
t.Fatalf("%d contradictory copies returned a successful page: %+v %v", oldCopies, got, err) t.Fatalf("%d contradictory copies returned a successful page: %+v %v", oldCopies, got, err)
} }
} }
} }
func TestMultipartListingUnsupportedStoredID(t *testing.T) { func TestMultipartDiagnosticUnsupportedStoredID(t *testing.T) {
for _, historical := range []bool{false, true} { for _, historical := range []bool{false, true} {
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{})
@@ -244,7 +244,7 @@ func TestMultipartListingUnsupportedStoredID(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10) got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
if historical { if historical {
if err != nil || len(got.Uploads) != 0 { if err != nil || len(got.Uploads) != 0 {
t.Fatalf("unidentified historical ID: %+v %v", got, err) t.Fatalf("unidentified historical ID: %+v %v", got, err)
+9 -24
View File
@@ -197,6 +197,7 @@ func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk Sto
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)
modTime := extractModTimeFromUploadIDDir(uploadIDDir) modTime := extractModTimeFromUploadIDDir(uploadIDDir)
@@ -205,12 +206,14 @@ func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk Sto
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())
@@ -223,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
}) })
@@ -414,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. Public listing always uses the durable scan. // 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)
@@ -522,16 +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, err := er.scanMultipartUploads(scan, bucket, 0, 0)
if err != nil {
return ListMultipartsInfo{}, err
}
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
} }
// newMultipartUpload - wrapper for initializing a new multipart // newMultipartUpload - wrapper for initializing a new multipart
+24
View File
@@ -1891,6 +1891,23 @@ func (er erasureObjects) DeleteObjects(ctx context.Context, bucket string, objec
if err == nil { if err == nil {
continue continue
} }
if err == errFileNotFound || err == errFileVersionNotFound {
// A retry after partial physical deletion may mix removals
// with already-absent replies. Both prove the desired outcome.
// Marker creation and replication-state updates still require
// successful writes; absence cannot acknowledge those operations.
physical := true
for _, v := range dedupVersions[i].Versions {
if v.Deleted || v.MarkDeleted || v.ReplicationState.ReplicationStatusInternal != "" ||
v.ReplicationState.VersionPurgeStatusInternal != "" || v.ReplicationState.ReplicaStatus != "" {
physical = false
break
}
}
if physical {
continue
}
}
for _, v := range dedupVersions[i].Versions { for _, v := range dedupVersions[i].Versions {
delObjErrs[index][v.Idx] = err delObjErrs[index][v.Idx] = err
} }
@@ -2195,6 +2212,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
+232 -1
View File
@@ -27,6 +27,7 @@ import (
madmin "github.com/minio/madmin-go/v3" madmin "github.com/minio/madmin-go/v3"
"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/pgsty/silo-pkg/v3/sync/errgroup"
) )
// objectPoolInfos reads the addressed version in every pool, including pools // objectPoolInfos reads the addressed version in every pool, including pools
@@ -62,6 +63,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 = &copy.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 +257,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
} }
@@ -410,3 +534,110 @@ func (z *erasureServerPools) deleteObjectReconciled(ctx context.Context, bucket,
oi.replicationDecision = opts.DeleteReplication.ReplicateDecisionStr oi.replicationDecision = opts.DeleteReplication.ReplicateDecisionStr
return oi, err return oi, err
} }
// deleteObjectsReconciled removes data while retaining each item's authoritative
// copy until every other pool has completed its cleanup. The caller holds the
// pools-layer write locks and separates marker creation from this batch.
func (z *erasureServerPools) deleteObjectsReconciled(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions) ([]DeletedObject, []error) {
deleted := make([]DeletedObject, len(objects))
errs := make([]error, len(objects))
primary := make([]int, len(objects))
tiered := make([]bool, len(objects))
lookup := errgroup.WithNErrs(len(objects)).WithConcurrency(32)
for i, obj := range objects {
// Preserve the full request identity even if lookup or cleanup fails.
// The HTTP handler includes replication state in its response-index key.
deleted[i] = DeletedObject{
ObjectName: obj.ObjectName,
VersionID: obj.VersionID,
ReplicationState: obj.ReplicationState(),
}
primary[i] = -1
lookup.Go(func() error {
copies, err := z.objectPoolInfos(ctx, bucket, obj.ObjectName, ObjectOptions{VersionID: obj.VersionID, NoAuditLog: true})
if err == nil {
primary[i] = copies[0].Index
for _, copy := range copies {
tiered[i] = tiered[i] || copy.ObjInfo.TransitionedObject.Status == lifecycle.TransitionComplete
}
} else if !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
if isErrReadQuorum(err) {
err = InsufficientWriteQuorum{Bucket: bucket, Object: decodeDirObject(obj.ObjectName)}
}
errs[i] = err
}
return nil
}, i)
}
lookup.Wait()
type poolBatch struct {
objects []ObjectToDelete
indices []int
deleted []DeletedObject
errs []error
}
for phase := 0; phase < 2; phase++ {
batches := make([]poolBatch, len(z.serverPools))
for i, obj := range objects {
if errs[i] != nil || tiered[i] {
continue
}
for p := range z.serverPools {
if (p == primary[i]) != (phase == 1) {
continue
}
batches[p].objects = append(batches[p].objects, obj)
batches[p].indices = append(batches[p].indices, i)
}
}
// Each phase still uses the set layer's bulk disk I/O and runs pools
// concurrently. Include apparently absent pools in cleanup: read-quorum
// absence is not proof that an offline drive holds no remaining copy.
group := errgroup.WithNErrs(len(z.serverPools))
for p, pool := range z.serverPools {
if len(batches[p].objects) == 0 {
continue
}
group.Go(func() error {
batches[p].deleted, batches[p].errs = pool.DeleteObjects(ctx, bucket, batches[p].objects, opts)
return nil
}, p)
}
group.Wait()
for _, batch := range batches {
for j, i := range batch.indices {
if errs[i] != nil {
continue
}
if batch.errs[j] != nil {
errs[i] = batch.errs[j]
} else if phase == 1 || primary[i] == -1 {
deleted[i] = batch.deleted[j]
}
}
}
}
// Tiered copies can share one remote object, including restored copies.
// Reuse the existing reconciliation which suppresses premature tier GC.
// Serializing this uncommon path also keeps duplicate key/version entries
// in one request from racing their metadata updates under the shared lock.
for i, obj := range objects {
if !tiered[i] || errs[i] != nil {
continue
}
deleteOpts := opts
deleteOpts.VersionID, deleteOpts.NoLock = obj.VersionID, true
deleteOpts.DeleteReplication = obj.ReplicationState()
if opts.PrefixEnabledFn != nil {
deleteOpts.Versioned = opts.PrefixEnabledFn(obj.ObjectName)
}
_, err := z.deleteObjectReconciled(ctx, bucket, obj.ObjectName, deleteOpts)
if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
errs[i] = err
} else {
deleted[i].found = true
}
}
return deleted, errs
}
+205 -5
View File
@@ -1071,6 +1071,8 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
restored bool restored bool
unconditional bool unconditional bool
skipFreeVersion bool skipFreeVersion bool
bulk bool
unversioned bool
}{ }{
{name: "metadata-copy"}, {name: "metadata-copy"},
{name: "restored-metadata-copy", restored: true}, {name: "restored-metadata-copy", restored: true},
@@ -1081,6 +1083,14 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
{name: "ordinary-failed-primary-delete", deleting: true, unconditional: true, failPrimaryDelete: true}, {name: "ordinary-failed-primary-delete", deleting: true, unconditional: true, failPrimaryDelete: true},
{name: "ordinary-successful-delete", deleting: true, unconditional: true}, {name: "ordinary-successful-delete", deleting: true, unconditional: true},
{name: "ordinary-skip-free-version", deleting: true, unconditional: true, skipFreeVersion: true}, {name: "ordinary-skip-free-version", deleting: true, unconditional: true, skipFreeVersion: true},
{name: "bulk-failed-primary-delete", deleting: true, bulk: true, failPrimaryDelete: true},
{name: "bulk-restored-failed-primary-delete", deleting: true, bulk: true, restored: true, failPrimaryDelete: true},
{name: "bulk-successful-delete", deleting: true, bulk: true},
{name: "bulk-distinct-reference", deleting: true, bulk: true, differentRemote: true},
{name: "bulk-skip-free-version", deleting: true, bulk: true, skipFreeVersion: true},
{name: "unversioned-failed-primary-delete", deleting: true, unconditional: true, unversioned: true, failPrimaryDelete: true},
{name: "unversioned-successful-delete", deleting: true, unconditional: true, unversioned: true},
{name: "bulk-unversioned-failed-primary-delete", deleting: true, bulk: true, unversioned: true, failPrimaryDelete: true},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
z, bucket := consistencyPools(t) z, bucket := consistencyPools(t)
@@ -1094,13 +1104,13 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
if test.restored { if test.restored {
metadata[xhttp.AmzRestore] = completedRestoreObj(time.Now().Add(time.Hour)).String() metadata[xhttp.AmzRestore] = completedRestoreObj(time.Now().Add(time.Hour)).String()
} }
oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{Versioned: true, UserDefined: metadata}) oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{Versioned: !test.unversioned, UserDefined: metadata})
secondaryMetadata := maps.Clone(metadata) secondaryMetadata := maps.Clone(metadata)
if test.differentRemote { if test.differentRemote {
secondaryMetadata[ReservedMetadataPrefixLower+TransitionedObjectName] = "other-remote-object" secondaryMetadata[ReservedMetadataPrefixLower+TransitionedObjectName] = "other-remote-object"
} }
putConsistencyObject(t, z, bucket, object, 1, "data", ObjectOptions{ putConsistencyObject(t, z, bucket, object, 1, "data", ObjectOptions{
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime, UserDefined: secondaryMetadata, Versioned: !test.unversioned, VersionID: oi.VersionID, MTime: oi.ModTime, UserDefined: secondaryMetadata,
}) })
current, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID}) current, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
if err != nil || current.TransitionedObject.Status != "complete" || current.IsRemote() == test.restored { if err != nil || current.TransitionedObject.Status != "complete" || current.IsRemote() == test.restored {
@@ -1113,7 +1123,11 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
set := z.serverPools[0].getHashedSet(object) set := z.serverPools[0].getHashedSet(object)
getDisks := set.getDisks getDisks := set.getDisks
faulty := append([]StorageAPI(nil), getDisks()...) faulty := append([]StorageAPI(nil), getDisks()...)
var failed, attempted atomic.Int32
for i := range faulty { for i := range faulty {
if test.bulk {
faulty[i] = bulkDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, mode: "eio", fail: true, failed: &failed, attempted: &attempted}
}
faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID} faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID}
} }
set.getDisks = func() []StorageAPI { return faulty } set.getDisks = func() []StorageAPI { return faulty }
@@ -1121,13 +1135,20 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
} }
if test.deleting { if test.deleting {
opts := ObjectOptions{ opts := ObjectOptions{
Versioned: true, VersionID: oi.VersionID, SkipFreeVersion: test.skipFreeVersion, Versioned: !test.unversioned, VersionID: oi.VersionID, SkipFreeVersion: test.skipFreeVersion,
CheckPrecondFn: func(info ObjectInfo) bool { return info.ETag != current.ETag }, CheckPrecondFn: func(info ObjectInfo) bool { return info.ETag != current.ETag },
} }
if test.unconditional { if test.unconditional {
opts.CheckPrecondFn = nil opts.CheckPrecondFn = nil
} }
_, err := z.DeleteObject(t.Context(), bucket, object, opts) var err error
if test.bulk {
opts.CheckPrecondFn = nil
_, errs := z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object, VersionID: oi.VersionID}}}, opts)
err = errs[0]
} else {
_, err = z.DeleteObject(t.Context(), bucket, object, opts)
}
if (err != nil) != test.failPrimaryDelete { if (err != nil) != test.failPrimaryDelete {
t.Fatalf("unexpected authoritative delete result: %v", err) t.Fatalf("unexpected authoritative delete result: %v", err)
} }
@@ -1145,7 +1166,7 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
_, err = z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID}) _, err = z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
primaryDeleted := test.deleting && !test.failPrimaryDelete primaryDeleted := test.deleting && !test.failPrimaryDelete
if primaryDeleted { if primaryDeleted {
if !isErrVersionNotFound(err) { if !isErrVersionNotFound(err) && (!test.unversioned || !isErrObjectNotFound(err)) {
t.Fatalf("authoritative copy survived successful delete: %v", err) t.Fatalf("authoritative copy survived successful delete: %v", err)
} }
} else if err != nil { } else if err != nil {
@@ -1306,3 +1327,182 @@ func TestPoolsDeleteDirectoryMarker(t *testing.T) {
} }
} }
} }
// An unconditional DELETE of an object without versions removes its copies in
// every pool. When the delete fails in one pool, the latest copy must stay in
// place: removing it anyway serves an older write of the object.
func TestPoolsDeleteUnversionedReportsOtherPoolFailure(t *testing.T) {
for _, offline := range []bool{false, true} {
for latest := range 2 {
t.Run(fmt.Sprintf("offline=%v/latest=%d", offline, latest), func(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "unversioned-failure"
older := 1 - latest
putConsistencyObject(t, z, bucket, object, older, "older", ObjectOptions{MaxParity: true, MTime: time.Now().Add(-time.Hour)})
oi := putConsistencyObject(t, z, bucket, object, latest, "latest", ObjectOptions{MaxParity: true})
set := z.serverPools[older].getHashedSet(object)
getDisks := set.getDisks
disks := append([]StorageAPI(nil), getDisks()...)
for i := range disks {
switch {
case !offline:
disks[i] = consistencyDeleteFaultDisk{StorageAPI: disks[i], bucket: bucket, object: object}
case i < len(disks)/2:
// An EC 8+8 copy stays readable, but a delete needs 9 drives.
disks[i] = nil
}
}
set.getDisks = func() []StorageAPI { return disks }
defer func() { set.getDisks = getDisks }()
if _, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{}); err == nil {
t.Error("delete reported success despite the other pool's write failure")
}
set.getDisks = getDisks
if got, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); err != nil || got.ETag != oi.ETag {
t.Errorf("after the failed delete the object reads ETag %q, %v; want the latest write %q", got.ETag, err, oi.ETag)
}
})
}
}
}
// consistencyBulkDeleteFaultDisk fails the multi-object deletes of one object.
type consistencyBulkDeleteFaultDisk struct {
StorageAPI
bucket, object string
}
func (d consistencyBulkDeleteFaultDisk) DeleteVersions(ctx context.Context, volume string, versions []FileInfoVersions, opts DeleteOptions) []error {
errs := make([]error, len(versions))
var rest []FileInfoVersions
var restIdx []int
for i, v := range versions {
if volume == d.bucket && v.Name == d.object {
errs[i] = errDiskFull
continue
}
rest = append(rest, v)
restIdx = append(restIdx, i)
}
if len(rest) > 0 {
for i, err := range d.StorageAPI.DeleteVersions(ctx, volume, rest, opts) {
errs[restIdx[i]] = err
}
}
return errs
}
// A multi-object delete must not report an object or version as deleted while
// a pool that failed to delete it still holds a copy.
func TestPoolsDeleteObjectsReportsOtherPoolFailure(t *testing.T) {
for _, versioned := range []bool{false, true} {
t.Run(fmt.Sprintf("versioned=%v", versioned), func(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "bulk-delete-failure"
opts := ObjectOptions{Versioned: versioned}
oi := putConsistencyObject(t, z, bucket, object, 0, "latest", opts)
if versioned {
// The copy a data movement leaves until it removes the source.
putConsistencyObject(t, z, bucket, object, 1, "latest", ObjectOptions{Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime})
} else {
putConsistencyObject(t, z, bucket, object, 1, "older", ObjectOptions{MTime: time.Now().Add(-time.Hour)})
}
set := z.serverPools[1].getHashedSet(object)
getDisks := set.getDisks
faulty := append([]StorageAPI(nil), getDisks()...)
for i := range faulty {
faulty[i] = consistencyBulkDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object}
}
set.getDisks = func() []StorageAPI { return faulty }
defer func() { set.getDisks = getDisks }()
_, errs := z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object, VersionID: oi.VersionID}}}, opts)
if errs[0] == nil {
t.Error("multi-object delete reported success despite the other pool's write failure")
}
})
}
}
// Pools delete a multi-object batch in parallel. With half the drives of the
// older copy's set offline, an EC 8+8 copy stays readable but cannot be
// deleted: the batch must fail the object and keep its latest copy, instead of
// deleting it and serving the older write once the drives are back.
func TestPoolsDeleteObjectsKeepsLatestWithoutDeleteQuorum(t *testing.T) {
for latest := range 2 {
t.Run(fmt.Sprintf("latest=%d", latest), func(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "bulk-delete-quorum"
older := 1 - latest
putConsistencyObject(t, z, bucket, object, older, "older", ObjectOptions{MaxParity: true, MTime: time.Now().Add(-time.Hour)})
oi := putConsistencyObject(t, z, bucket, object, latest, "latest", ObjectOptions{MaxParity: true})
set := z.serverPools[older].getHashedSet(object)
getDisks := set.getDisks
disks := append([]StorageAPI(nil), getDisks()...)
for i := range len(disks) / 2 {
disks[i] = nil
}
set.getDisks = func() []StorageAPI { return disks }
defer func() { set.getDisks = getDisks }()
_, errs := z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object}}}, ObjectOptions{})
if !errors.As(errs[0], &InsufficientWriteQuorum{}) {
t.Errorf("multi-object delete: got %v, want InsufficientWriteQuorum", errs[0])
}
set.getDisks = getDisks
if got, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); err != nil || got.ETag != oi.ETag {
t.Errorf("after the failed delete the object reads ETag %q, %v; want the latest write %q", got.ETag, err, oi.ETag)
}
// Nothing was deleted, so the retry removes every copy.
if _, errs = z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object}}}, ObjectOptions{}); errs[0] != nil {
t.Fatal(errs[0])
}
for i, pool := range z.serverPools {
if _, err := pool.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); !isErrObjectNotFound(err) {
t.Errorf("pool %d retained the object after the retry: %v", i, err)
}
}
})
}
}
// A delete marker hides the object in every pool. When the versions of an
// object are in two pools and the set of one of them cannot take a write, the
// marker goes to the other pool instead of failing the delete. The versions
// stay below the marker.
func TestPoolsDeleteMarkerSkipsSetWithoutQuorum(t *testing.T) {
for _, tc := range []struct {
name string
degraded int // pool whose set loses drives
offline int
}{
{"older version readable", 0, 8},
{"older version unreadable", 0, 9},
{"latest version readable", 1, 8},
} {
t.Run(tc.name, func(t *testing.T) {
z, bucket := consistencyPools(t)
const object = "marker-quorum"
v1 := putConsistencyObject(t, z, bucket, object, 0, "v1", ObjectOptions{Versioned: true, MaxParity: true, MTime: time.Now().Add(-time.Hour)})
v2 := putConsistencyObject(t, z, bucket, object, 1, "v2", ObjectOptions{Versioned: true, MaxParity: true})
set := z.serverPools[tc.degraded].getHashedSet(object)
getDisks := set.getDisks
disks := append([]StorageAPI(nil), getDisks()...)
for i := range tc.offline {
disks[i] = nil
}
set.getDisks = func() []StorageAPI { return disks }
defer func() { set.getDisks = getDisks }()
if _, err := z.DeleteObject(t.Context(), bucket, object, ObjectOptions{Versioned: true}); err != nil {
t.Fatalf("delete with the set of pool %d without quorum: %v", tc.degraded, err)
}
set.getDisks = getDisks
if _, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); !isErrObjectNotFound(err) && !isErrMethodNotAllowed(err) {
t.Errorf("object still readable after the delete: %v", err)
}
for pool, v := range []ObjectInfo{v1, v2} {
if _, err := z.serverPools[pool].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: v.VersionID}); err != nil {
t.Errorf("pool %d lost version %s: %v", pool, v.VersionID, err)
}
}
})
}
}
+22 -32
View File
@@ -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,17 +898,19 @@ 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 isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) { if isDataMovementSuperseded(err) {
ignore = true err = nil
stopFn(0, nil) }
} if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
ignore = true
stopFn(0, nil)
} }
if !ignore { if !ignore {
stopFn(version.Size, err) stopFn(version.Size, err)
@@ -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
+582
View File
@@ -0,0 +1,582 @@
// 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"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
minio "github.com/minio/minio-go/v7"
)
func bulkDeletePools(t *testing.T, n int) (*erasureServerPools, string) {
t.Helper()
ctx, cancel := context.WithCancel(t.Context())
dirs, err := getRandomDisks(4 * n)
if err != nil {
cancel()
t.Fatal(err)
}
var endpoints EndpointServerPools
for i := range n {
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 := "bulk-delete-consistency"
if err := z.MakeBucket(ctx, bucket, MakeBucketOptions{}); err != nil {
t.Fatal(err)
}
return z, bucket
}
func bulkDeleteBody(t *testing.T, z *erasureServerPools, bucket, object, version string) string {
t.Helper()
r, err := z.GetObjectNInfo(t.Context(), bucket, object, nil, nil, ObjectOptions{VersionID: version})
if err != nil {
t.Fatal(err)
}
defer r.Close()
body, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
return string(body)
}
// All drives are online and all metadata reads succeed. Failure starts inside
// DeleteVersions, after any preflight. Count attempts to delete the latest copy
// instead of waiting for them: a safe implementation must never make that call.
type bulkDeleteFaultDisk struct {
StorageAPI
bucket, object, mode string
fail, latest bool
failed, attempted *atomic.Int32
}
func (d bulkDeleteFaultDisk) DeleteVersions(ctx context.Context, bucket string, versions []FileInfoVersions, opts DeleteOptions) []error {
if bucket != d.bucket {
return d.StorageAPI.DeleteVersions(ctx, bucket, versions, opts)
}
errs := make([]error, len(versions))
for i, v := range versions {
if v.Name != d.object {
errs[i] = d.StorageAPI.DeleteVersions(ctx, bucket, []FileInfoVersions{v}, opts)[0]
continue
}
if d.latest {
d.attempted.Add(1)
}
if !d.fail {
errs[i] = d.StorageAPI.DeleteVersions(ctx, bucket, []FileInfoVersions{v}, opts)[0]
continue
}
d.failed.Add(1)
switch d.mode {
case "eio":
errs[i] = syscall.EIO
case "disconnect":
errs[i] = errDiskNotFound
case "permission":
meta := filepath.Join(d.String(), bucket, v.Name, xlStorageFormatFile)
info, err := os.Stat(meta)
if err == nil {
err = os.Chmod(meta, 0)
}
if err != nil {
errs[i] = err
continue
}
errs[i] = d.StorageAPI.DeleteVersions(ctx, bucket, []FileInfoVersions{v}, opts)[0]
if err := os.Chmod(meta, info.Mode()); err != nil {
panic(err)
}
}
}
return errs
}
func requireBulkDeletePermissionFailure(t *testing.T) {
t.Helper()
probe := filepath.Join(t.TempDir(), "permission-probe")
if err := os.WriteFile(probe, []byte("metadata"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chmod(probe, 0); err != nil {
t.Skipf("cannot inject filesystem permissions: %v", err)
}
_, err := os.ReadFile(probe)
if restoreErr := os.Chmod(probe, 0o600); restoreErr != nil {
t.Fatal(restoreErr)
}
if !os.IsPermission(err) {
t.Skip("this user/filesystem can read mode-000 files")
}
}
func TestPoolsBulkDeleteKeepsLatestOnIOFailure(t *testing.T) {
for _, mode := range []string{"eio", "disconnect", "permission"} {
for _, n := range []int{2, 3} {
for primary := range n {
for _, kind := range []string{"unversioned", "null", "uuid"} {
t.Run(fmt.Sprintf("%s/pools=%d/primary=%d/%s", mode, n, primary, kind), func(t *testing.T) {
if mode == "permission" {
requireBulkDeletePermissionFailure(t)
}
z, bucket := bulkDeletePools(t, n)
object, old := "target", (primary+1)%n
opts := ObjectOptions{MTime: UTCNow().Add(-time.Hour), MaxParity: true}
oldBody := "old"
if kind == "uuid" {
opts.Versioned, opts.VersionID, oldBody = true, mustGetUUID(), "latest"
}
putConsistencyObject(t, z, bucket, object, old, oldBody, opts)
opts.MTime = opts.MTime.Add(time.Minute)
latest := putConsistencyObject(t, z, bucket, object, primary, "latest", opts)
version := opts.VersionID
if kind == "null" {
version = nullVersionID
}
for _, key := range []string{"before", "after"} {
putConsistencyObject(t, z, bucket, key, primary, "unrelated", ObjectOptions{})
}
var failed, attempted atomic.Int32
var restore []func()
for _, p := range []int{old, primary} {
set := z.serverPools[p].getHashedSet(object)
original := set.getDisks
disks := append([]StorageAPI(nil), original()...)
for i, disk := range disks {
disks[i] = bulkDeleteFaultDisk{StorageAPI: disk, bucket: bucket, object: object, mode: mode, fail: p == old, latest: p == primary, failed: &failed, attempted: &attempted}
}
set.getDisks = func() []StorageAPI { return disks }
restore = append(restore, func() { set.getDisks = original })
}
defer func() {
for _, f := range restore {
f()
}
}()
objects := []ObjectToDelete{{ObjectV: ObjectV{ObjectName: "before"}}, {ObjectV: ObjectV{ObjectName: object, VersionID: version}}, {ObjectV: ObjectV{ObjectName: "after"}}}
deleted, errs := z.DeleteObjects(t.Context(), bucket, objects, ObjectOptions{})
if failed.Load() != 4 || attempted.Load() != 0 {
t.Errorf("cleanup failures=%d; latest deletion attempts=%d, want 4/0", failed.Load(), attempted.Load())
}
if errs[1] == nil {
t.Error("cleanup failure was reported as success")
}
for _, i := range []int{0, 2} {
if errs[i] != nil || deleted[i].ObjectName != objects[i].ObjectName {
t.Errorf("unrelated item %d: %+v, %v", i, deleted[i], errs[i])
}
}
if body := bulkDeleteBody(t, z, bucket, object, version); body != "latest" {
t.Errorf("GET during failure=%q", body)
}
for _, f := range restore {
f()
}
if body := bulkDeleteBody(t, z, bucket, object, version); body != "latest" {
t.Errorf("GET after recovery=%q", body)
}
listed, err := z.ListObjectVersions(t.Context(), bucket, object, "", "", "", 100)
if err != nil || len(listed.Objects) != 1 || listed.Objects[0].ETag != latest.ETag {
t.Errorf("recovered versions=%+v, %v", listed.Objects, err)
}
_, retry := z.DeleteObjects(t.Context(), bucket, objects[1:2], ObjectOptions{})
if retry[0] != nil {
t.Fatal(retry[0])
}
_, err = z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: version})
if !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
t.Errorf("retry retained the object: %v", err)
}
})
}
}
}
}
}
func TestPoolsBulkDeleteReplicationIdentity(t *testing.T) {
z, _ := bulkDeletePools(t, 2)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
bucket, router, err := initAPIHandlerTest(ctx, z, nil, MakeBucketOptions{VersioningEnabled: true})
if err != nil {
t.Fatal(err)
}
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("unexpected outbound replication for a failed deletion")
w.WriteHeader(http.StatusNoContent)
}))
defer remote.Close()
client, err := minio.New(strings.TrimPrefix(remote.URL, "http://"), &minio.Options{Region: "us-east-1", MaxRetries: 1})
if err != nil {
t.Fatal(err)
}
_, restoreReplication := r5ReplicationFixture(t, z, bucket, client)
defer restoreReplication()
versions := make(map[string]string)
for _, key := range []string{"first", "second"} {
v := putConsistencyObject(t, z, bucket, key, 0, "payload", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour)})
putConsistencyObject(t, z, bucket, key, 1, "payload", ObjectOptions{Versioned: true, VersionID: v.VersionID, MTime: v.ModTime})
versions[key] = v.VersionID
}
set := z.serverPools[0].getHashedSet("first")
original := set.getDisks
disks := append([]StorageAPI(nil), original()...)
disks[0], disks[1] = nil, nil
set.getDisks = func() []StorageAPI { return disks }
defer func() { set.getDisks = original }()
body := []byte(fmt.Sprintf("<Delete><Object><Key>first</Key><VersionId>%s</VersionId></Object><Object><Key>second</Key><VersionId>%s</VersionId></Object></Delete>", versions["first"], versions["second"]))
req, err := newTestSignedRequestV4(http.MethodPost, getMultiDeleteObjectURL("", bucket), int64(len(body)), bytes.NewReader(body), globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var result struct {
Errors []struct {
Key, Code string
VersionID string `xml:"VersionId"`
} `xml:"Error"`
Deleted []struct{ Key string } `xml:"Deleted"`
}
if err := xml.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK || len(result.Errors) != 2 || len(result.Deleted) != 0 {
t.Fatalf("lost response identity: HTTP %d %s", rec.Code, rec.Body.String())
}
for _, e := range result.Errors {
if versions[e.Key] == "" || e.VersionID != versions[e.Key] || e.Code != "SlowDownWrite" {
t.Errorf("incorrect error identity: %+v", e)
}
delete(versions, e.Key)
}
if len(versions) != 0 {
t.Errorf("missing errors: %v", versions)
}
}
func TestPoolsBulkDeleteNullBeforeMarker(t *testing.T) {
for _, n := range []int{1, 2, 3} {
t.Run(fmt.Sprintf("pools=%d", n), func(t *testing.T) {
z, bucket := bulkDeletePools(t, n)
object := "suspended"
history := putConsistencyObject(t, z, bucket, object, 0, "history", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour)})
for p := range n {
putConsistencyObject(t, z, bucket, object, p, "null", ObjectOptions{MTime: UTCNow().Add(-time.Duration(p+1) * time.Minute)})
}
objects := []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object}}, {ObjectV: ObjectV{ObjectName: object, VersionID: nullVersionID}}}
deleted, errs := z.DeleteObjects(t.Context(), bucket, objects, ObjectOptions{VersionSuspended: true})
if errs[0] != nil || errs[1] != nil || !deleted[0].DeleteMarker || deleted[1].VersionID != nullVersionID {
t.Fatalf("results=%+v errors=%v", deleted, errs)
}
info, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
if !info.DeleteMarker || !isErrObjectNotFound(err) {
t.Errorf("replacement null marker lost: %+v, %v", info, err)
}
if body := bulkDeleteBody(t, z, bucket, object, history.VersionID); body != "history" {
t.Errorf("UUID history changed: %q", body)
}
})
}
}
func TestPoolsBulkDeletePartialCleanupRetry(t *testing.T) {
for _, kind := range []string{"unversioned", "null", "uuid"} {
t.Run(kind, func(t *testing.T) {
z, bucket := bulkDeletePools(t, 2)
object := "partial-cleanup"
opts := ObjectOptions{MTime: UTCNow().Add(-time.Hour)}
oldBody := "old"
if kind == "uuid" {
opts.Versioned, opts.VersionID, oldBody = true, mustGetUUID(), "latest"
}
putConsistencyObject(t, z, bucket, object, 0, oldBody, opts)
opts.MTime = UTCNow()
putConsistencyObject(t, z, bucket, object, 1, "latest", opts)
version := opts.VersionID
if kind == "null" {
version = nullVersionID
}
set := z.serverPools[0].getHashedSet(object)
original := set.getDisks
disks := append([]StorageAPI(nil), original()...)
var failed, attempted atomic.Int32
for i := range 2 {
disks[i] = bulkDeleteFaultDisk{StorageAPI: disks[i], bucket: bucket, object: object, mode: "eio", fail: true, failed: &failed, attempted: &attempted}
}
set.getDisks = func() []StorageAPI { return disks }
defer func() { set.getDisks = original }()
objects := []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object, VersionID: version}}}
_, errs := z.DeleteObjects(t.Context(), bucket, objects, ObjectOptions{})
if errs[0] == nil || failed.Load() != 2 {
t.Fatalf("did not partially fail cleanup: %v, failures=%d", errs, failed.Load())
}
set.getDisks = original
if got := bulkDeleteBody(t, z, bucket, object, version); got != "latest" {
t.Fatalf("latest lost after partial cleanup: %q", got)
}
_, errs = z.DeleteObjects(t.Context(), bucket, objects, ObjectOptions{})
if errs[0] != nil {
t.Fatalf("recovered retry must combine successful removals and already-absent replies: %v", errs[0])
}
_, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: version})
if !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
t.Errorf("retry retained object: %v", err)
}
})
}
}
func TestPoolsBulkDeleteDifferentPrimaries(t *testing.T) {
for _, n := range []int{2, 3} {
t.Run(fmt.Sprintf("pools=%d", n), func(t *testing.T) {
z, bucket := bulkDeletePools(t, n)
objects := make([]ObjectToDelete, n)
for primary := range n {
object := fmt.Sprintf("key-%d", primary)
objects[primary].ObjectName = object
putConsistencyObject(t, z, bucket, object, (primary+1)%n, "old", ObjectOptions{MTime: UTCNow().Add(-time.Hour)})
putConsistencyObject(t, z, bucket, object, primary, "latest", ObjectOptions{})
}
set := z.serverPools[0].getHashedSet(objects[n-1].ObjectName)
original := set.getDisks
disks := append([]StorageAPI(nil), original()...)
var failed, attempted atomic.Int32
for i, disk := range disks {
disks[i] = bulkDeleteFaultDisk{StorageAPI: disk, bucket: bucket, object: objects[n-1].ObjectName, mode: "eio", fail: true, failed: &failed, attempted: &attempted}
}
set.getDisks = func() []StorageAPI { return disks }
defer func() { set.getDisks = original }()
deleted, errs := z.DeleteObjects(t.Context(), bucket, objects, ObjectOptions{})
for i := range objects {
if deleted[i].ObjectName != objects[i].ObjectName {
t.Errorf("wrong result index %d: %+v", i, deleted[i])
}
if i == n-1 {
if errs[i] == nil || bulkDeleteBody(t, z, bucket, objects[i].ObjectName, "") != "latest" {
t.Errorf("failed item lost latest: %v", errs[i])
}
continue
}
_, err := z.GetObjectInfo(t.Context(), bucket, objects[i].ObjectName, ObjectOptions{})
if errs[i] != nil || !isErrObjectNotFound(err) {
t.Errorf("healthy item %d: delete=%v GET=%v", i, errs[i], err)
}
}
})
}
}
func TestPoolsBulkDeleteTieAndUnreadable(t *testing.T) {
for _, unreadable := range []bool{false, true} {
t.Run(fmt.Sprintf("unreadable=%t", unreadable), func(t *testing.T) {
z, bucket := bulkDeletePools(t, 3)
object := "same-time"
stamp := UTCNow().Add(-time.Hour)
for p := range 3 {
putConsistencyObject(t, z, bucket, object, p, fmt.Sprintf("pool-%d", p), ObjectOptions{MTime: stamp})
}
if got := bulkDeleteBody(t, z, bucket, object, ""); got != "pool-0" {
t.Fatalf("unexpected read winner: %q", got)
}
set := z.serverPools[2].getHashedSet(object)
original := set.getDisks
disks := append([]StorageAPI(nil), original()...)
var failed, attempted atomic.Int32
for i, disk := range disks {
if unreadable {
disks[i] = consistencyReadFaultDisk{StorageAPI: disk, bucket: bucket, object: object}
} else {
disks[i] = bulkDeleteFaultDisk{StorageAPI: disk, bucket: bucket, object: object, mode: "eio", fail: true, failed: &failed, attempted: &attempted}
}
}
set.getDisks = func() []StorageAPI { return disks }
defer func() { set.getDisks = original }()
_, errs := z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object}}}, ObjectOptions{})
set.getDisks = original
if errs[0] == nil || bulkDeleteBody(t, z, bucket, object, "") != "pool-0" {
t.Errorf("lost tie winner or unreadable-pool error: %v", errs[0])
}
})
}
}
type bulkDeletePauseDisk struct {
StorageAPI
bucket, object string
entered chan<- struct{}
release <-chan struct{}
}
func (d bulkDeletePauseDisk) DeleteVersions(ctx context.Context, bucket string, versions []FileInfoVersions, opts DeleteOptions) []error {
for _, v := range versions {
if bucket != d.bucket || v.Name != d.object {
continue
}
select {
case d.entered <- struct{}{}:
default:
}
select {
case <-d.release:
case <-ctx.Done():
}
errs := make([]error, len(versions))
for i := range errs {
errs[i] = syscall.EIO
}
return errs
}
return d.StorageAPI.DeleteVersions(ctx, bucket, versions, opts)
}
func TestPoolsBulkDeleteLockAndCancellation(t *testing.T) {
for _, canceled := range []bool{false, true} {
t.Run(fmt.Sprintf("cancel=%t", canceled), func(t *testing.T) {
z, bucket := bulkDeletePools(t, 2)
object := "locked"
putConsistencyObject(t, z, bucket, object, 0, "old", ObjectOptions{MTime: UTCNow().Add(-time.Hour)})
putConsistencyObject(t, z, bucket, object, 1, "latest", ObjectOptions{})
set := z.serverPools[0].getHashedSet(object)
original := set.getDisks
disks := append([]StorageAPI(nil), original()...)
entered, release := make(chan struct{}, 4), make(chan struct{})
for i, disk := range disks {
disks[i] = bulkDeletePauseDisk{StorageAPI: disk, bucket: bucket, object: object, entered: entered, release: release}
}
set.getDisks = func() []StorageAPI { return disks }
defer func() { set.getDisks = original }()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
done := make(chan error, 1)
go func() {
_, errs := z.DeleteObjects(ctx, bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object}}}, ObjectOptions{})
done <- errs[0]
}()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("old-copy cleanup did not start")
}
if canceled {
cancel()
if err := <-done; err == nil {
t.Fatal("canceled cleanup succeeded")
}
set.getDisks = original
if got := bulkDeleteBody(t, z, bucket, object, ""); got != "latest" {
t.Errorf("cancellation lost latest: %q", got)
}
return
}
putDone := make(chan error, 1)
reader := mustGetPutObjReader(t, bytes.NewBufferString("after-put"), 9, "", "")
go func() {
_, err := z.PutObject(t.Context(), bucket, object, reader, ObjectOptions{})
putDone <- err
}()
select {
case err := <-putDone:
t.Fatalf("PUT passed the held DELETE lock: %v", err)
case <-time.After(50 * time.Millisecond):
}
close(release)
if err := <-done; err == nil {
t.Error("cleanup failure was lost")
}
if err := <-putDone; err != nil {
t.Fatal(err)
}
if got := bulkDeleteBody(t, z, bucket, object, ""); got != "after-put" {
t.Errorf("concurrent PUT lost: %q", got)
}
})
}
}
type bulkDeleteReplyDisk struct {
StorageAPI
err error
}
func (d bulkDeleteReplyDisk) IsOnline() bool { return true }
func (d bulkDeleteReplyDisk) DeleteVersions(_ context.Context, _ string, versions []FileInfoVersions, _ DeleteOptions) []error {
errs := make([]error, len(versions))
for i := range errs {
errs[i] = d.err
}
return errs
}
func TestBulkDeleteQuorumEvidence(t *testing.T) {
for _, tc := range []struct {
name string
obj ObjectToDelete
opts ObjectOptions
ok bool
}{
{name: "unversioned", ok: true},
{name: "null", obj: ObjectToDelete{ObjectV: ObjectV{VersionID: nullVersionID}}, ok: true},
{name: "uuid", obj: ObjectToDelete{ObjectV: ObjectV{VersionID: mustGetUUID()}}, ok: true},
{name: "marker", opts: ObjectOptions{Versioned: true}},
{name: "suspended-marker", opts: ObjectOptions{VersionSuspended: true}},
{name: "pending-purge", obj: ObjectToDelete{ObjectV: ObjectV{VersionID: mustGetUUID()}, VersionPurgeStatuses: "target=PENDING;"}},
{name: "replicated-marker", obj: ObjectToDelete{DeleteMarkerReplicationStatus: "target=PENDING;"}, opts: ObjectOptions{Versioned: true}},
} {
t.Run(tc.name, func(t *testing.T) {
disks := []StorageAPI{
bulkDeleteReplyDisk{},
bulkDeleteReplyDisk{},
bulkDeleteReplyDisk{err: errFileNotFound},
bulkDeleteReplyDisk{err: errFileVersionNotFound},
}
er := erasureObjects{getDisks: func() []StorageAPI { return disks }}
tc.obj.ObjectName, tc.opts.NoAuditLog = "object", true
_, errs := er.DeleteObjects(t.Context(), "bucket", []ObjectToDelete{tc.obj}, tc.opts)
if (errs[0] == nil) != tc.ok {
t.Errorf("two writes + two absent replies: error=%v, want success=%t", errs[0], tc.ok)
}
})
}
}
@@ -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)
}
})
}
}
}
+344 -8
View File
@@ -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})
+19 -23
View File
@@ -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
+216 -70
View File
@@ -39,6 +39,7 @@ import (
"github.com/minio/minio-go/v7/pkg/set" "github.com/minio/minio-go/v7/pkg/set"
"github.com/minio/minio-go/v7/pkg/tags" "github.com/minio/minio-go/v7/pkg/tags"
"github.com/minio/minio/internal/bpool" "github.com/minio/minio/internal/bpool"
"github.com/minio/minio/internal/bucket/lifecycle"
"github.com/minio/minio/internal/cachevalue" "github.com/minio/minio/internal/cachevalue"
"github.com/minio/minio/internal/config/storageclass" "github.com/minio/minio/internal/config/storageclass"
xioutil "github.com/minio/minio/internal/ioutil" xioutil "github.com/minio/minio/internal/ioutil"
@@ -1183,6 +1184,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 +1211,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 +1252,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,24 +1356,13 @@ 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. // Retire older copies before the newest copy for unversioned objects.
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.
if len(noReadQuorumPools) > 0 && !opts.Versioned && !opts.VersionSuspended { if len(noReadQuorumPools) > 0 && !opts.Versioned && !opts.VersionSuspended {
if pinfo.ObjInfo.TransitionedObject.Status == lifecycle.TransitionComplete {
// Restored and remote copies can share a tier object. Reuse the
// reconciliation which keeps that reference out of premature GC.
return z.deleteObjectReconciled(ctx, bucket, object, opts)
}
return z.deleteObjectFromAllPools(ctx, bucket, object, opts, noReadQuorumPools) return z.deleteObjectFromAllPools(ctx, bucket, object, opts, noReadQuorumPools)
} }
@@ -1356,6 +1373,21 @@ func (z *erasureServerPools) DeleteObject(ctx context.Context, bucket string, ob
return objInfo, err return objInfo, err
} }
// A delete marker hides the object in every pool. When more than one pool
// holds the object, put the marker into the first one with a readable
// version whose set can take the write, so that a set without quorum does
// not fail the delete while another pool could take the marker.
if opts.VersionID == "" && len(noReadQuorumPools) > 1 {
for idx, pool := range z.serverPools {
if !poolReadable(noReadQuorumPools, idx) || deleteQuorumMissing(pool, object) {
continue
}
objInfo, err = pool.DeleteObject(ctx, bucket, object, opts)
objInfo.Name = decodeDirObject(object)
return objInfo, err
}
}
for _, pool := range z.serverPools { for _, pool := range z.serverPools {
objInfo, err := pool.DeleteObject(ctx, bucket, object, opts) objInfo, err := pool.DeleteObject(ctx, bucket, object, opts)
if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) { if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
@@ -1376,36 +1408,76 @@ func (z *erasureServerPools) DeleteObject(ctx context.Context, bucket string, ob
} }
func (z *erasureServerPools) deleteObjectFromAllPools(ctx context.Context, bucket string, object string, opts ObjectOptions, poolIndices []poolErrs) (objInfo ObjectInfo, err error) { func (z *erasureServerPools) deleteObjectFromAllPools(ctx context.Context, bucket string, object string, opts ObjectOptions, poolIndices []poolErrs) (objInfo ObjectInfo, err error) {
derrs := make([]error, len(poolIndices))
dobjects := make([]ObjectInfo, len(poolIndices))
// Delete concurrently in all server pools that reported no error or read quorum error
// where the read quorum issue is from metadata inconsistency.
var wg sync.WaitGroup
for idx, pe := range poolIndices {
if v, ok := pe.Err.(InsufficientReadQuorum); ok && v.Type != RQInconsistentMeta {
derrs[idx] = InsufficientWriteQuorum{}
continue
}
wg.Add(1)
pool := z.serverPools[pe.Index]
go func(idx int, pool *erasureSets) {
defer wg.Done()
dobjects[idx], derrs[idx] = pool.DeleteObject(ctx, bucket, object, opts)
}(idx, pool)
}
wg.Wait()
// the poolIndices array is pre-sorted in order of latest ModTime, we care only about pool with latest object though
// the delete call tries to clean up other pools during DeleteObject call.
objInfo = dobjects[0]
objInfo.Name = decodeDirObject(object) objInfo.Name = decodeDirObject(object)
for _, derr := range derrs {
// Delete in all server pools that reported no error or read quorum error
// where the read quorum issue is from metadata inconsistency. The
// poolIndices array is pre-sorted in order of latest ModTime. Retire the
// older copies first and stop at the first failure, as
// deleteObjectReconciled does: a failed delete then keeps the latest copy
// in place, instead of serving an older write of the object.
for idx := len(poolIndices) - 1; idx >= 0; idx-- {
pe := poolIndices[idx]
if v, ok := pe.Err.(InsufficientReadQuorum); ok && v.Type != RQInconsistentMeta {
return objInfo, InsufficientWriteQuorum{}
}
dobj, derr := z.serverPools[pe.Index].DeleteObject(ctx, bucket, object, opts)
if idx == 0 {
objInfo, err = dobj, derr
break
}
if derr != nil && !isErrObjectNotFound(derr) && !isErrVersionNotFound(derr) { if derr != nil && !isErrObjectNotFound(derr) && !isErrVersionNotFound(derr) {
return objInfo, derr dobj.Name = decodeDirObject(object)
return dobj, derr
} }
} }
return objInfo, derrs[0] objInfo.Name = decodeDirObject(object)
return objInfo, err
}
// deleteQuorumMissing reports whether the set of object in pool has fewer
// online drives than a delete needs (N/2+1, as in erasureObjects.DeleteObjects).
func deleteQuorumMissing(pool *erasureSets, object string) bool {
disks := pool.getHashedSet(object).getDisks()
return countOnlineDisks(disks) < len(disks)/2+1
}
// poolReadable reports whether pools lists pool idx with a readable version.
func poolReadable(pools []poolErrs, idx int) bool {
for _, pe := range pools {
if pe.Index == idx {
return pe.Err == nil
}
}
return false
}
// copyWithoutDeleteQuorum avoids starting a known unwritable cleanup. This
// reduces partial deletion, but successful cleanup must still precede removing
// the authoritative copy: drives can fail after this preflight.
func (z *erasureServerPools) copyWithoutDeleteQuorum(ctx context.Context, bucket, object, versionID string) bool {
for _, pool := range z.serverPools {
if deleteQuorumMissing(pool, object) {
_, err := pool.GetObjectInfo(ctx, bucket, object, ObjectOptions{VersionID: versionID, NoLock: true})
if err == nil || isErrReadQuorum(err) || isErrMethodNotAllowed(err) {
return true
}
}
}
return false
}
// deletesData reports whether deleting obj removes an object or a version, as
// opposed to adding a delete marker (see erasureObjects.DeleteObjects).
func deletesData(obj ObjectToDelete, opts ObjectOptions) bool {
if obj.VersionID != "" {
return true
}
versioned := opts.Versioned
if opts.PrefixEnabledFn != nil {
versioned = opts.PrefixEnabledFn(obj.ObjectName)
}
return !versioned && !opts.VersionSuspended
} }
func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions) ([]DeletedObject, []error) { func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, objects []ObjectToDelete, opts ObjectOptions) ([]DeletedObject, []error) {
@@ -1414,6 +1486,11 @@ func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, o
objSets := set.NewStringSet() objSets := set.NewStringSet()
for i := range derrs { for i := range derrs {
objects[i].ObjectName = encodeDirObject(objects[i].ObjectName) objects[i].ObjectName = encodeDirObject(objects[i].ObjectName)
dobjects[i] = DeletedObject{
ObjectName: decodeDirObject(objects[i].ObjectName),
VersionID: objects[i].VersionID,
ReplicationState: objects[i].ReplicationState(),
}
derrs[i] = checkDelObjArgs(ctx, bucket, objects[i].ObjectName) derrs[i] = checkDelObjArgs(ctx, bucket, objects[i].ObjectName)
objSets.Add(objects[i].ObjectName) objSets.Add(objects[i].ObjectName)
@@ -1431,25 +1508,64 @@ func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, o
ctx = lkctx.Context() ctx = lkctx.Context()
defer multiDeleteLock.Unlock(lkctx) defer multiDeleteLock.Unlock(lkctx)
// Removing data needs the same authoritative-copy protection as a single
// DELETE. An online-drive preflight cannot protect against later I/O errors.
// Keep marker creation after data removal, including when the same request
// explicitly removes a null version and creates its replacement marker.
batch, batchIdx := objects, []int(nil)
if !z.SinglePool() {
batch = make([]ObjectToDelete, 0, len(objects))
batchIdx = make([]int, 0, len(objects))
data, dataIdx := make([]ObjectToDelete, 0, len(objects)), make([]int, 0, len(objects))
for i, obj := range objects {
if derrs[i] != nil {
continue
}
if deletesData(obj, opts) {
if z.copyWithoutDeleteQuorum(ctx, bucket, obj.ObjectName, obj.VersionID) {
derrs[i] = InsufficientWriteQuorum{}
continue
}
data = append(data, obj)
dataIdx = append(dataIdx, i)
continue
}
batch = append(batch, obj)
batchIdx = append(batchIdx, i)
}
if len(data) > 0 {
deleted, errs := z.deleteObjectsReconciled(ctx, bucket, data, opts)
for j, i := range dataIdx {
dobjects[i], derrs[i] = deleted[j], errs[j]
}
}
}
dObjectsByPool := make([][]DeletedObject, len(z.serverPools)) dObjectsByPool := make([][]DeletedObject, len(z.serverPools))
dErrsByPool := make([][]error, len(z.serverPools)) dErrsByPool := make([][]error, len(z.serverPools))
eg := errgroup.WithNErrs(len(z.serverPools)).WithConcurrency(len(z.serverPools)) if len(batch) > 0 {
for i, pool := range z.serverPools { eg := errgroup.WithNErrs(len(z.serverPools)).WithConcurrency(len(z.serverPools))
pool := pool for i, pool := range z.serverPools {
eg.Go(func() error { pool := pool
dObjectsByPool[i], dErrsByPool[i] = pool.DeleteObjects(ctx, bucket, objects, opts) eg.Go(func() error {
return nil dObjectsByPool[i], dErrsByPool[i] = pool.DeleteObjects(ctx, bucket, batch, opts)
}, i) return nil
}, i)
}
eg.Wait() // wait to check all the pools.
} }
eg.Wait() // wait to check all the pools.
for i := range dobjects { for j := range batch {
i := j
if batchIdx != nil {
i = batchIdx[j]
}
// Iterate over pools // Iterate over pools
for pool := range z.serverPools { for pool := range z.serverPools {
if dErrsByPool[pool][i] == nil && dObjectsByPool[pool][i].found { if dErrsByPool[pool][j] == nil && dObjectsByPool[pool][j].found {
// A fast exit when the object is found and removed // A fast exit when the object is found and removed
dobjects[i] = dObjectsByPool[pool][i] dobjects[i] = dObjectsByPool[pool][j]
derrs[i] = nil derrs[i] = nil
break break
} }
@@ -1457,8 +1573,8 @@ func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, o
// No error related to this object is found, assign this pool result // No error related to this object is found, assign this pool result
// whether it is nil because there is no object found or because of // whether it is nil because there is no object found or because of
// some other errors such erasure quorum errors. // some other errors such erasure quorum errors.
dobjects[i] = dObjectsByPool[pool][i] dobjects[i] = dObjectsByPool[pool][j]
derrs[i] = dErrsByPool[pool][i] derrs[i] = dErrsByPool[pool][j]
} }
} }
} }
@@ -1916,27 +2032,45 @@ 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)
} }
scan, err := startMultipartScan(ctx, false)
if err != nil {
return ListMultipartsInfo{}, err
}
defer scan.close()
var uploads []MultipartInfo poolResult := ListMultipartsInfo{}
poolResult.MaxUploads = maxUploads
poolResult.KeyMarker = keyMarker
poolResult.Prefix = prefix
poolResult.Delimiter = delimiter
// 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 == "" {
z.mpCache.Range(func(_ string, mp MultipartInfo) bool {
if mp.Bucket == bucket {
poolResult.Uploads = append(poolResult.Uploads, mp)
}
return true
})
sort.Slice(poolResult.Uploads, func(i int, j int) bool {
return poolResult.Uploads[i].Initiated.Before(poolResult.Uploads[j].Initiated)
})
return poolResult, nil
}
if z.SinglePool() {
return z.serverPools[0].getHashedSet(prefix).listMultipartUploadsExact(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
}
for idx, pool := range z.serverPools { for idx, pool := range z.serverPools {
if z.IsSuspended(idx) { if z.IsSuspended(idx) {
continue continue
} }
poolUploads, err := pool.scanMultipartUploads(scan, bucket, idx) result, err := pool.getHashedSet(prefix).listMultipartUploadsExact(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
if err != nil { if err != nil {
return ListMultipartsInfo{}, err return result, err
} }
uploads = append(uploads, poolUploads...) poolResult.Uploads = append(poolResult.Uploads, result.Uploads...)
} }
// Confirmed historical records lack a recoverable bucket/key and are return poolResult, nil
// omitted by the scan. They do not block listing identifiable uploads.
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
} }
// Initiate a new multipart upload on a hashedSet based on object name. // Initiate a new multipart upload on a hashedSet based on object name.
@@ -2230,6 +2364,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
@@ -2249,7 +2390,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
} }
@@ -3240,6 +3381,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 -25
View File
@@ -886,31 +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, err := s.scanMultipartUploads(scan, bucket, 0)
if err != nil {
return ListMultipartsInfo{}, err
}
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
}
func (s *erasureSets) scanMultipartUploads(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
} }
func (s *erasureSets) listMultipartUploadsExact(ctx context.Context, bucket, object string) (ListMultipartsInfo, error) { func (s *erasureSets) listMultipartUploadsExact(ctx context.Context, bucket, object string) (ListMultipartsInfo, error) {
+8 -2
View File
@@ -85,15 +85,21 @@ func TestIAMPolicyDeletionRemainsExplicit(t *testing.T) {
} }
p, err := sys.store.GetPolicy("readwrite") p, err := sys.store.GetPolicy("readwrite")
mustIAM(t, err) mustIAM(t, err)
native, err := sys.InfoPolicy("readwrite")
mustIAM(t, err)
if err := sys.DeletePolicy(ctx, "readwrite", true); err == nil { if err := sys.DeletePolicy(ctx, "readwrite", true); err == nil {
t.Fatal("local pristine builtin policy became deletable") t.Fatal("local pristine builtin policy became deletable")
} }
_, err = sys.SetPolicy(ctx, "readwrite", p) _, err = sys.SetPolicy(ctx, "readwrite", p)
mustIAM(t, err) mustIAM(t, err)
mustIAM(t, sys.DeletePolicy(ctx, "readwrite", true)) mustIAM(t, sys.DeletePolicy(ctx, "readwrite", true))
assertIAMPolicyResetInfo(t, sys, "readwrite", native)
mustIAM(t, sys.store.LoadIAMCache(ctx, false)) mustIAM(t, sys.store.LoadIAMCache(ctx, false))
if _, err := sys.store.GetPolicy("readwrite"); !errors.Is(err, errNoSuchPolicy) { assertIAMPolicyResetInfo(t, sys, "readwrite", native)
t.Fatalf("reload restored an explicitly deleted override: %v", err) r, err = loadIAMRevision(ctx, sys.store, getPolicyDocPath("readwrite"))
mustIAM(t, err)
if !r.Deleted {
t.Fatal("native policy restoration removed the override tombstone")
} }
_, err = sys.SetPolicy(ctx, "readwrite", p) _, err = sys.SetPolicy(ctx, "readwrite", p)
mustIAM(t, err) mustIAM(t, err)
+15 -11
View File
@@ -123,8 +123,8 @@ func (ies *IAMEtcdStore) saveIAMConfig(ctx context.Context, item any, itemPath s
return nil return nil
} }
func (ies *IAMEtcdStore) decodeIAMConfig(item any, data []byte, path string) error { func (ies *IAMEtcdStore) decodeIAMConfig(ctx context.Context, item any, data []byte, path string) error {
data, err := decryptData(data, path) data, err := decryptData(ctx, data, path)
if err != nil { if err != nil {
return err return err
} }
@@ -145,7 +145,7 @@ func (ies *IAMEtcdStore) loadIAMConfigBytes(ctx context.Context, path string) ([
if err != nil { if err != nil {
return nil, err return nil, err
} }
data, err = decryptData(data, path) data, err = decryptData(ctx, data, path)
if err == nil { if err == nil {
ies.index.observe(path, data) ies.index.observe(path, data)
} }
@@ -187,7 +187,7 @@ func (ies *IAMEtcdStore) loadPolicyDoc(ctx context.Context, policy string, m map
} }
func (ies *IAMEtcdStore) getPolicyDocKV(ctx context.Context, kvs *mvccpb.KeyValue, m map[string]PolicyDoc) error { func (ies *IAMEtcdStore) getPolicyDocKV(ctx context.Context, kvs *mvccpb.KeyValue, m map[string]PolicyDoc) error {
data, err := decryptData(kvs.Value, string(kvs.Key)) data, err := decryptData(ctx, kvs.Value, string(kvs.Key))
if err != nil { if err != nil {
if err == errConfigNotFound { if err == errConfigNotFound {
return errNoSuchPolicy return errNoSuchPolicy
@@ -211,27 +211,31 @@ func (ies *IAMEtcdStore) getPolicyDocKV(ctx context.Context, kvs *mvccpb.KeyValu
} }
func (ies *IAMEtcdStore) loadPolicyDocs(ctx context.Context, m map[string]PolicyDoc) error { func (ies *IAMEtcdStore) loadPolicyDocs(ctx context.Context, m map[string]PolicyDoc) error {
ctx, cancel := context.WithTimeout(ctx, defaultContextTimeout) // Bound the etcd read without shortening the caller's decryption budget.
cctx, cancel := context.WithTimeout(ctx, defaultContextTimeout)
defer cancel() defer cancel()
// Retrieve all keys and values to avoid too many calls to etcd in case of // Retrieve all keys and values to avoid too many calls to etcd in case of
// a large number of policies // a large number of policies
r, err := ies.client.Get(ctx, iamConfigPoliciesPrefix, etcd.WithPrefix()) r, err := ies.client.Get(cctx, iamConfigPoliciesPrefix, etcd.WithPrefix())
if err != nil { if err != nil {
return err return err
} }
// Parse all values to construct the policies data model. // Parse all values to construct the policies data model.
for _, kvs := range r.Kvs { for _, kvs := range r.Kvs {
if err := ctx.Err(); err != nil {
return err
}
if err = ies.getPolicyDocKV(ctx, kvs, m); err != nil && !errors.Is(err, errNoSuchPolicy) { if err = ies.getPolicyDocKV(ctx, kvs, m); err != nil && !errors.Is(err, errNoSuchPolicy) {
return err return err
} }
} }
return nil return ctx.Err()
} }
func (ies *IAMEtcdStore) getUserKV(ctx context.Context, userkv *mvccpb.KeyValue, userType IAMUserType, m map[string]UserIdentity, basePrefix string) error { func (ies *IAMEtcdStore) getUserKV(ctx context.Context, userkv *mvccpb.KeyValue, userType IAMUserType, m map[string]UserIdentity, basePrefix string) error {
var u UserIdentity var u UserIdentity
err := ies.decodeIAMConfig(&u, userkv.Value, string(userkv.Key)) err := ies.decodeIAMConfig(ctx, &u, userkv.Value, string(userkv.Key))
if err != nil { if err != nil {
if err == errConfigNotFound { if err == errConfigNotFound {
return errNoSuchUser return errNoSuchUser
@@ -392,9 +396,9 @@ func (ies *IAMEtcdStore) loadMappedPolicy(ctx context.Context, name string, user
return nil return nil
} }
func (ies *IAMEtcdStore) getMappedPolicy(kv *mvccpb.KeyValue, m *xsync.MapOf[string, MappedPolicy], basePrefix string) error { func (ies *IAMEtcdStore) getMappedPolicy(ctx context.Context, kv *mvccpb.KeyValue, m *xsync.MapOf[string, MappedPolicy], basePrefix string) error {
var p MappedPolicy var p MappedPolicy
err := ies.decodeIAMConfig(&p, kv.Value, string(kv.Key)) err := ies.decodeIAMConfig(ctx, &p, kv.Value, string(kv.Key))
if err != nil { if err != nil {
if err == errConfigNotFound { if err == errConfigNotFound {
return errNoSuchPolicy return errNoSuchPolicy
@@ -434,7 +438,7 @@ func (ies *IAMEtcdStore) loadMappedPolicies(ctx context.Context, userType IAMUse
// Parse all policies mapping to create the proper data model // Parse all policies mapping to create the proper data model
for _, kv := range r.Kvs { for _, kv := range r.Kvs {
if err = ies.getMappedPolicy(kv, m, basePrefix); err != nil && !errors.Is(err, errNoSuchPolicy) { if err = ies.getMappedPolicy(ctx, kv, m, basePrefix); err != nil && !errors.Is(err, errNoSuchPolicy) {
return err return err
} }
} }
+40 -5
View File
@@ -108,7 +108,10 @@ func (iamOS *IAMObjectStore) saveIAMConfig(ctx context.Context, item any, objPat
return nil return nil
} }
func decryptData(data []byte, objPath string) ([]byte, error) { func decryptData(ctx context.Context, data []byte, objPath string) ([]byte, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if utf8.Valid(data) { if utf8.Valid(data) {
return data, nil return data, nil
} }
@@ -118,13 +121,17 @@ func decryptData(data []byte, objPath string) ([]byte, error) {
return pdata, nil return pdata, nil
} }
if GlobalKMS != nil { if GlobalKMS != nil {
pdata, err = config.DecryptBytes(GlobalKMS, data, kms.Context{ pdata, err = config.DecryptBytes(ctx, GlobalKMS, data, kms.Context{
minioMetaBucket: path.Join(minioMetaBucket, objPath), minioMetaBucket: path.Join(minioMetaBucket, objPath),
}) })
if err == nil { if err == nil {
return pdata, nil return pdata, nil
} }
pdata, err = config.DecryptBytes(GlobalKMS, data, kms.Context{ // Cancellation must not trigger the legacy associated-data fallback.
if err := ctx.Err(); err != nil {
return nil, err
}
pdata, err = config.DecryptBytes(ctx, GlobalKMS, data, kms.Context{
minioMetaBucket: objPath, minioMetaBucket: objPath,
}) })
if err == nil { if err == nil {
@@ -134,12 +141,37 @@ func decryptData(data []byte, objPath string) ([]byte, error) {
return nil, err return nil, err
} }
// iamConfigNameTooLong identifies length errors for valid keys with an ASCII
// component that cannot be stored. Normalized LDAP DNs escape non-ASCII bytes.
// Byte length alone is insufficient for Unicode filenames on macOS/Windows,
// and a total-path limit can make an existing record unreadable after a move.
func iamConfigNameTooLong(objPath string, err error) bool {
var invalid ObjectNameInvalid
if !errors.Is(err, errFileNameTooLong) && !errors.As(err, &invalid) {
return false
}
if checkBucketAndObjectNames(context.Background(), minioMetaBucket, objPath) != nil {
return false
}
for component := range strings.SplitSeq(objPath, SlashSeparator) {
if len(component) > 255 && utf8.RuneCountInString(component) == len(component) {
return true
}
}
return false
}
func (iamOS *IAMObjectStore) loadIAMConfigBytesWithMetadata(ctx context.Context, objPath string) ([]byte, ObjectInfo, error) { func (iamOS *IAMObjectStore) loadIAMConfigBytesWithMetadata(ctx context.Context, objPath string) ([]byte, ObjectInfo, error) {
data, meta, err := readConfigWithMetadata(ctx, iamOS.objAPI, objPath, ObjectOptions{}) data, meta, err := readConfigWithMetadata(ctx, iamOS.objAPI, objPath, ObjectOptions{})
if err != nil { if err != nil {
if iamConfigNameTooLong(objPath, err) {
// A missing record, not a storage failure: lookups of the
// identity proceed, while saving its record still fails.
err = errConfigNotFound
}
return nil, meta, err return nil, meta, err
} }
data, err = decryptData(data, objPath) data, err = decryptData(ctx, data, objPath)
if err != nil { if err != nil {
return nil, meta, err return nil, meta, err
} }
@@ -251,12 +283,15 @@ func (iamOS *IAMObjectStore) loadPolicyDocs(ctx context.Context, m map[string]Po
return item.Err return item.Err
} }
if err := ctx.Err(); err != nil {
return err
}
policyName := path.Dir(item.Item) policyName := path.Dir(item.Item)
if err := iamOS.loadPolicyDoc(ctx, policyName, m); err != nil && !errors.Is(err, errNoSuchPolicy) { if err := iamOS.loadPolicyDoc(ctx, policyName, m); err != nil && !errors.Is(err, errNoSuchPolicy) {
return err return err
} }
} }
return nil return ctx.Err()
} }
func (iamOS *IAMObjectStore) loadSecretKey(ctx context.Context, user string, userType IAMUserType) (string, error) { func (iamOS *IAMObjectStore) loadSecretKey(ctx context.Context, user string, userType IAMUserType) (string, error) {
+181
View File
@@ -0,0 +1,181 @@
// Copyright (c) 2026 Feng Ruohang
// 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 (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync/atomic"
"testing"
"time"
"github.com/minio/minio/internal/config"
"github.com/minio/minio/internal/kms"
"github.com/puzpuzpuz/xsync/v3"
etcd "go.etcd.io/etcd/client/v3"
)
type iamPolicyBudgetGetObserver struct {
etcd.KV
elapsed time.Duration
budget time.Duration
block bool
}
func (k *iamPolicyBudgetGetObserver) Get(ctx context.Context, key string, opts ...etcd.OpOption) (*etcd.GetResponse, error) {
start := time.Now()
if d, ok := ctx.Deadline(); ok {
k.budget = time.Until(d)
}
defer func() { k.elapsed = time.Since(start) }()
if k.block {
<-ctx.Done()
return nil, ctx.Err()
}
return k.KV.Get(ctx, key, opts...)
}
func iamPolicyBudgetCall(ctx context.Context, store *IAMStoreSys, operation string) (int, error) {
switch operation {
case "policies":
m, err := store.ListPolicies(ctx, "")
return len(m), err
case "docs":
m, err := store.ListPolicyDocs(ctx, "")
return len(m), err
default:
m := xsync.NewMapOf[string, MappedPolicy]()
err := store.IAMStorageAPI.(*IAMEtcdStore).loadMappedPolicies(ctx, regUser, false, m)
return m.Size(), err
}
}
func prepareIAMPolicyBudgetFixture(t *testing.T, operation string) (*IAMStoreSys, *iamPolicyBudgetGetObserver, *atomic.Int32) {
t.Helper()
ctx, sys, _ := prepareIAMRevisionFixture(t, "etcd")
backend := sys.store.IAMStorageAPI.(*IAMEtcdStore)
builtin, err := kms.NewBuiltin("cancellation-key", bytes.Repeat([]byte{43}, 32))
mustIAM(t, err)
var doc any
path := getPolicyDocPath("budget-slow")
if operation == "mappings" {
path = getMappedPolicyPath("budget-slow", regUser, false)
doc = newMappedPolicy("readonly")
} else {
p, err := sys.store.GetPolicy("readonly")
mustIAM(t, err)
doc = newPolicyDoc(p)
}
plain, err := json.Marshal(doc)
mustIAM(t, err)
encrypted, err := config.EncryptBytes(builtin, plain, kms.Context{minioMetaBucket: minioMetaBucket + "/" + path})
mustIAM(t, err)
_, err = backend.client.Put(ctx, path, string(encrypted))
mustIAM(t, err)
calls := &atomic.Int32{}
priorKMS := GlobalKMS
GlobalKMS = connectCancellationKES(t, cancellationDecryptHandler(t, builtin, func(r *http.Request) {
calls.Add(1)
select {
case <-time.After(450 * time.Millisecond):
case <-r.Context().Done():
}
}))
t.Cleanup(func() { GlobalKMS = priorKMS })
priorTimeout := defaultContextTimeout
defaultContextTimeout = 200 * time.Millisecond
t.Cleanup(func() { defaultContextTimeout = priorTimeout })
observer := &iamPolicyBudgetGetObserver{KV: backend.client.KV}
backend.client.KV = observer
t.Cleanup(func() { backend.client.KV = observer.KV })
return sys.store, observer, calls
}
// A fast etcd Get followed by slow KES decryption must retain the caller's
// budget, even when decryption lasts longer than the etcd read timeout.
func TestIAMPolicyEtcdDecodeBudget(t *testing.T) {
for _, operation := range []string{"policies", "docs", "mappings"} {
t.Run(operation, func(t *testing.T) {
store, observer, calls := prepareIAMPolicyBudgetFixture(t, operation)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
n, err := iamPolicyBudgetCall(ctx, store, operation)
t.Logf("operation=%s total_ms=%.3f get_ms=%.3f get_budget_ms=%.3f decrypts=%d count=%d error=%v caller_error=%v", operation, float64(time.Since(start).Microseconds())/1000, float64(observer.elapsed.Microseconds())/1000, float64(observer.budget.Microseconds())/1000, calls.Load(), n, err, ctx.Err())
if err != nil {
t.Fatalf("valid caller budget was shortened after etcd Get completed: %v", err)
}
if observer.elapsed >= 200*time.Millisecond || observer.budget <= 0 || observer.budget > 210*time.Millisecond {
t.Fatalf("etcd read budget invalid: %+v", observer)
}
if n == 0 || calls.Load() != 1 || ctx.Err() != nil {
t.Fatal("successful result was incomplete")
}
})
}
}
// The correction must retain bounded etcd RPCs and caller cancellation. A
// failed list cannot publish its private map or advance the cache timestamp.
func TestIAMPolicyEtcdBudgetGuards(t *testing.T) {
for _, operation := range []string{"policies", "docs", "mappings"} {
for _, mode := range []string{"read_timeout", "caller_deadline", "caller_cancel"} {
t.Run(fmt.Sprintf("%s/%s", operation, mode), func(t *testing.T) {
store, observer, calls := prepareIAMPolicyBudgetFixture(t, operation)
cache := store.rlock()
before, count := cache.updatedAt, len(cache.iamPolicyDocsMap)
store.runlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
want := context.DeadlineExceeded
if mode == "read_timeout" {
observer.block = true
}
if mode == "caller_deadline" {
cancel()
ctx, cancel = context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
}
var timer *time.Timer
if mode == "caller_cancel" {
timer = time.AfterFunc(80*time.Millisecond, cancel)
defer timer.Stop()
want = context.Canceled
}
start := time.Now()
_, err := iamPolicyBudgetCall(ctx, store, operation)
t.Logf("operation=%s mode=%s elapsed_ms=%.3f decrypts=%d error=%v", operation, mode, float64(time.Since(start).Microseconds())/1000, calls.Load(), err)
if !errors.Is(err, want) {
t.Fatalf("expected %v, got %v", want, err)
}
if mode == "read_timeout" && (calls.Load() != 0 || ctx.Err() != nil) {
t.Fatal("read timeout did not remain local to the etcd RPC")
}
cache = store.rlock()
unchanged := cache.updatedAt.Equal(before) && len(cache.iamPolicyDocsMap) == count
store.runlock()
if !unchanged {
t.Fatal("failed list published a cache replacement")
}
})
}
}
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright (c) 2026 Feng Ruohang
// 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 (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"sync"
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/auth"
"github.com/pgsty/silo-pkg/v3/policy"
)
type iamPolicyRefreshContext struct{}
// Keep IAMStorageAPI as *IAMObjectStore so LoadIAMCache exercises the actual
// 32-worker object loader. Capture bytes and release the physical read lock
// before pausing; concurrent writes can commit against real object storage.
type iamPolicyRefreshReadGate struct {
ObjectLayer
policyPath, userPath string
listEntered, listRelease, refreshEntered, refreshRelease chan struct{}
listOnce, refreshOnce sync.Once
}
func (g *iamPolicyRefreshReadGate) GetObjectNInfo(ctx context.Context, bucket, object string, rs *HTTPRangeSpec, h http.Header, opts ObjectOptions) (*GetObjectReader, error) {
r, err := g.ObjectLayer.GetObjectNInfo(ctx, bucket, object, rs, h, opts)
if err != nil {
return nil, err
}
mode, _ := ctx.Value(iamPolicyRefreshContext{}).(string)
var release <-chan struct{}
if bucket == minioMetaBucket && mode == "list" && object == g.policyPath {
release = g.listRelease
}
if bucket == minioMetaBucket && mode == "refresh" && object == g.userPath {
release = g.refreshRelease
}
if release == nil {
return r, nil
}
data, err := io.ReadAll(r)
info := r.ObjInfo
r.Close()
if err != nil {
return nil, err
}
if mode == "list" {
g.listOnce.Do(func() { close(g.listEntered) })
} else {
g.refreshOnce.Do(func() { close(g.refreshEntered) })
}
select {
case <-release:
return NewGetObjectReaderFromReader(bytes.NewReader(data), info, opts)
case <-ctx.Done():
return nil, ctx.Err()
}
}
func TestIAMPolicyReloadActualObjectRefresh(t *testing.T) {
for _, docs := range []bool{false, true} {
for _, canceled := range []bool{false, true} {
t.Run(fmt.Sprintf("docs=%t/cancel=%t", docs, canceled), func(t *testing.T) {
ctx, sys, obj := prepareIAMRevisionFixture(t)
store := sys.store
rw, err := store.GetPolicy("readwrite")
mustIAM(t, err)
ro, err := store.GetPolicy("readonly")
mustIAM(t, err)
for _, name := range []string{"review-edit", "review-delete"} {
_, err = store.SetPolicy(ctx, name, rw)
mustIAM(t, err)
}
const parent = "review-parent"
_, err = sys.CreateUser(ctx, parent, madmin.AddOrUpdateUserReq{SecretKey: "synthetic-review-password", Status: madmin.AccountEnabled})
mustIAM(t, err)
secret, err := getTokenSigningKey()
mustIAM(t, err)
issue := func(typ string) auth.Credentials {
c, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: parent, tokenRevokeTypeClaim: typ}, secret)
mustIAM(t, err)
c.ParentUser = parent
_, err = store.SetTempUser(ctx, c.AccessKey, c, "")
mustIAM(t, err)
return c
}
revoked, retained := issue("selected"), issue("other")
gate := &iamPolicyRefreshReadGate{ObjectLayer: obj, policyPath: getPolicyDocPath("review-edit"), userPath: getUserIdentityPath(parent, regUser), listEntered: make(chan struct{}), listRelease: make(chan struct{}), refreshEntered: make(chan struct{}), refreshRelease: make(chan struct{})}
store.IAMStorageAPI.(*IAMObjectStore).objAPI = gate
freeList := sync.OnceFunc(func() { close(gate.listRelease) })
freeRefresh := sync.OnceFunc(func() { close(gate.refreshRelease) })
var wg sync.WaitGroup
t.Cleanup(func() { freeList(); freeRefresh(); wg.Wait() })
start := func(fn func() error) <-chan error {
done := make(chan error, 1)
wg.Go(func() { done <- fn() })
return done
}
refreshDone := start(func() error {
return store.LoadIAMCache(context.WithValue(ctx, iamPolicyRefreshContext{}, "refresh"), false)
})
awaitIAMPolicyReload(t, gate.refreshEntered)
listCtx, cancel := context.WithCancel(context.WithValue(ctx, iamPolicyRefreshContext{}, "list"))
defer cancel()
listDone := start(func() error { return iamPolicyReloadCall(listCtx, store, docs) })
awaitIAMPolicyReload(t, gate.listEntered)
read := start(func() error {
if _, ok := store.GetUser(parent); !ok {
return errors.New("cached user lost")
}
_, err := store.GetPolicy("review-edit")
return err
})
select {
case err := <-read:
mustIAM(t, err)
case <-time.After(time.Second):
t.Fatal("actual object reload blocked cached reads")
}
writes := []<-chan error{
start(func() error { _, err := store.SetPolicy(ctx, "review-edit", ro); return err }),
start(func() error { return store.DeletePolicy(ctx, "review-delete", false) }),
start(func() error { _, err := store.RevokeTokens(ctx, parent, "selected"); return err }),
}
if canceled {
cancel()
} else {
freeList()
}
err = <-listDone
if canceled {
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancel error: %v", err)
}
} else {
mustIAM(t, err)
}
for _, done := range writes {
mustIAM(t, <-done)
}
freeRefresh()
mustIAM(t, <-refreshDone)
p, err := store.GetPolicy("review-edit")
mustIAM(t, err)
if p.IsAllowed(policy.Args{Action: policy.PutObjectAction, BucketName: "bucket", ObjectName: "object"}) || !p.IsAllowed(policy.Args{Action: policy.GetObjectAction, BucketName: "bucket", ObjectName: "object"}) {
t.Fatal("actual object refresh restored stale policy")
}
if _, err := store.GetPolicy("review-delete"); !errors.Is(err, errNoSuchPolicy) {
t.Fatal("deleted policy restored")
}
if _, ok := store.GetUser(revoked.AccessKey); ok {
t.Fatal("revoked STS token restored")
}
if _, ok := store.GetUser(retained.AccessKey); !ok {
t.Fatal("unrelated token lost")
}
})
}
}
}
+373
View File
@@ -0,0 +1,373 @@
// Copyright (c) 2026 Feng Ruohang
// 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 (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/auth"
"github.com/pgsty/silo-pkg/v3/policy"
)
type iamPolicyReloadStore struct {
IAMStorageAPI
policies func(context.Context, map[string]PolicyDoc) error
users func(context.Context, IAMUserType, map[string]UserIdentity) error
}
func (s *iamPolicyReloadStore) loadPolicyDocs(ctx context.Context, m map[string]PolicyDoc) error {
return s.policies(ctx, m)
}
func (s *iamPolicyReloadStore) loadUsers(ctx context.Context, typ IAMUserType, m map[string]UserIdentity) error {
if s.users != nil {
return s.users(ctx, typ, m)
}
return s.IAMStorageAPI.loadUsers(ctx, typ, m)
}
func iamPolicyReloadCall(ctx context.Context, store *IAMStoreSys, docs bool) error {
if docs {
_, err := store.ListPolicyDocs(ctx, "")
return err
}
_, err := store.ListPolicies(ctx, "")
return err
}
func awaitIAMPolicyReload(t *testing.T, ch <-chan struct{}) {
t.Helper()
select {
case <-ch:
case <-time.After(5 * time.Second):
t.Fatal("IAM operation did not reach the controlled barrier")
}
}
// Both list entry points must allow cached readers while preserving the writer
// ordering used by policy edits, peer notifications and credential revocation.
// A refresh that captured the old state must not publish after those writes.
func TestIAMPolicyReloadConcurrentChanges(t *testing.T) {
for _, backend := range []string{"object", "etcd"} {
for _, docs := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/docs=%t", backend, docs), func(t *testing.T) {
ctx, sys, obj := prepareIAMRevisionFixture(t, backend)
store := sys.store
original := store.IAMStorageAPI
rw, err := store.GetPolicy("readwrite")
mustIAM(t, err)
ro, err := store.GetPolicy("readonly")
mustIAM(t, err)
for _, name := range []string{"reload-edit", "reload-delete", "reload-peer"} {
_, err := store.SetPolicy(ctx, name, rw)
mustIAM(t, err)
}
const parent = "reload-parent"
_, err = sys.CreateUser(ctx, parent, madmin.AddOrUpdateUserReq{SecretKey: "synthetic-reload-password", Status: madmin.AccountEnabled})
mustIAM(t, err)
secret, err := getTokenSigningKey()
mustIAM(t, err)
issue := func(typ string) auth.Credentials {
c, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: parent, tokenRevokeTypeClaim: typ}, secret)
mustIAM(t, err)
c.ParentUser = parent
_, err = store.SetTempUser(ctx, c.AccessKey, c, "")
mustIAM(t, err)
return c
}
revoked, retained := issue("selected"), issue("other")
peer := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(obj, MinIOUsersSysType)}
if backend == "etcd" {
peer.IAMStorageAPI = newIAMEtcdStore(original.(*IAMEtcdStore).client, MinIOUsersSysType)
}
mustIAM(t, peer.LoadIAMCache(ctx, true))
type refreshKey struct{}
loading, releaseLoad := make(chan struct{}), make(chan struct{})
refreshing, releaseRefresh := make(chan struct{}), make(chan struct{})
unblockLoad := sync.OnceFunc(func() { close(releaseLoad) })
unblockRefresh := sync.OnceFunc(func() { close(releaseRefresh) })
var wg sync.WaitGroup
t.Cleanup(func() { unblockLoad(); unblockRefresh(); wg.Wait() })
store.IAMStorageAPI = &iamPolicyReloadStore{
IAMStorageAPI: original,
policies: func(ctx context.Context, m map[string]PolicyDoc) error {
if err := original.loadPolicyDocs(ctx, m); err != nil {
return err
}
if ctx.Value(refreshKey{}) == nil {
close(loading)
select {
case <-releaseLoad:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
},
users: func(ctx context.Context, typ IAMUserType, m map[string]UserIdentity) error {
if err := original.loadUsers(ctx, typ, m); err != nil {
return err
}
if typ == stsUser && ctx.Value(refreshKey{}) != nil {
close(refreshing)
<-releaseRefresh
}
return nil
},
}
start := func(fn func() error) <-chan error {
out := make(chan error, 1)
wg.Go(func() { out <- fn() })
return out
}
refreshDone := start(func() error { return store.LoadIAMCache(context.WithValue(ctx, refreshKey{}, true), false) })
awaitIAMPolicyReload(t, refreshing)
listDone := start(func() error { return iamPolicyReloadCall(ctx, store, docs) })
awaitIAMPolicyReload(t, loading)
reader := start(func() error {
if _, ok := store.GetUser(parent); !ok {
return errors.New("committed user disappeared during policy loading")
}
_, err := store.GetPolicy("reload-edit")
return err
})
select {
case err := <-reader:
mustIAM(t, err)
case <-time.After(time.Second):
t.Fatal("policy loading blocked cached authentication")
}
// The peer commits independently; its notification waits alongside
// local changes while the older list snapshot remains in flight.
_, err = peer.SetPolicy(ctx, "reload-peer", ro)
mustIAM(t, err)
writersStarted := make(chan struct{}, 4)
startWrite := func(fn func() error) <-chan error {
return start(func() error { writersStarted <- struct{}{}; return fn() })
}
writes := []<-chan error{
startWrite(func() error { _, err := store.SetPolicy(ctx, "reload-edit", ro); return err }),
startWrite(func() error { return store.DeletePolicy(ctx, "reload-delete", false) }),
startWrite(func() error { _, err := store.RevokeTokens(ctx, parent, "selected"); return err }),
startWrite(func() error { return store.PolicyNotificationHandler(ctx, "reload-peer") }),
}
for range writes {
<-writersStarted
}
select {
case err := <-writes[0]:
t.Fatalf("policy write overtook the pending reload: %v", err)
case <-time.After(25 * time.Millisecond):
}
for _, done := range writes[1:] {
select {
case err := <-done:
t.Fatalf("IAM mutation overtook the pending reload: %v", err)
default:
}
}
unblockLoad()
mustIAM(t, <-listDone)
for _, done := range writes {
mustIAM(t, <-done)
}
unblockRefresh()
mustIAM(t, <-refreshDone)
for _, name := range []string{"reload-edit", "reload-peer"} {
p, err := store.GetPolicy(name)
mustIAM(t, err)
if p.IsAllowed(policy.Args{Action: policy.PutObjectAction, BucketName: "bucket", ObjectName: "object"}) {
t.Fatalf("stale list/refresh restored the old grant: %s", name)
}
if !p.IsAllowed(policy.Args{Action: policy.GetObjectAction, BucketName: "bucket", ObjectName: "object"}) {
t.Fatalf("reload lost the committed read grant: %s", name)
}
}
if _, err := store.GetPolicy("reload-delete"); !errors.Is(err, errNoSuchPolicy) {
t.Fatalf("deleted policy returned: %v", err)
}
if _, ok := store.GetUser(revoked.AccessKey); ok {
t.Fatal("stale list/refresh restored a revoked token")
}
for _, key := range []string{retained.AccessKey, parent} {
if _, ok := store.GetUser(key); !ok {
t.Fatal("reload removed an unrelated credential")
}
}
})
}
}
}
func TestIAMPolicyReloadFailurePreservesCache(t *testing.T) {
for _, docs := range []bool{false, true} {
for _, failure := range []string{"storage", "cancel_partial", "caller_deadline_partial"} {
t.Run(fmt.Sprintf("docs=%t/%s", docs, failure), func(t *testing.T) {
ctx, sys, _ := prepareIAMRevisionFixture(t)
var cancel context.CancelFunc
if failure == "caller_deadline_partial" {
ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond)
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel()
store := sys.store
cache := store.rlock()
updated := cache.updatedAt
store.runlock()
injected := errors.New("policy read failed")
entered := make(chan struct{})
original := store.IAMStorageAPI
store.IAMStorageAPI = &iamPolicyReloadStore{IAMStorageAPI: original, policies: func(ctx context.Context, m map[string]PolicyDoc) error {
m["partial-result"] = PolicyDoc{}
close(entered)
if failure == "storage" {
return injected
}
<-ctx.Done()
// A canceled enumeration can close without yielding an error.
return nil
}}
done := make(chan error, 1)
go func() { done <- iamPolicyReloadCall(ctx, store, docs) }()
awaitIAMPolicyReload(t, entered)
want := injected
switch failure {
case "cancel_partial":
cancel()
want = context.Canceled
case "caller_deadline_partial":
want = context.DeadlineExceeded
}
select {
case err := <-done:
if !errors.Is(err, want) {
t.Fatalf("got %v, want %v", err, want)
}
case <-time.After(time.Second):
cancel()
<-done
t.Fatal("reload did not honor caller cancellation")
}
cache = store.rlock()
_, partial := cache.iamPolicyDocsMap["partial-result"]
unchanged := cache.updatedAt.Equal(updated)
store.runlock()
if partial || !unchanged {
t.Fatal("failed/canceled reload published a partial policy map")
}
store.IAMStorageAPI = original
// Cancellation must release both locks so the next operation works.
mustIAM(t, iamPolicyReloadCall(context.Background(), store, docs))
if _, err := store.GetPolicy("readwrite"); err != nil {
t.Fatal("reload lost default policies")
}
})
}
}
}
type iamPolicyRelockObserver struct {
IAMStorageAPI
calls atomic.Int32
reacquiring chan struct{}
}
func (s *iamPolicyRelockObserver) lock() *iamCache {
if s.calls.Add(1) == 2 {
close(s.reacquiring)
}
return s.IAMStorageAPI.lock()
}
func TestIAMPolicyReloadCallerBudgetPreserved(t *testing.T) {
for _, docs := range []bool{false, true} {
t.Run(fmt.Sprintf("docs=%t", docs), func(t *testing.T) {
_, sys, _ := prepareIAMRevisionFixture(t)
original := sys.store.IAMStorageAPI
prior := defaultContextTimeout
defaultContextTimeout = 50 * time.Millisecond
t.Cleanup(func() { defaultContextTimeout = prior })
sys.store.IAMStorageAPI = &iamPolicyReloadStore{IAMStorageAPI: original, policies: func(ctx context.Context, m map[string]PolicyDoc) error {
select {
case <-time.After(120 * time.Millisecond):
return original.loadPolicyDocs(ctx, m)
case <-ctx.Done():
return ctx.Err()
}
}}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := iamPolicyReloadCall(ctx, sys.store, docs)
if err != nil {
t.Fatalf("reload cut off a still-valid caller budget: err=%v callerErr=%v", err, ctx.Err())
}
})
}
}
func TestIAMPolicyReloadCancelWhileReacquiringCache(t *testing.T) {
for _, docs := range []bool{false, true} {
t.Run(fmt.Sprintf("docs=%t", docs), func(t *testing.T) {
_, sys, _ := prepareIAMRevisionFixture(t)
store := sys.store
original := store.IAMStorageAPI
observer := &iamPolicyRelockObserver{IAMStorageAPI: original, reacquiring: make(chan struct{})}
entered, release := make(chan struct{}), make(chan struct{})
store.IAMStorageAPI = &iamPolicyReloadStore{IAMStorageAPI: observer, policies: func(ctx context.Context, m map[string]PolicyDoc) error {
m["partial-reload"] = PolicyDoc{}
close(entered)
<-release
return nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- iamPolicyReloadCall(ctx, store, docs) }()
awaitIAMPolicyReload(t, entered)
cache := store.rlock()
before := cache.updatedAt
count := len(cache.iamPolicyDocsMap)
close(release)
awaitIAMPolicyReload(t, observer.reacquiring)
// This reader forces the reload to wait before its commit step.
// The request is canceled after I/O completed, before publishing.
cancel()
store.runlock()
err := <-done
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancel before publish lost: %v", err)
}
cache = store.rlock()
_, partial := cache.iamPolicyDocsMap["partial-reload"]
unchanged := cache.updatedAt.Equal(before) && len(cache.iamPolicyDocsMap) == count
store.runlock()
if partial || !unchanged {
t.Fatal("canceled complete load still published")
}
})
}
}
+305
View File
@@ -0,0 +1,305 @@
// Copyright (c) 2026 PGSTY
// 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 (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"sync"
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/grid"
"github.com/pgsty/silo-pkg/v3/policy"
)
func newIAMPolicyResetSibling(ctx context.Context, t *testing.T, source *IAMSys, obj ObjectLayer) *IAMSys {
t.Helper()
store := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(obj, MinIOUsersSysType)}
if original, ok := source.store.IAMStorageAPI.(*IAMEtcdStore); ok {
store.IAMStorageAPI = newIAMEtcdStore(original.client, MinIOUsersSysType)
}
mustIAM(t, store.LoadIAMCache(ctx, true))
return &IAMSys{store: store, usersSysType: MinIOUsersSysType}
}
func assertIAMPolicyResetInfo(t *testing.T, sys *IAMSys, name string, want *madmin.PolicyInfo) {
t.Helper()
got, err := sys.InfoPolicy(name)
mustIAM(t, err)
var gotPolicy, wantPolicy policy.Policy
mustIAM(t, json.Unmarshal(got.Policy, &gotPolicy))
mustIAM(t, json.Unmarshal(want.Policy, &wantPolicy))
// Actions and resources are sets, whose JSON array order is not stable.
// Compare their exact contents while retaining all native metadata checks.
gotMetadata, wantMetadata := *got, *want
gotMetadata.Policy, wantMetadata.Policy = nil, nil
if !gotPolicy.Equals(wantPolicy) || !reflect.DeepEqual(gotMetadata, wantMetadata) {
t.Fatalf("policy definition or native metadata changed: got %+v, want %+v", got, want)
}
}
// The first InfoPolicy call after Reset must work without any inventory read,
// refresh, or retry. The durable tombstone belongs to the removed override;
// it must still reject stale overrides without suppressing the native policy.
func TestIAMBuiltinPolicyResetRestoresNativePolicy(t *testing.T) {
for _, backend := range []string{"object", "etcd"} {
t.Run(backend, func(t *testing.T) {
ctx, sys, obj := prepareIAMRevisionFixture(t, backend)
for _, builtin := range policy.DefaultPolicies {
t.Run(builtin.Name, func(t *testing.T) {
name := builtin.Name
native, err := sys.InfoPolicy(name)
mustIAM(t, err)
override := builtin.Definition
override.ID = "reset-override"
written, err := sys.SetPolicy(ctx, name, override)
mustIAM(t, err)
mustIAM(t, sys.DeletePolicy(ctx, name, true))
assertIAMPolicyResetInfo(t, sys, name, native)
path := getPolicyDocPath(name)
deleted, err := loadIAMRevision(ctx, sys.store, path)
mustIAM(t, err)
if !deleted.Deleted || !deleted.timestamp().After(written) {
t.Fatalf("Reset lost the override deletion revision: %+v", deleted)
}
for _, at := range []time.Time{written, deleted.timestamp()} {
_, err = sys.SetPolicy(withIAMReplicationTime(ctx, at), name, override)
if !errors.Is(err, errIAMStaleUpdate) {
t.Fatalf("Reset accepted a stale override at %s: %v", at, err)
}
assertIAMPolicyResetInfo(t, sys, name, native)
}
mustIAM(t, sys.store.LoadIAMCache(ctx, false))
assertIAMPolicyResetInfo(t, sys, name, native)
fresh := newIAMPolicyResetSibling(ctx, t, sys, obj)
assertIAMPolicyResetInfo(t, fresh, name, native)
for _, docs := range []bool{false, true} {
mustIAM(t, iamPolicyReloadCall(ctx, fresh.store, docs))
assertIAMPolicyResetInfo(t, fresh, name, native)
}
after, err := loadIAMRevision(ctx, fresh.store, path)
mustIAM(t, err)
if !reflect.DeepEqual(after, deleted) || !fresh.store.revisionIndex().get(path).Deleted {
t.Fatal("default restoration changed the durable deletion or revision index")
}
// A deliberate new edit remains possible after Reset.
_, err = sys.SetPolicy(ctx, name, override)
mustIAM(t, err)
current, err := sys.store.GetPolicyDoc(name)
mustIAM(t, err)
if current.Policy.ID != override.ID || current.UpdateDate.IsZero() {
t.Fatal("Reset prevented a subsequent explicit override")
}
mustIAM(t, sys.DeletePolicy(ctx, name, true))
})
}
})
}
}
// Peer and watch notifications carry a name, not a version. Reload committed
// storage so delayed deletion events cannot erase a recreated policy. Built-in
// defaults retain mappings; a genuinely deleted custom policy does not.
func TestIAMPolicyResetNotificationLoadsCommittedState(t *testing.T) {
for _, backend := range []string{"object", "etcd"} {
for _, delivery := range []string{"peer", "watch"} {
for _, recreate := range []bool{false, true} {
for _, name := range []string{"writeonly", "reset-custom"} {
t.Run(fmt.Sprintf("%s/%s/recreate=%t/%s", backend, delivery, recreate, name), func(t *testing.T) {
ctx, source, obj := prepareIAMRevisionFixture(t, backend)
globalObjLayerMutex.Lock()
globalObjectAPI = obj
globalObjLayerMutex.Unlock()
builtin := name == "writeonly"
var want *madmin.PolicyInfo
if builtin {
var err error
want, err = source.InfoPolicy(name)
mustIAM(t, err)
}
p, err := source.store.GetPolicy("readwrite")
mustIAM(t, err)
p.ID = "reset-original-override"
_, err = source.SetPolicy(ctx, name, p)
mustIAM(t, err)
_, err = source.CreateUser(ctx, "reset-user", madmin.AddOrUpdateUserReq{SecretKey: "synthetic-reset-password", Status: madmin.AccountEnabled})
mustIAM(t, err)
_, err = source.AddUsersToGroup(ctx, "reset-group", []string{"reset-user"})
mustIAM(t, err)
for _, group := range []bool{false, true} {
entity := "reset-user"
if group {
entity = "reset-group"
}
_, err = source.PolicyDBSet(ctx, entity, "readonly,"+name, regUser, group)
mustIAM(t, err)
}
if err := source.DeletePolicy(ctx, name, true); !errors.Is(err, errPolicyInUse) {
t.Fatalf("Reset bypassed policy-in-use protection: %v", err)
}
sibling := newIAMPolicyResetSibling(ctx, t, source, obj)
mappings := make(map[bool]MappedPolicy)
mappings[false], _ = sibling.store.GetMappedPolicy("reset-user", false)
mappings[true], _ = sibling.store.GetMappedPolicy("reset-group", true)
// A remote site may delete before this node has received the
// corresponding mapping changes. Local deletion stays guarded.
mustIAM(t, source.DeletePolicy(withIAMReplicationTime(ctx, UTCNow()), name, true))
if recreate {
p.ID = "reset-recreated-override"
_, err = source.SetPolicy(ctx, name, p)
mustIAM(t, err)
want, err = source.InfoPolicy(name)
mustIAM(t, err)
}
before, err := loadIAMRevision(ctx, source.store, getPolicyDocPath(name))
mustIAM(t, err)
globalIAMSys = sibling
defer func() { globalIAMSys = source }()
for range 2 {
if delivery == "peer" {
_, remoteErr := (&peerRESTServer{}).DeletePolicyHandler(grid.NewMSSWith(map[string]string{peerRESTPolicy: name}))
if remoteErr != nil {
t.Fatal(remoteErr)
}
} else {
mustIAM(t, sibling.loadWatchedEvent(ctx, iamWatchEvent{keyPath: getPolicyDocPath(name)}))
}
if builtin || recreate {
assertIAMPolicyResetInfo(t, sibling, name, want)
} else if _, err := sibling.InfoPolicy(name); !errors.Is(err, errNoSuchPolicy) {
t.Fatalf("deleted custom policy returned: %v", err)
}
for group, original := range mappings {
entity := "reset-user"
if group {
entity = "reset-group"
}
got, _ := sibling.store.GetMappedPolicy(entity, group)
if builtin || recreate {
if !reflect.DeepEqual(got, original) {
t.Fatalf("notification changed surviving mappings: %+v", got)
}
} else if got.Policies != "readonly" {
t.Fatalf("custom deletion did not retain only the unrelated mapping: %+v", got)
}
}
}
after, err := loadIAMRevision(ctx, source.store, getPolicyDocPath(name))
mustIAM(t, err)
if !reflect.DeepEqual(after, before) {
t.Fatal("notification changed durable policy storage")
}
})
}
}
}
}
}
func TestIAMPolicyResetNotificationSurvivesOlderRefresh(t *testing.T) {
ctx, source, obj := prepareIAMRevisionFixture(t)
p, err := source.store.GetPolicy("readwrite")
mustIAM(t, err)
p.ID = "old-override"
_, err = source.SetPolicy(ctx, "writeonly", p)
mustIAM(t, err)
sibling := newIAMPolicyResetSibling(ctx, t, source, obj)
original := sibling.store.IAMStorageAPI
loading, release := make(chan struct{}), make(chan struct{})
unblock := sync.OnceFunc(func() { close(release) })
var wg sync.WaitGroup
t.Cleanup(func() { unblock(); wg.Wait() })
sibling.store.IAMStorageAPI = &iamPolicyReloadStore{
IAMStorageAPI: original,
policies: original.loadPolicyDocs,
users: func(ctx context.Context, typ IAMUserType, m map[string]UserIdentity) error {
if typ == regUser {
close(loading)
<-release
}
return original.loadUsers(ctx, typ, m)
},
}
done := make(chan error, 1)
wg.Go(func() { done <- sibling.store.LoadIAMCache(ctx, false) })
awaitIAMPolicyReload(t, loading)
mustIAM(t, source.DeletePolicy(ctx, "writeonly", true))
p.ID = "newer-override"
_, err = source.SetPolicy(ctx, "writeonly", p)
mustIAM(t, err)
want, err := source.InfoPolicy("writeonly")
mustIAM(t, err)
// The delayed deletion notification must observe the newer edit, and
// prevent a refresh that captured the old override from replacing it.
mustIAM(t, sibling.DeletePolicy(ctx, "writeonly", false))
unblock()
mustIAM(t, <-done)
assertIAMPolicyResetInfo(t, sibling, "writeonly", want)
}
type iamPolicyResetFailureStore struct {
IAMStorageAPI
err error
}
func (s *iamPolicyResetFailureStore) saveIAMConfig(context.Context, any, string, ...options) error {
return s.err
}
func (s *iamPolicyResetFailureStore) loadPolicyDoc(context.Context, string, map[string]PolicyDoc) error {
return s.err
}
func TestIAMBuiltinPolicyResetStorageFailurePreservesOverride(t *testing.T) {
for _, backend := range []string{"object", "etcd"} {
t.Run(backend, func(t *testing.T) {
ctx, sys, _ := prepareIAMRevisionFixture(t, backend)
p, err := sys.store.GetPolicy("readonly")
mustIAM(t, err)
_, err = sys.SetPolicy(ctx, "writeonly", p)
mustIAM(t, err)
before, err := sys.InfoPolicy("writeonly")
mustIAM(t, err)
stored, err := loadIAMRevision(ctx, sys.store, getPolicyDocPath("writeonly"))
mustIAM(t, err)
original := sys.store.IAMStorageAPI
injected := errors.New("injected policy storage failure")
sys.store.IAMStorageAPI = &iamPolicyResetFailureStore{IAMStorageAPI: original, err: injected}
for _, notify := range []bool{true, false} {
if err := sys.DeletePolicy(ctx, "writeonly", notify); !errors.Is(err, injected) {
t.Fatalf("storage failure became a successful Reset, notify=%t: %v", notify, err)
}
assertIAMPolicyResetInfo(t, sys, "writeonly", before)
}
if err := sys.loadWatchedEvent(ctx, iamWatchEvent{keyPath: getPolicyDocPath("writeonly")}); !errors.Is(err, injected) {
t.Fatalf("watch storage failure became a default policy: %v", err)
}
assertIAMPolicyResetInfo(t, sys, "writeonly", before)
after, err := loadIAMRevision(ctx, original, getPolicyDocPath("writeonly"))
mustIAM(t, err)
if !reflect.DeepEqual(after, stored) {
t.Fatal("failed Reset changed the durable override")
}
})
}
}
+18
View File
@@ -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)
+32 -8
View File
@@ -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,11 +401,18 @@ 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) {
opts = []options{{ttl: max(1, int64(time.Until(v.ExpiresAt).Seconds())+1)}} 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)}}
}
} }
} else { } else {
if v.Credentials.SessionToken != "" && v.Credentials.Claims == nil { if v.Credentials.SessionToken != "" && v.Credentials.Claims == nil {
@@ -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 {
+210
View File
@@ -7,14 +7,19 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath"
"runtime"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"github.com/minio/madmin-go/v3" "github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/grid" "github.com/minio/minio/internal/grid"
"github.com/pgsty/silo-pkg/v3/ldap"
xnet "github.com/pgsty/silo-pkg/v3/net" xnet "github.com/pgsty/silo-pkg/v3/net"
) )
@@ -93,6 +98,211 @@ func TestIAMRevocationTombstoneReplayIsIdempotent(t *testing.T) {
} }
} }
// An LDAP parent named by a normalized DN longer than NAME_MAX has no record
// on object storage. Its lookups must find nothing instead of failing login.
func TestLoadIAMRevisionNameTooLong(t *testing.T) {
for _, backend := range []string{"object", "etcd"} {
t.Run(backend, func(t *testing.T) {
ctx, sys, _ := prepareIAMRevisionFixture(t, backend)
store := sys.store.IAMStorageAPI
// 160 bytes of UTF-8 become 384 characters.
parent, err := ldap.NormalizeDN("uid=Сотрудник,ou=Отдел разработки,ou=Дирекция информационных технологий,ou=people,ou=swengg,dc=min,dc=io")
mustIAM(t, err)
if len(parent) <= 255 {
t.Fatalf("normalized DN has only %d characters", len(parent))
}
parentPath := getUserIdentityPath(parent, regUser)
for _, path := range []string{parentPath, getUserIdentityPath("missing-parent", regUser)} {
r, err := loadIAMRevision(ctx, store, path)
if err != nil || r.Deleted || !r.timestamp().IsZero() || !r.RevokedBefore.IsZero() {
t.Fatalf("absent revision %.40s...: %+v, %v", path, r, err)
}
}
if backend == "object" {
var invalid ObjectNameInvalid
if _, err := loadIAMRevision(ctx, store, iamConfigUsersPrefix+"a//b/"+iamIdentityFile); !errors.As(err, &invalid) {
t.Fatalf("invalid name not caused by length was masked: %v", err)
}
}
claims := map[string]any{}
mustIAM(t, setIAMParentRevocationClaim(ctx, store, parent, claims))
if _, ok := claims[iamParentRevocationClaim]; ok {
t.Fatalf("unexpected revocation claim: %v", claims)
}
if _, err := sys.PolicyDBGet(parent); err != nil {
t.Fatalf("policy lookup failed: %v", err)
}
// The call that failed in AssumeRoleWithLDAPIdentity.
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
_, err = sys.SetTempUser(ctx, cred.AccessKey, cred, "")
mustIAM(t, err)
if _, ok := sys.store.GetUser(cred.AccessKey); !ok {
t.Fatal("temporary credential not cached")
}
// Object storage still cannot record a revocation for this parent;
// the failure must be reported. Etcd has no such limit.
err = saveIAMRevision(ctx, store, parentPath, &iamUserRevocation{})
if backend == "object" {
if !iamConfigNameTooLong(parentPath, err) {
t.Fatalf("expected name-too-long error, got %v", err)
}
return
}
mustIAM(t, err)
if err := checkIAMParentRevision(ctx, store, cred); !errors.Is(err, errIAMStaleUpdate) {
t.Fatalf("revoked parent accepted: %v", err)
}
})
}
}
// checkPathLength does not count backslashes outside Windows, so this escaped
// DN passes it. Once config/iam/users exists, as after creating any built-in
// user, the filesystem rejects the element with ENAMETOOLONG instead.
func TestLoadIAMRevisionFilesystemNameTooLong(t *testing.T) {
ctx, sys, obj := prepareIAMRevisionFixture(t)
parent, err := ldap.NormalizeDN("cn=Сидоров П.П.,ou=Отдел ИТ-поддержки,ou=Пользователи,ou=people,ou=swengg,dc=min,dc=io")
mustIAM(t, err)
parentPath := getUserIdentityPath(parent, regUser)
if len(parent) <= 255 || checkPathLength(parentPath) != nil {
t.Fatalf("normalized DN must exceed NAME_MAX yet pass checkPathLength: %d characters, %v", len(parent), checkPathLength(parentPath))
}
_, err = sys.CreateUser(ctx, "existing-user", madmin.AddOrUpdateUserReq{SecretKey: "synthetic-user-password", Status: madmin.AccountEnabled})
mustIAM(t, err)
mustIAM(t, saveIAMRevision(ctx, sys.store.IAMStorageAPI, getMappedPolicyPath("existing-parent", stsUser, false), &MappedPolicy{Version: 1, Policies: "readwrite"}))
store := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(obj, LDAPUsersSysType)}
mustIAM(t, store.LoadIAMCache(ctx, false))
r, err := loadIAMRevision(ctx, store.IAMStorageAPI, parentPath)
if err != nil || r.Deleted || !r.timestamp().IsZero() {
t.Fatalf("absent revision: %+v, %v", r, err)
}
// LDAP mode reads policydb/sts-users/<DN>.json.
if _, err := store.PolicyDBGet(parent); err != nil {
t.Fatalf("policy lookup failed: %v", 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
if _, err := store.SetTempUser(ctx, cred.AccessKey, cred, ""); err != nil {
t.Fatalf("temporary credential rejected: %v", err)
}
err = saveIAMRevision(ctx, store.IAMStorageAPI, parentPath, &iamUserRevocation{})
if !iamConfigNameTooLong(parentPath, err) {
t.Fatalf("expected name-too-long error, got %v", err)
}
}
func TestIAMConfigNameTooLong(t *testing.T) {
long := iamConfigUsersPrefix + strings.Repeat("x", 256) + "/" + iamIdentityFile
short := iamConfigUsersPrefix + strings.Repeat("x", 200) + "/" + iamIdentityFile
invalid := iamConfigUsersPrefix + "a//b/" + iamIdentityFile
escaped := iamConfigUsersPrefix + strings.Repeat("\\d0", 86) + "/" + iamIdentityFile
limit := iamConfigUsersPrefix + strings.Repeat("x", 255) + "/" + iamIdentityFile
unicode := iamConfigUsersPrefix + strings.Repeat("界", 100) + "/" + iamIdentityFile
deep := iamConfigUsersPrefix + strings.Repeat("nested/", 160) + iamIdentityFile
for _, tc := range []struct {
name string
path string
err error
want bool
}{
{"NAME_MAX", long, ObjectNameInvalid{Bucket: minioMetaBucket, Object: long}, true},
{"escaped DN", escaped, ObjectNameInvalid{Bucket: minioMetaBucket, Object: escaped}, runtime.GOOS != "windows"},
{"short component", short, ObjectNameInvalid{Bucket: minioMetaBucket, Object: short}, false},
{"exact component limit", limit, ObjectNameInvalid{Bucket: minioMetaBucket, Object: limit}, false},
{"unicode byte length", unicode, ObjectNameInvalid{Bucket: minioMetaBucket, Object: unicode}, false},
{"total path length", deep, ObjectNameInvalid{Bucket: minioMetaBucket, Object: deep}, false},
{"short drive path", short, errFileNameTooLong, false},
{"policy suffix at limit", getMappedPolicyPath(strings.Repeat("x", 250), stsUser, false), errFileNameTooLong, false},
{"policy suffix exceeds limit", getMappedPolicyPath(strings.Repeat("x", 251), stsUser, false), errFileNameTooLong, true},
{"drive", long, errFileNameTooLong, true},
{"wrapped drive", long, fmt.Errorf("read: %w", errFileNameTooLong), true},
{"wrapped object", long, fmt.Errorf("read: %w", ObjectNameInvalid{Bucket: minioMetaBucket, Object: long}), true},
{"no error", long, nil, false},
{"not found", long, errConfigNotFound, false},
{"offline drive", long, errDiskNotFound, false},
{"read quorum", long, InsufficientReadQuorum{}, false},
{"access denied", long, PrefixAccessDenied{}, false},
{"deadline", long, context.DeadlineExceeded, false},
{"invalid name", invalid, ObjectNameInvalid{Bucket: minioMetaBucket, Object: invalid}, false},
{"invalid long name", invalid + strings.Repeat("x", 256), errFileNameTooLong, false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := iamConfigNameTooLong(tc.path, tc.err); got != tc.want {
t.Errorf("got %t, want %t", got, tc.want)
}
})
}
}
// Moving a drive can make an existing record exceed the total path limit.
// A length error must not turn that parent's revocation into a missing record.
func TestLoadIAMRevisionDeepPath(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("exercises the Darwin total-path limit")
}
for _, tc := range []struct {
name string
parent string
}{
{"ASCII", strings.Repeat("p", 200)},
{"Unicode", strings.Repeat("界", 100)},
} {
t.Run(tc.name, func(t *testing.T) {
resetTestGlobals()
defer resetTestGlobals()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
base := t.TempDir()
oldRoot := filepath.Join(base, "drive")
mustIAM(t, os.Mkdir(oldRoot, 0o700))
obj, _, err := initObjectLayer(ctx, mustGetPoolEndpoints(0, oldRoot))
mustIAM(t, err)
defer func() { obj.Shutdown(ctx) }()
store := newIAMObjectStore(obj, LDAPUsersSysType)
cred := auth.Credentials{ParentUser: tc.parent}
parentPath := getUserIdentityPath(tc.parent, regUser)
mustIAM(t, store.saveIAMConfig(ctx, &UserIdentity{Version: 1, Deleted: true, RevokedBefore: UTCNow()}, parentPath))
if err := checkIAMParentRevision(ctx, store, cred); !errors.Is(err, errIAMStaleUpdate) {
t.Fatalf("original parent revocation ineffective: %v", err)
}
mustIAM(t, obj.Shutdown(ctx))
deep := base
for len(deep) < 850 {
deep = filepath.Join(deep, strings.Repeat("d", 80))
}
mustIAM(t, os.MkdirAll(deep, 0o700))
newRoot := filepath.Join(deep, "drive")
mustIAM(t, checkPathLength(newRoot))
mustIAM(t, os.Rename(oldRoot, newRoot))
// The record still exists; a relative read avoids the total-path limit.
root, err := os.OpenRoot(newRoot)
mustIAM(t, err)
defer root.Close()
data, err := root.ReadFile(filepath.Join(minioMetaBucket, parentPath, xlStorageFormatFile))
mustIAM(t, err)
if len(data) == 0 {
t.Fatal("missing physical revocation record")
}
obj, _, err = initObjectLayer(ctx, mustGetPoolEndpoints(0, newRoot))
mustIAM(t, err)
store = newIAMObjectStore(obj, LDAPUsersSysType)
var invalid ObjectNameInvalid
if err := checkIAMParentRevision(ctx, store, cred); !errors.As(err, &invalid) {
t.Fatalf("expected path error for an existing parent revocation, got %v", err)
}
})
}
}
// Run with both object storage and etcd through TestIAMRevocation*Lifecycle. // Run with both object storage and etcd through TestIAMRevocation*Lifecycle.
// The object-store case uses the real peer RPC and deletion handler, so a // The object-store case uses the real peer RPC and deletion handler, so a
// spurious notification actually destroys the parent instead of only counting it. // spurious notification actually destroys the parent instead of only counting it.
+153 -76
View File
@@ -647,6 +647,20 @@ func setDefaultCannedPolicies(policies map[string]PolicyDoc) {
} }
} }
// Remove a stored policy from cache. A built-in policy's deletion removes its
// override, so its native definition remains available with native metadata.
// The durable tombstone is retained separately to reject stale overrides.
func removePolicyFromCache(name string, policies map[string]PolicyDoc) (restoredDefault bool) {
delete(policies, name)
for _, p := range policy.DefaultPolicies {
if p.Name == name {
policies[name] = defaultPolicyDoc(p.Definition)
return true
}
}
return false
}
// LoadIAMCache reads all IAM items and populates a new iamCache object and // LoadIAMCache reads all IAM items and populates a new iamCache object and
// replaces the in-memory cache object. // replaces the in-memory cache object.
func (store *IAMStoreSys) LoadIAMCache(ctx context.Context, firstTime bool) error { func (store *IAMStoreSys) LoadIAMCache(ctx context.Context, firstTime bool) error {
@@ -711,13 +725,18 @@ 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()
} }
for name := range newCache.iamPolicyDocsMap { for name := range newCache.iamPolicyDocsMap {
if store.revisionIndex().get(getPolicyDocPath(name)).Deleted { if store.revisionIndex().get(getPolicyDocPath(name)).Deleted {
delete(newCache.iamPolicyDocsMap, name) removePolicyFromCache(name, newCache.iamPolicyDocsMap)
} }
} }
@@ -754,6 +773,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
} }
@@ -1363,9 +1390,9 @@ func (store *IAMStoreSys) PolicyDBSet(ctx context.Context, name, policy string,
} }
// PolicyNotificationHandler - loads given policy from storage. If not present, // PolicyNotificationHandler - loads given policy from storage. If not present,
// deletes from cache. This notification only reads from storage, and updates // removes its override from cache. This notification only reads from storage,
// cache. When the notification is for a policy deletion, it updates the // and updates cache. A deleted built-in override restores its native policy;
// user-policy and group-policy maps as well. // a deleted custom policy is removed from user-policy and group-policy maps.
func (store *IAMStoreSys) PolicyNotificationHandler(ctx context.Context, policy string) error { func (store *IAMStoreSys) PolicyNotificationHandler(ctx context.Context, policy string) error {
if policy == "" { if policy == "" {
return errInvalidArgument return errInvalidArgument
@@ -1376,8 +1403,10 @@ func (store *IAMStoreSys) PolicyNotificationHandler(ctx context.Context, policy
err := store.loadPolicyDoc(ctx, policy, cache.iamPolicyDocsMap) err := store.loadPolicyDoc(ctx, policy, cache.iamPolicyDocsMap)
if errors.Is(err, errNoSuchPolicy) { if errors.Is(err, errNoSuchPolicy) {
// policy was deleted, update cache. if removePolicyFromCache(policy, cache.iamPolicyDocsMap) {
delete(cache.iamPolicyDocsMap, policy) cache.updatedAt = time.Now()
return nil
}
// update user policy map // update user policy map
cache.iamUserPolicyMap.Range(func(u string, mp MappedPolicy) bool { cache.iamUserPolicyMap.Range(func(u string, mp MappedPolicy) bool {
@@ -1413,74 +1442,77 @@ func (store *IAMStoreSys) PolicyNotificationHandler(ctx context.Context, policy
cache.updatedAt = time.Now() cache.updatedAt = time.Now()
return nil return nil
} }
if err == nil {
cache.updatedAt = time.Now()
}
return err return err
} }
// DeletePolicy - deletes policy from storage and cache. When this called in // DeletePolicy - deletes a stored policy and restores the native definition
// response to a notification (i.e. isFromNotification = true), it skips the // when it was a built-in override. Notifications reload committed storage, so
// validation of policy usage and the attempt to delete in the backend as well // a delayed deletion event cannot remove a subsequently recreated policy.
// (as this is already done by the notifying node).
func (store *IAMStoreSys) DeletePolicy(ctx context.Context, policy string, isFromNotification bool) error { func (store *IAMStoreSys) DeletePolicy(ctx context.Context, policy string, isFromNotification bool) error {
if policy == "" { if policy == "" {
return errInvalidArgument return errInvalidArgument
} }
if isFromNotification {
return store.PolicyNotificationHandler(ctx, policy)
}
cache := store.lock() cache := store.lock()
defer store.unlock() defer store.unlock()
if !isFromNotification { if err := store.checkIAMRevision(ctx, getPolicyDocPath(policy), true); err != nil {
if err := store.checkIAMRevision(ctx, getPolicyDocPath(policy), true); err != nil { return err
return err }
if _, replicated := iamReplicationTime(ctx); !replicated {
if _, exists := cache.iamPolicyDocsMap[policy]; !exists {
// Preserve idempotent local deletion without creating a durable
// revocation for a misspelled/nonexistent policy name.
return nil
} }
if _, replicated := iamReplicationTime(ctx); !replicated { // Check if policy is mapped to any existing user or group. If so, we do not
if _, exists := cache.iamPolicyDocsMap[policy]; !exists { // allow deletion of the policy. If the policy is mapped to an STS account,
// Preserve idempotent local deletion without creating a durable // we do allow deletion.
// revocation for a misspelled/nonexistent policy name. users := []string{}
return nil groups := []string{}
cache.iamUserPolicyMap.Range(func(u string, mp MappedPolicy) bool {
pset := mp.policySet()
if store.getUsersSysType() == MinIOUsersSysType {
if _, ok := cache.iamUsersMap[u]; !ok {
// This case can happen when a temporary account is
// deleted or expired - remove it from userPolicyMap.
cache.iamUserPolicyMap.Delete(u)
return true
}
} }
// Check if policy is mapped to any existing user or group. If so, we do not if pset.Contains(policy) {
// allow deletion of the policy. If the policy is mapped to an STS account, users = append(users, u)
// we do allow deletion.
users := []string{}
groups := []string{}
cache.iamUserPolicyMap.Range(func(u string, mp MappedPolicy) bool {
pset := mp.policySet()
if store.getUsersSysType() == MinIOUsersSysType {
if _, ok := cache.iamUsersMap[u]; !ok {
// This case can happen when a temporary account is
// deleted or expired - remove it from userPolicyMap.
cache.iamUserPolicyMap.Delete(u)
return true
}
}
if pset.Contains(policy) {
users = append(users, u)
}
return true
})
cache.iamGroupPolicyMap.Range(func(g string, mp MappedPolicy) bool {
pset := mp.policySet()
if pset.Contains(policy) {
groups = append(groups, g)
}
return true
})
if len(users) != 0 || len(groups) != 0 {
return errPolicyInUse
} }
} return true
})
err := store.deletePolicyDoc(ctx, policy) cache.iamGroupPolicyMap.Range(func(g string, mp MappedPolicy) bool {
if errors.Is(err, errNoSuchPolicy) { pset := mp.policySet()
// Ignore error if policy is already deleted. if pset.Contains(policy) {
err = nil groups = append(groups, g)
} }
if err != nil { return true
return err })
if len(users) != 0 || len(groups) != 0 {
return errPolicyInUse
} }
} }
delete(cache.iamPolicyDocsMap, policy) err := store.deletePolicyDoc(ctx, policy)
if errors.Is(err, errNoSuchPolicy) {
// Ignore error if policy is already deleted.
err = nil
}
if err != nil {
return err
}
removePolicyFromCache(policy, cache.iamPolicyDocsMap)
cache.updatedAt = time.Now() cache.updatedAt = time.Now()
return nil return nil
@@ -1568,10 +1600,22 @@ func (store *IAMStoreSys) ListPolicies(ctx context.Context, bucketName string) (
defer store.unlock() defer store.unlock()
m := map[string]PolicyDoc{} m := map[string]PolicyDoc{}
err := store.loadPolicyDocs(ctx, m) // Keep cache writers serialized, but let authentication use the committed
// cache while the replacement is loaded into this private map.
err := func() error {
store.IAMStorageAPI.unlock()
defer store.IAMStorageAPI.lock()
if err := store.loadPolicyDocs(ctx, m); err != nil {
return err
}
return ctx.Err()
}()
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := ctx.Err(); err != nil {
return nil, err
}
// Sets default canned policies // Sets default canned policies
setDefaultCannedPolicies(m) setDefaultCannedPolicies(m)
@@ -1596,10 +1640,20 @@ func (store *IAMStoreSys) ListPolicyDocs(ctx context.Context, bucketName string)
defer store.unlock() defer store.unlock()
m := map[string]PolicyDoc{} m := map[string]PolicyDoc{}
err := store.loadPolicyDocs(ctx, m) err := func() error {
store.IAMStorageAPI.unlock()
defer store.IAMStorageAPI.lock()
if err := store.loadPolicyDocs(ctx, m); err != nil {
return err
}
return ctx.Err()
}()
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := ctx.Err(); err != nil {
return nil, err
}
// Sets default canned policies // Sets default canned policies
setDefaultCannedPolicies(m) setDefaultCannedPolicies(m)
@@ -2184,45 +2238,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
} }
+254
View File
@@ -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
View File
@@ -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).
+256
View File
@@ -0,0 +1,256 @@
// Copyright (c) 2026 Feng Ruohang
// 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 (
"bytes"
"context"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/minio/kms-go/kes"
"github.com/minio/minio/internal/config"
"github.com/minio/minio/internal/crypto"
"github.com/minio/minio/internal/kms"
)
// Use the configured KES client over TLS, rather than a mocked KMS interface:
// cancellation must survive every layer, including the HTTP request context.
func connectCancellationKES(t *testing.T, handler http.Handler) *kms.KMS {
t.Helper()
for _, key := range []string{kms.EnvKMSEndpoint, kms.EnvKMSSecretKey, kms.EnvKMSSecretKeyFile} {
t.Setenv(key, "")
mustIAM(t, os.Unsetenv(key))
}
server := httptest.NewTLSServer(handler)
t.Cleanup(server.Close)
ca := filepath.Join(t.TempDir(), "ca.pem")
mustIAM(t, os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600))
key, err := kes.GenerateAPIKey(nil)
mustIAM(t, err)
t.Setenv(kms.EnvKESEndpoint, server.URL)
t.Setenv(kms.EnvKESAPIKey, key.String())
t.Setenv(kms.EnvKESDefaultKey, "cancellation-key")
t.Setenv(kms.EnvKESServerCA, ca)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
k, err := kms.Connect(ctx, nil)
mustIAM(t, err)
return k
}
func cancellationDecryptHandler(t *testing.T, k *kms.KMS, before func(*http.Request)) http.Handler {
t.Helper()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// KMS Connect periodically describes the default key to keep it cached.
// This maintenance request is independent of the decrypt under test.
if r.Method == http.MethodGet && r.URL.Path == "/v1/key/describe/cancellation-key" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"name": "cancellation-key"})
return
}
if r.URL.Path != "/v1/key/decrypt/cancellation-key" {
t.Errorf("unexpected KES path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
var request struct {
Ciphertext []byte `json:"ciphertext"`
Context []byte `json:"context"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Error(err)
return
}
// Drain the HTTP/1 body so net/http can observe a peer disconnect while
// the handler is blocked in the simulated KES operation.
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
before(r)
if r.Context().Err() != nil {
return
}
var aad kms.Context
if err := json.Unmarshal(request.Context, &aad); err != nil {
t.Error(err)
return
}
plain, err := k.Decrypt(r.Context(), &kms.DecryptRequest{Name: "cancellation-key", Ciphertext: request.Ciphertext, AssociatedData: aad})
w.Header().Set("Content-Type", "application/json")
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"message": kes.ErrDecrypt.Error()})
return
}
_ = json.NewEncoder(w).Encode(map[string][]byte{"plaintext": plain})
})
}
func TestKMSRequestCancellation(t *testing.T) {
for _, operation := range []string{"iam", "sse-list"} {
for _, expiry := range []string{"cancel", "deadline"} {
t.Run(operation+"/"+expiry, func(t *testing.T) {
builtin, err := kms.NewBuiltin("cancellation-key", bytes.Repeat([]byte{42}, 32))
mustIAM(t, err)
entered, canceled, release := make(chan struct{}), make(chan struct{}), make(chan struct{})
var calls atomic.Int32
var enteredOnce, canceledOnce sync.Once
unblock := sync.OnceFunc(func() { close(release) })
client := connectCancellationKES(t, cancellationDecryptHandler(t, builtin, func(r *http.Request) {
calls.Add(1)
enteredOnce.Do(func() { close(entered) })
select {
case <-r.Context().Done():
canceledOnce.Do(func() { close(canceled) })
case <-release:
}
}))
t.Cleanup(unblock)
prior := GlobalKMS
GlobalKMS = client
t.Cleanup(func() { GlobalKMS = prior })
var run func(context.Context) error
if operation == "iam" {
const name = "config/iam/policies/cancel/policy.json"
plaintext := []byte(`{"Version":1}`)
ciphertext, err := config.EncryptBytes(builtin, plaintext, kms.Context{minioMetaBucket: minioMetaBucket + "/" + name})
mustIAM(t, err)
run = func(ctx context.Context) error {
got, err := decryptData(ctx, ciphertext, name)
if err == nil && !bytes.Equal(got, plaintext) {
return errors.New("config plaintext changed")
}
return err
}
} else {
var metadata []map[string]string
var buckets, names []string
var expected []crypto.ObjectKey
for i := range 3 {
name := fmt.Sprintf("object-%d", i)
dek, err := builtin.GenerateKey(context.Background(), &kms.GenerateKeyRequest{AssociatedData: kms.Context{"bucket": "bucket/" + name}})
mustIAM(t, err)
key := crypto.GenerateKey(dek.Plaintext, nil)
sealed := key.Seal(dek.Plaintext, crypto.GenerateIV(nil), crypto.S3.String(), "bucket", name)
metadata = append(metadata, crypto.S3.CreateMetadata(nil, dek.KeyID, dek.Ciphertext, sealed))
buckets, names = append(buckets, "bucket"), append(names, name)
expected = append(expected, key)
}
run = func(ctx context.Context) error {
keys, err := crypto.S3.UnsealObjectKeys(ctx, client, metadata, buckets, names)
if err != nil {
return err
}
if len(keys) != len(expected) {
return errors.New("partial key list returned as success")
}
for i := range keys {
if keys[i] != expected[i] {
return errors.New("object key or ordering changed")
}
}
return nil
}
}
ctx, cancel := context.WithCancel(context.Background())
if expiry == "deadline" {
cancel()
ctx, cancel = context.WithTimeout(context.Background(), 300*time.Millisecond)
}
defer cancel()
done := make(chan error, 1)
go func() { done <- run(ctx) }()
select {
case <-entered:
case err := <-done:
t.Fatalf("decrypt returned before reaching KES: %v (type=%v)", err, client.Type)
case <-time.After(5 * time.Second):
t.Fatal("decrypt did not reach KES")
}
want := context.DeadlineExceeded
if expiry == "cancel" {
cancel()
want = context.Canceled
}
select {
case err := <-done:
if !errors.Is(err, want) {
t.Fatalf("got %v, want %v", err, want)
}
// The current KES SDK may sleep twice for up to 999ms after a
// deadline error. Include that existing backoff plus the 300ms
// request deadline; cancellation must still stop new decrypts.
case <-time.After(3 * time.Second):
unblock()
<-done
t.Fatal("request exceeded the cancellation and KES retry budget")
}
awaitIAMPolicyReload(t, canceled)
if n := calls.Load(); n != 1 {
t.Fatalf("cancellation launched a fallback or more object decryptions: %d calls", n)
}
unblock()
mustIAM(t, run(context.Background()))
before := calls.Load()
cctx, ccancel := context.WithCancel(context.Background())
ccancel()
if err := run(cctx); !errors.Is(err, context.Canceled) || calls.Load() != before {
t.Fatal("already-canceled request still issued decryption work")
}
})
}
}
}
func TestIAMDecryptAssociatedDataCompatibility(t *testing.T) {
builtin, err := kms.NewBuiltin("cancellation-key", bytes.Repeat([]byte{42}, 32))
mustIAM(t, err)
var calls atomic.Int32
client := connectCancellationKES(t, cancellationDecryptHandler(t, builtin, func(*http.Request) { calls.Add(1) }))
prior := GlobalKMS
GlobalKMS = client
t.Cleanup(func() { GlobalKMS = prior })
const name = "config/iam/policies/compat/policy.json"
plaintext := []byte(`{"Version":1}`)
for _, legacy := range []bool{false, true} {
t.Run(fmt.Sprint(legacy), func(t *testing.T) {
aad, wantCalls := minioMetaBucket+"/"+name, int32(1)
if legacy {
aad, wantCalls = name, 2
}
ciphertext, err := config.EncryptBytes(builtin, plaintext, kms.Context{minioMetaBucket: aad})
mustIAM(t, err)
before := calls.Load()
got, err := decryptData(context.Background(), ciphertext, name)
mustIAM(t, err)
if !bytes.Equal(got, plaintext) || calls.Load()-before != wantCalls {
t.Fatal("current/legacy encrypted IAM records no longer decrypt correctly")
}
})
}
}
+16 -16
View File
@@ -40,7 +40,7 @@ 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)
@@ -81,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)
} }
@@ -97,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)
} }
@@ -108,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)
} }
@@ -141,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)
} }
@@ -153,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)
} }
@@ -197,12 +197,12 @@ 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)
} }
legacy, err := z.ListMultipartUploads(t.Context(), bucket, legacyObject, "", "", "", 100) legacy, err := z.scanMultipartUploadsForTest(t.Context(), bucket, legacyObject, "", "", "", 100)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
requireMultipartUploadKeys(t, legacy) requireMultipartUploadKeys(t, legacy)
remaining, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 100) remaining, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 100)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -280,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)
} }
@@ -289,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)
} }
+22 -4
View File
@@ -781,6 +781,15 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
} }
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 {
@@ -790,6 +799,7 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
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 {
@@ -819,10 +829,9 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
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.
@@ -838,6 +847,7 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
} }
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
} }
@@ -866,6 +876,11 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
if err != nil { if err != nil {
return err return err
} }
if resolved != nil {
if err := discardDirectories(); err != nil {
return err
}
}
if resolved != nil && resolved.name > last { if resolved != nil && resolved.name > last {
select { select {
case <-ctxDone: case <-ctxDone:
@@ -876,6 +891,9 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
} }
continue continue
} }
if err := discardDirectories(); err != nil {
return err
}
// Merge any unmerged // Merge any unmerged
if len(toMerge) > 0 { if len(toMerge) > 0 {
+243
View File
@@ -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)
}
})
}
}
}
+42 -25
View File
@@ -221,33 +221,50 @@ func TestListingRecoveryNormalPathDoesNotReadOrLock(t *testing.T) {
func TestListingPendingDirectoryCollision(t *testing.T) { func TestListingPendingDirectoryCollision(t *testing.T) {
for _, objectFirst := range []bool{false, true} { for _, objectFirst := range []bool{false, true} {
t.Run(fmt.Sprint(objectFirst), func(t *testing.T) { for _, present := range []bool{true, false} {
in := []chan metaCacheEntry{make(chan metaCacheEntry, 1), make(chan metaCacheEntry, 1)} t.Run(fmt.Sprintf("objectFirst=%v/present=%v", objectFirst, present), func(t *testing.T) {
objectIndex := 1 in := []chan metaCacheEntry{make(chan metaCacheEntry, 2), make(chan metaCacheEntry, 2), make(chan metaCacheEntry, 2)}
if objectFirst { objectIndex := 1
objectIndex = 0 if objectFirst {
} objectIndex = 0
in[objectIndex] <- metaCacheEntry{name: "dir/", listingSet: &erasureObjects{}} }
in[1-objectIndex] <- metaCacheEntry{name: "dir/"} in[objectIndex] <- metaCacheEntry{name: "dir/", listingSet: &erasureObjects{}}
for _, c := range in { in[1-objectIndex] <- metaCacheEntry{name: "dir/"}
close(c) in[2] <- metaCacheEntry{name: "a", metadata: []byte{1}}
} for _, c := range in {
out := make(chan metaCacheEntry, 2) close(c)
calls := 0 }
err := mergeEntryChannelsWithResolver(t.Context(), in, out, 1, nil, func(group []*metaCacheEntry) (*metaCacheEntry, error) { out := make(chan metaCacheEntry, 4)
calls++ calls := 0
if len(group) != 1 || group[0].listingSet == nil { err := mergeEntryChannelsWithResolver(t.Context(), in, out, 1, nil, func(group []*metaCacheEntry) (*metaCacheEntry, error) {
t.Fatalf("wrong pending group: %+v", group) 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)
} }
return &metaCacheEntry{name: "dir/", metadata: []byte{1}}, nil
}) })
if err != nil || calls != 1 || len(out) != 1 { }
t.Fatalf("err=%v recoveries=%d outputs=%d", err, calls, len(out))
}
if entry := <-out; entry.isDir() || !entry.isObjectDir() {
t.Fatalf("object became directory: %+v", entry)
}
})
} }
} }
+11
View File
@@ -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
}
+187
View File
@@ -0,0 +1,187 @@
// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// Exercise the production RequestServer driver against a real S3 backend.
// The library's filesystem Server and its ReadOnly option are a different path.
func TestSFTPRequestServerObjectOperations(t *testing.T) {
for _, secure := range []bool{false, true} {
t.Run(fmt.Sprintf("TLS=%t", secure), func(t *testing.T) {
c := &check{t, "ErasureSD"}
suite := newTestSuiteIAM(TestSuiteCommon{serverType: "ErasureSD", signer: signerV4, secure: secure}, false)
suite.SetUpSuite(c)
t.Cleanup(func() { suite.TearDownSuite(c) })
previousTLS, previousTransport := globalIsTLS, globalRemoteFTPClientTransport
globalIsTLS = secure
globalRemoteFTPClientTransport = suite.TestSuiteCommon.client.Transport.(*http.Transport)
t.Cleanup(func() {
globalIsTLS, globalRemoteFTPClientTransport = previousTLS, previousTransport
})
ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout)
defer cancel()
connect := func(policy string) *sftp.Client {
t.Helper()
accessKey, secretKey := mustGenerateCredentials(c)
if err := suite.adm.SetUser(ctx, accessKey, secretKey, madmin.AccountEnabled); err != nil {
t.Fatal(err)
}
if _, err := suite.adm.AttachPolicy(ctx, madmin.PolicyAssociationReq{Policies: []string{policy}, User: accessKey}); err != nil {
t.Fatal(err)
}
perms, err := sshPasswordAuth(newSSHConnMock(accessKey), []byte(secretKey))
if err != nil {
t.Fatal(err)
}
return newSFTPRequestTestClient(t, perms)
}
client := connect("readwrite")
bucket := "/" + getRandomBucketName()
if err := client.Mkdir(bucket); err != nil {
t.Fatal(err)
}
read := func(client *sftp.Client, name string) []byte {
t.Helper()
f, err := client.Open(name)
if err != nil {
t.Fatal(err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
t.Fatal(err)
}
return data
}
for _, size := range []int{0, 32767, 32768, 65537} {
name := fmt.Sprintf("%s/中文-café-%d", bucket, size)
data := bytes.Repeat([]byte{byte(size)}, size)
f, err := client.Create(name)
if err != nil {
t.Fatal(err)
}
if n, err := f.ReadFrom(bytes.NewReader(data)); err != nil || n != int64(size) {
f.Close()
t.Fatalf("upload %s: n=%d, err=%v", name, n, err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if got := read(client, name); !bytes.Equal(got, data) {
t.Fatalf("download %s: byte mismatch", name)
}
info, err := client.Stat(name)
if err != nil || info.Size() != int64(size) {
t.Fatalf("stat %s: info=%v, err=%v", name, info, err)
}
}
entries, err := client.ReadDir(bucket)
if err != nil || len(entries) != 4 {
t.Fatalf("list: %d entries, err=%v", len(entries), err)
}
name := bucket + "/中文-café-65537"
original := read(client, name)
// Include list permission because Client.Remove also tries Rmdir;
// both requests must observe the denied delete, not a failed listing.
readonly := connect("consolereadonly")
if !bytes.Equal(read(readonly, name), original) {
t.Fatal("readonly download changed content")
}
if f, err := readonly.OpenFile(name, os.O_WRONLY|os.O_TRUNC); err == nil {
_, writeErr := f.Write([]byte("forbidden"))
closeErr := f.Close()
// The streaming driver can acknowledge buffered bytes before S3
// rejects the PUT. Verify the object, not just the write status.
t.Logf("readonly write: write=%v close=%v", writeErr, closeErr)
}
if !bytes.Equal(read(client, name), original) {
t.Fatal("readonly upload modified content")
}
if err := readonly.Remove(name); err == nil {
t.Fatal("readonly delete succeeded")
}
if !bytes.Equal(read(client, name), original) {
t.Fatal("denied operation modified content")
}
// Remove retries Rmdir, which can mask a listing error for a user
// without ListBucket. Check the stored object even if it returns nil.
withoutList := connect("readonly")
withoutList.Remove(name)
if !bytes.Equal(read(client, name), original) {
t.Fatal("readonly user without listing permission deleted content")
}
for operation, err := range map[string]error{
"hardlink": client.Link(name, bucket+"/hardlink"),
"symlink": client.Symlink(name, bucket+"/symlink"),
"rename": client.Rename(name, bucket+"/renamed"),
} {
if status, ok := err.(*sftp.StatusError); !ok || status.Code != uint32(sftp.ErrSSHFxOpUnsupported) {
t.Fatalf("%s: expected unsupported operation, got %v", operation, err)
}
}
for _, entry := range entries {
if err := client.Remove(bucket + "/" + entry.Name()); err != nil {
t.Fatal(err)
}
}
entries, err = client.ReadDir(bucket)
if err != nil || len(entries) != 0 {
t.Fatalf("list after deletion: %d entries, err=%v", len(entries), err)
}
// SILO exposes a missing object as a directory for stat-then-list
// clients. Keep that existing virtual-directory behavior.
if info, err := client.Stat(name); err != nil || !info.IsDir() {
t.Fatalf("stat virtual directory: info=%v, err=%v", info, err)
}
if err := client.RemoveDirectory(bucket); err != nil {
t.Fatal(err)
}
})
}
}
func newSFTPRequestTestClient(t *testing.T, perms *ssh.Permissions) *sftp.Client {
t.Helper()
serverConn, clientConn := net.Pipe()
deadline := time.Now().Add(time.Minute)
serverConn.SetDeadline(deadline)
clientConn.SetDeadline(deadline)
server := sftp.NewRequestServer(serverConn, NewSFTPDriver(perms, "127.0.0.1"), sftp.WithRSAllocator())
done := make(chan struct{})
go func() {
defer close(done)
server.Serve()
}()
t.Cleanup(func() {
clientConn.Close()
server.Close()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("SFTP request server did not stop")
}
})
client, err := sftp.NewClientPipe(clientConn, clientConn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { client.Close() })
return client
}
+108
View File
@@ -36,6 +36,7 @@ import (
"testing" "testing"
"time" "time"
goldap "github.com/go-ldap/ldap/v3"
"github.com/klauspost/compress/zip" "github.com/klauspost/compress/zip"
"github.com/minio/madmin-go/v3" "github.com/minio/madmin-go/v3"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
@@ -1095,6 +1096,7 @@ func TestIAMWithLDAPServerSuite(t *testing.T) {
suite.TestLDAPSTSServiceAccountsWithGroups(c) suite.TestLDAPSTSServiceAccountsWithGroups(c)
suite.TestLDAPAttributesLookup(c) suite.TestLDAPAttributesLookup(c)
suite.TestLDAPCyrillicUser(c) suite.TestLDAPCyrillicUser(c)
suite.TestLDAPLongNonASCIIDN(c)
suite.TestLDAPSlashDN(c) suite.TestLDAPSlashDN(c)
suite.TearDownSuite(c) suite.TearDownSuite(c)
}, },
@@ -3291,6 +3293,112 @@ func (s *TestSuiteIAM) TestLDAPCyrillicUser(c *check) {
} }
} }
// Normalized DNs hex-escape every non-ASCII byte, so a user in nested Cyrillic
// OUs has a DN longer than NAME_MAX: 160 bytes of UTF-8 become 384 characters.
// Records named after such a DN can never be stored by the object backend.
// The user is entitled only through a group, as is typical for AD deployments.
func (s *TestSuiteIAM) TestLDAPLongNonASCIIDN(c *check) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
const (
username = "Сотрудник"
parentOU = "ou=Дирекция информационных технологий,ou=people,ou=swengg,dc=min,dc=io"
childOU = "ou=Отдел разработки," + parentOU
userDN = "uid=" + username + "," + childOU
groupDN = "cn=long-dn-staff,ou=groups,ou=swengg,dc=min,dc=io"
)
normDN, err := ldap.NormalizeDN(userDN)
if err != nil {
c.Fatal(err)
}
if len(normDN) <= 255 {
c.Fatalf("normalized test DN must exceed 255 characters, got %d", len(normDN))
}
conn, err := globalIAMSys.LDAPConfig.LDAP.Connect()
if err != nil {
c.Fatalf("LDAP connect failed: %v", err)
}
defer conn.Close()
if err = conn.Bind("cn=admin,dc=min,dc=io", "admin"); err != nil {
c.Fatalf("LDAP admin bind failed: %v", err)
}
newEntry := func(dn string, attrs map[string]string) *goldap.AddRequest {
req := goldap.NewAddRequest(dn, nil)
for name, value := range attrs {
req.Attribute(name, []string{value})
}
return req
}
entries := []*goldap.AddRequest{
newEntry(parentOU, map[string]string{"objectClass": "organizationalUnit", "ou": "Дирекция информационных технологий"}),
newEntry(childOU, map[string]string{"objectClass": "organizationalUnit", "ou": "Отдел разработки"}),
newEntry(userDN, map[string]string{"objectClass": "inetOrgPerson", "uid": username, "cn": username, "sn": username, "userPassword": "example"}),
newEntry(groupDN, map[string]string{"objectClass": "groupOfNames", "cn": "long-dn-staff", "member": userDN}),
}
removeEntries := func() {
for i := len(entries) - 1; i >= 0; i-- {
err := conn.Del(goldap.NewDelRequest(entries[i].DN, nil))
if err != nil && !goldap.IsErrorWithCode(err, goldap.LDAPResultNoSuchObject) {
c.Errorf("Unable to remove LDAP entry %s: %v", entries[i].DN, err)
}
}
}
removeEntries() // left over from an interrupted run
defer removeEntries()
for _, entry := range entries {
if err = conn.Add(entry); err != nil {
c.Fatalf("Unable to add LDAP entry %s: %v", entry.DN, err)
}
}
groupReq := madmin.PolicyAssociationReq{
Policies: []string{"readwrite"},
Group: groupDN,
}
if _, err = s.adm.AttachPolicyLDAP(ctx, groupReq); err != nil {
c.Fatalf("Unable to attach group policy: %v", err)
}
defer func() {
if _, err := s.adm.DetachPolicyLDAP(ctx, groupReq); err != nil {
c.Errorf("Unable to detach group policy: %v", err)
}
}()
bucket := getRandomBucketName()
if err = s.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil {
c.Fatalf("bucket create error: %v", err)
}
ldapID := cr.LDAPIdentity{
Client: s.TestSuiteCommon.client,
STSEndpoint: s.endPoint,
LDAPUsername: username,
LDAPPassword: "example",
}
value, err := ldapID.Retrieve()
if err != nil {
c.Fatalf("Expected to generate STS creds, got err: %#v", err)
}
u, ok := globalIAMSys.GetUser(ctx, value.AccessKeyID)
if !ok {
c.Fatalf("Expected to find user %s", value.AccessKeyID)
}
if u.Credentials.ParentUser != normDN {
c.Fatalf("unexpected parent user: %s", u.Credentials.ParentUser)
}
c.mustListObjects(ctx, s.getUserClient(c, value.AccessKeyID, value.SecretAccessKey, value.SessionToken), bucket)
// An access key created by this user inherits the same parent.
userAdmClient := s.getAdminClient(c, value.AccessKeyID, value.SecretAccessKey, value.SessionToken)
svc := c.mustCreateSvcAccount(ctx, value.AccessKeyID, userAdmClient)
c.assertSvcAccS3Access(ctx, s, svc, bucket)
if err = s.adm.DeleteServiceAccount(ctx, svc.AccessKey); err != nil {
c.Fatalf("Unable to delete service account: %v", err)
}
}
func (s *TestSuiteIAM) TestLDAPSlashDN(c *check) { func (s *TestSuiteIAM) TestLDAPSlashDN(c *check) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
+7 -4
View File
@@ -3,8 +3,8 @@ set -eu
# Build both release architectures from the same verified upstream source. # Build both release architectures from the same verified upstream source.
# The previous binary supplier stopped publishing current ARM64 builds. # The previous binary supplier stopped publishing current ARM64 builds.
CURL_VERSION=8.22.0 CURL_VERSION=8.23.0
CURL_SHA256=f7ef3ae8a22e521f289803fe93543eb64c329b58aa73a9e224dfd915a2a5f4f7 CURL_SHA256=8dadd7d2536a314f982ad05a651b713f426bf7d02cc2c76cffb9796f2218e6c5
case ${TARGETARCH:?TARGETARCH is required} in case ${TARGETARCH:?TARGETARCH is required} in
amd64) expected_arch=x86_64 ;; amd64) expected_arch=x86_64 ;;
@@ -16,9 +16,12 @@ esac
exit 1 exit 1
} }
# Keep security fixes in statically linked libraries, including distro backports.
apk add --no-cache --upgrade build-base curl ca-certificates \ apk add --no-cache --upgrade build-base curl ca-certificates \
openssl-libs-static nghttp2-static libpsl-static libidn2-static \ 'musl-dev>=1.2.6-r3' 'openssl-libs-static>=3.5.9-r0' \
libunistring-static zlib-static brotli-static zstd-static libssh2-static 'nghttp2-static>=1.70.0-r0' libpsl-static libidn2-static \
libunistring-static 'zlib-static>=1.3.2-r1' 'brotli-static>=1.2.1-r0' \
zstd-static 'libssh2-static>=1.11.1-r4'
work=$(mktemp -d) work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT trap 'rm -rf "$work"' EXIT
+11 -13
View File
@@ -1,25 +1,23 @@
module github.com/minio/minio/docs/debugging/inspect module github.com/minio/minio/docs/debugging/inspect
go 1.23.0 go 1.27.3
toolchain go1.24.8
require ( require (
github.com/klauspost/compress v1.17.11 github.com/klauspost/compress v1.20.3
github.com/klauspost/filepathx v1.1.1 github.com/klauspost/filepathx v1.1.1
github.com/minio/colorjson v1.0.8 github.com/minio/colorjson v1.0.8
github.com/minio/madmin-go/v3 v3.0.88 github.com/minio/madmin-go/v3 v3.0.110
github.com/secure-io/sio-go v0.3.1 github.com/secure-io/sio-go v0.3.1
github.com/tinylib/msgp v1.2.5 github.com/tinylib/msgp v1.6.5
) )
require ( require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fatih/color v1.18.0 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.16 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/minio/pkg/v3 v3.0.28 // indirect github.com/minio/pkg/v3 v3.11.0 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/philhofer/fwd v1.2.1 // indirect
golang.org/x/crypto v0.35.0 // indirect golang.org/x/crypto v0.58.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.49.0 // indirect
) )
+20 -21
View File
@@ -1,36 +1,35 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/filepathx v1.1.1 h1:201zvAsL1PhZvmXTP+QLer3AavWrO3U1NILWpniHK4w= github.com/klauspost/filepathx v1.1.1 h1:201zvAsL1PhZvmXTP+QLer3AavWrO3U1NILWpniHK4w=
github.com/klauspost/filepathx v1.1.1/go.mod h1:XWxdp8rEw4gupPBrxrV5Q57dL/71xj0OgV1gKt2zTfU= github.com/klauspost/filepathx v1.1.1/go.mod h1:XWxdp8rEw4gupPBrxrV5Q57dL/71xj0OgV1gKt2zTfU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/minio/colorjson v1.0.8 h1:AS6gEQ1dTRYHmC4xuoodPDRILHP/9Wz5wYUGDQfPLpg= github.com/minio/colorjson v1.0.8 h1:AS6gEQ1dTRYHmC4xuoodPDRILHP/9Wz5wYUGDQfPLpg=
github.com/minio/colorjson v1.0.8/go.mod h1:wrs39G/4kqNlGjwqHvPlAnXuc2tlPszo6JKdSBCLN8w= github.com/minio/colorjson v1.0.8/go.mod h1:wrs39G/4kqNlGjwqHvPlAnXuc2tlPszo6JKdSBCLN8w=
github.com/minio/madmin-go/v3 v3.0.88 h1:6AEPJItB65XVKmrpnuvDEO0bwNDeUwPY2cQUZzbGdT0= github.com/minio/madmin-go/v3 v3.0.110 h1:FIYekj7YPc430ffpXFWiUtyut3qBt/unIAcDzJn9H5M=
github.com/minio/madmin-go/v3 v3.0.88/go.mod h1:pMLdj9OtN0CANNs5tdm6opvOlDFfj0WhbztboZAjRWE= github.com/minio/madmin-go/v3 v3.0.110/go.mod h1:WOe2kYmYl1OIlY2DSRHVQ8j1v4OItARQ6jGyQqcCud8=
github.com/minio/pkg/v3 v3.0.28 h1:8tSuZnJbjc3C3DM2DEh4ZnSWjMZdccd679stk8sPD60= github.com/minio/pkg/v3 v3.11.0 h1:WjzWGP7lyuupJgUSRim1RDtNujHtESyMQF7qSR4r5Dg=
github.com/minio/pkg/v3 v3.0.28/go.mod h1:mIaN552nu0D2jiSk5BQC8LB25f44ytbOBJCuLtksX7Q= github.com/minio/pkg/v3 v3.11.0/go.mod h1:gBNHbob7QUCu/5nOtkAEEpN6diu1o1lwFpEnzU35s9U=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/secure-io/sio-go v0.3.1 h1:dNvY9awjabXTYGsTF1PiCySl9Ltofk9GA3VdWlo7rRc= github.com/secure-io/sio-go v0.3.1 h1:dNvY9awjabXTYGsTF1PiCySl9Ltofk9GA3VdWlo7rRc=
github.com/secure-io/sio-go v0.3.1/go.mod h1:+xbkjDzPjwh4Axd07pRKSNriS9SCiYksWnZqdnfpQxs= github.com/secure-io/sio-go v0.3.1/go.mod h1:+xbkjDzPjwh4Axd07pRKSNriS9SCiYksWnZqdnfpQxs=
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
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=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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=
+1 -3
View File
@@ -1,5 +1,3 @@
module github.com/minio/minio/docs/debugging/pprofgoparser module github.com/minio/minio/docs/debugging/pprofgoparser
go 1.21 go 1.27.3
toolchain go1.24.8
+2 -2
View File
@@ -1,5 +1,5 @@
module github.com/minio/minio/docs/debugging/reorder-disks module github.com/minio/minio/docs/debugging/reorder-disks
go 1.26.0 go 1.27.3
require github.com/pgsty/silo-pkg/v3 v3.13.2 require github.com/pgsty/silo-pkg/v3 v3.14.3
+2 -2
View File
@@ -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=
+16 -15
View File
@@ -1,25 +1,26 @@
module github.com/minio/minio/docs/debugging/s3-verify module github.com/minio/minio/docs/debugging/s3-verify
go 1.23.0 go 1.27.3
toolchain go1.24.8 require github.com/minio/minio-go/v7 v7.4.1
require github.com/minio/minio-go/v7 v7.0.83
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/compress v1.20.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/klauspost/cpuid/v2 v2.4.1 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/philhofer/fwd v1.2.1 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
golang.org/x/crypto v0.36.0 // indirect github.com/tinylib/msgp v1.6.5 // indirect
golang.org/x/net v0.38.0 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect
golang.org/x/sys v0.31.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/crypto v0.58.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect golang.org/x/net v0.61.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
) )
+44 -23
View File
@@ -1,36 +1,57 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 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.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= 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/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
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.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA= github.com/minio/minio-go/v7 v7.4.1 h1:PtU/zIOfQRWZ+QydXAZysc+xZYHdG+BBR4Z6TUvy73w=
github.com/minio/minio-go/v7 v7.0.83/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY= github.com/minio/minio-go/v7 v7.4.1/go.mod h1:nQnQi7zTfXR4lREtYXZQWqo5aw0Rqv+ZqCGrTORygRk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
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=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+4 -5
View File
@@ -1,16 +1,15 @@
module github.com/minio/minio/docs/debugging/xattr module github.com/minio/minio/docs/debugging/xattr
go 1.21 go 1.27.3
toolchain go1.24.8
require ( require (
github.com/olekukonko/tablewriter v0.0.5 github.com/olekukonko/tablewriter v0.0.5
github.com/pkg/xattr v0.4.9 github.com/pkg/xattr v0.4.12
) )
require ( require (
// Preserve Unicode table alignment; v0.0.31 changes Hindi and CJK-locale output.
github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/rivo/uniseg v0.4.4 // indirect github.com/rivo/uniseg v0.4.4 // indirect
golang.org/x/sys v0.15.0 // indirect golang.org/x/sys v0.49.0 // indirect
) )
+4 -4
View File
@@ -3,11 +3,11 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE= github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
@@ -0,0 +1,22 @@
# Single-node migration from MinIO to SILO
The maintained migration procedure and single-node case study belong to the
companion documentation site:
- [English migration guide](https://silo.pgsty.com/compatibility/migration/)
- [中文迁移指南](https://silo.pgsty.com/zh/compatibility/migration/)
Follow the guide's deployment checks, consistent backup procedure, verification
steps and [rollback requirements](https://silo.pgsty.com/compatibility/migration/#rollback).
Modern single-node/single-drive erasure deployments are distinct from the retired
filesystem backend; historical FS installations need the
[filesystem migration procedure](https://silo.pgsty.com/operations/deployments/baremetal-migrate-fs-gateway/).
Paris Y. ([@yupoet](https://github.com/yupoet)) contributed the production report in
[#228](https://github.com/pgsty/silo/pull/228), describing a 2026-09-26 forward
upgrade to SILO 20260916. The
[original report](https://github.com/pgsty/silo/blob/fec1e0804a7493f68ba409345da8e90f0f4d5fbb/docs/upgrade/single-node-from-archived-minio.md)
is retained as historical evidence. Its FS terminology and unconditional
metadata, healthcheck, multipart-listing and rollback claims are superseded by
the maintained guide. The report is not independent verification of a newer
release, a complete historical-object audit, or a reverse downgrade.
+127 -121
View File
@@ -1,12 +1,12 @@
module github.com/minio/minio module github.com/minio/minio
go 1.27.2 go 1.27.3
// 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-20261015132217-e5b165e4c0ee replace github.com/minio/console => github.com/pgsty/silo-console v0.0.0-20261025140954-258717be077b
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-20261025104502-dcc78d506f94
// 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
@@ -20,15 +20,15 @@ tool (
) )
require ( require (
aead.dev/mtls v0.3.0 aead.dev/mtls v0.4.0
cloud.google.com/go/storage v1.61.3 cloud.google.com/go/storage v1.70.0
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.15.0
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.2
github.com/IBM/sarama v1.45.1 github.com/IBM/sarama v1.61.1
github.com/alecthomas/participle v0.7.1 github.com/alecthomas/participle v0.7.1
github.com/beevik/ntp v1.5.0 github.com/beevik/ntp v1.6.0
github.com/buger/jsonparser v1.1.2 github.com/buger/jsonparser v1.6.1
github.com/cespare/xxhash/v2 v2.3.0 github.com/cespare/xxhash/v2 v2.3.0
github.com/cheggaaa/pb v1.0.30 github.com/cheggaaa/pb v1.0.30
github.com/coreos/go-oidc/v3 v3.21.0 github.com/coreos/go-oidc/v3 v3.21.0
@@ -41,23 +41,23 @@ 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.3
github.com/go-sql-driver/mysql v1.9.3 github.com/go-sql-driver/mysql v1.10.1
github.com/gobwas/ws v1.4.0 github.com/gobwas/ws v1.4.0
github.com/golang-jwt/jwt/v4 v4.5.2 github.com/golang-jwt/jwt/v4 v4.5.2
github.com/gomodule/redigo v1.9.3 github.com/gomodule/redigo v1.9.3
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.3.0
github.com/klauspost/readahead v1.4.0 github.com/klauspost/readahead v1.4.0
github.com/klauspost/reedsolomon v1.13.3 github.com/klauspost/reedsolomon v1.14.2
github.com/lib/pq v1.10.9 github.com/lib/pq v1.12.3
github.com/lithammer/shortuuid/v4 v4.2.0 github.com/lithammer/shortuuid/v4 v4.3.0
github.com/miekg/dns v1.1.73 github.com/miekg/dns v1.1.73
github.com/minio/cli v1.24.2 github.com/minio/cli v1.24.2
github.com/minio/console v1.7.6 github.com/minio/console v1.7.6
@@ -66,78 +66,79 @@ require (
github.com/minio/dperf v0.7.1 github.com/minio/dperf v0.7.1
github.com/minio/highwayhash v1.0.4 github.com/minio/highwayhash v1.0.4
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.7.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.2-0.20261020194015-ab93db7ba435
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
github.com/minio/sio v0.4.3 github.com/minio/sio v0.5.1
github.com/minio/xxml v0.0.3 github.com/minio/xxml v0.0.3
github.com/minio/zipindex v0.5.0 github.com/minio/zipindex v0.5.0
github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-homedir v1.1.0
github.com/nats-io/nats-server/v2 v2.11.15 github.com/nats-io/nats-server/v2 v2.15.1
github.com/nats-io/nats.go v1.49.0 github.com/nats-io/nats.go v1.55.0
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.15.1
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.33
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.12
github.com/pkg/xattr v0.4.12 github.com/pkg/xattr v0.4.12
github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_golang v1.25.0
github.com/prometheus/client_model v0.6.3 github.com/prometheus/client_model v0.6.3
github.com/prometheus/common v0.71.0 github.com/prometheus/common v0.72.1
github.com/prometheus/procfs v0.22.0 github.com/prometheus/procfs v0.23.0
github.com/puzpuzpuz/xsync/v3 v3.5.1 github.com/puzpuzpuz/xsync/v3 v3.5.1
github.com/rabbitmq/amqp091-go v1.14.0 github.com/rabbitmq/amqp091-go v1.16.0
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9
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.3
go.etcd.io/etcd/client/v3 v3.7.1 go.etcd.io/etcd/client/v3 v3.7.3
go.uber.org/atomic v1.11.0 go.uber.org/atomic v1.12.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.308.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
require ( require (
aead.dev/mem v0.2.0 // indirect aead.dev/mem v0.2.0 // indirect
aead.dev/minisign v0.3.0 // indirect aead.dev/minisign v0.3.0 // indirect
cel.dev/expr v0.25.2 // indirect cel.dev/expr v0.25.3 // indirect
cloud.google.com/go v0.123.0 // indirect cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth v0.24.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.3.0 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.10.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/iam v1.15.0 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect cloud.google.com/go/monitoring v1.32.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/internal v0.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
github.com/VividCortex/ewma v1.2.0 // indirect github.com/VividCortex/ewma v1.2.0 // indirect
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/apache/thrift v0.24.0 // indirect github.com/apache/arrow-go/v18 v18.8.0 // indirect
github.com/apache/thrift v0.25.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect
@@ -145,7 +146,7 @@ require (
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
@@ -156,59 +157,58 @@ require (
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/envoyproxy/go-control-plane/envoy v1.40.0 // indirect
github.com/eapache/queue v1.1.0 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
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
github.com/go-openapi/analysis v0.26.2 // indirect github.com/go-openapi/analysis v1.0.1 // indirect
github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/errors v0.22.9 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect github.com/go-openapi/jsonpointer v1.0.2 // indirect
github.com/go-openapi/jsonreference v1.0.1 // indirect github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/runtime v0.33.1 // indirect github.com/go-openapi/runtime v0.33.3 // indirect
github.com/go-openapi/runtime/server-middleware v0.33.1 // indirect github.com/go-openapi/runtime/server-middleware v0.33.3 // indirect
github.com/go-openapi/spec v1.0.0 // indirect github.com/go-openapi/spec v1.0.1 // indirect
github.com/go-openapi/strfmt v0.27.2 // indirect github.com/go-openapi/strfmt v0.27.4 // indirect
github.com/go-openapi/swag v0.29.1 // indirect github.com/go-openapi/swag v0.29.2 // indirect
github.com/go-openapi/swag/cmdutils v0.29.1 // indirect github.com/go-openapi/swag/cmdutils v0.29.2 // indirect
github.com/go-openapi/swag/conv v0.29.1 // indirect github.com/go-openapi/swag/conv v0.29.2 // indirect
github.com/go-openapi/swag/fileutils v0.29.1 // indirect github.com/go-openapi/swag/fileutils v0.29.2 // indirect
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
github.com/go-openapi/swag/loading v0.29.1 // indirect github.com/go-openapi/swag/loading v0.29.2 // indirect
github.com/go-openapi/swag/mangling v0.29.1 // indirect github.com/go-openapi/swag/mangling v0.29.2 // indirect
github.com/go-openapi/swag/netutils v0.29.1 // indirect github.com/go-openapi/swag/netutils v0.29.2 // indirect
github.com/go-openapi/swag/pools v0.29.1 // indirect github.com/go-openapi/swag/pools v0.29.2 // indirect
github.com/go-openapi/swag/stringutils v0.29.1 // indirect github.com/go-openapi/swag/stringutils v0.29.2 // indirect
github.com/go-openapi/swag/typeutils v0.29.1 // indirect github.com/go-openapi/swag/typeutils v0.29.2 // indirect
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
github.com/go-openapi/validate v0.26.5 // indirect github.com/go-openapi/validate v1.0.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-json v0.11.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect github.com/golang/snappy v1.0.0 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/go-tpm v0.9.8 // indirect github.com/google/go-tpm v0.9.8 // indirect
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.11 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.24 // indirect
github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/googleapis/gax-go/v2 v2.27.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-uuid v1.0.4 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gofork v1.7.6 // indirect
@@ -225,15 +225,15 @@ require (
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect
github.com/lestrrat-go/jwx/v3 v3.3.0 // indirect github.com/lestrrat-go/jwx/v3 v3.4.0 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.16 // indirect
github.com/mattn/go-ieproxy v0.0.12 // indirect github.com/mattn/go-ieproxy v0.0.12 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.29 // indirect github.com/mattn/go-runewidth v0.0.31 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/minio/colorjson v1.0.8 // indirect github.com/minio/colorjson v1.0.8 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect
@@ -242,7 +242,7 @@ require (
github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect
// Legacy minio/colorjson and minio/dperf code still imports pkg/console. // Legacy minio/colorjson and minio/dperf code still imports pkg/console.
// SILO source imports github.com/pgsty/silo-pkg/v3 directly instead. // SILO source imports github.com/pgsty/silo-pkg/v3 directly instead.
github.com/minio/pkg/v3 v3.6.1 // indirect github.com/minio/pkg/v3 v3.11.0 // indirect
github.com/minio/websocket v1.6.0 // indirect github.com/minio/websocket v1.6.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
@@ -251,56 +251,62 @@ require (
github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/nats-streaming-server v0.24.6 // indirect github.com/nats-io/nats-streaming-server v0.25.6 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/posener/complete v1.2.3 // indirect github.com/posener/complete v1.2.3 // indirect
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect
github.com/prometheus/prom2json v1.5.0 // indirect github.com/prometheus/prom2json v1.5.0 // indirect
github.com/prometheus/prometheus v0.314.0 // indirect github.com/prometheus/prometheus v0.315.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rjeczalik/notify v0.9.3 // indirect github.com/rjeczalik/notify v0.9.3 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/safchain/ethtool v0.7.0 // indirect github.com/safchain/ethtool v0.7.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/shoenig/go-m1cpu v0.2.2 // indirect github.com/shoenig/go-m1cpu v0.2.4 // indirect
github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect github.com/spiffe/go-spiffe/v2 v2.9.0 // indirect
github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/gjson v1.20.0 // indirect
github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.2 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect
github.com/unrolled/secure v1.17.0 // indirect github.com/unrolled/secure v1.17.0 // indirect
github.com/valyala/fastjson v1.6.10 // indirect github.com/valyala/fastjson v1.6.10 // indirect
github.com/vbauerster/cupwriter v0.0.4 // indirect github.com/vbauerster/cupwriter v0.0.5 // indirect
github.com/vbauerster/mpb/v8 v8.16.1 // indirect github.com/vbauerster/mpb/v8 v8.16.3 // indirect
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.2.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.3 // 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.47.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.72.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect go.opentelemetry.io/otel v1.47.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect
go.opentelemetry.io/otel/sdk v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.47.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect go.opentelemetry.io/otel/sdk v1.47.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.47.0 // indirect
go.opentelemetry.io/otel/trace v1.47.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/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect
golang.org/x/net v0.60.0 // indirect golang.org/x/mod v0.42.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/net v0.61.0 // indirect
golang.org/x/tools v0.50.0 // indirect golang.org/x/text v0.43.0 // indirect
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect golang.org/x/tools v0.52.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b // indirect
google.golang.org/grpc v1.83.2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect
google.golang.org/grpc v1.84.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/ini.v1 v1.67.3 // indirect
) )
// v0.0.31 changes existing CJK and combining-character table widths.
// Keep the verified width behavior even when mpb requires the newer version.
replace github.com/mattn/go-runewidth => github.com/mattn/go-runewidth v0.0.29
+271 -335
View File
@@ -3,62 +3,61 @@ aead.dev/mem v0.2.0/go.mod h1:4qj+sh8fjDhlvne9gm/ZaMRIX9EkmDrKOLwmyDtoMWM=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA= aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y= aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
aead.dev/mtls v0.3.0 h1:a+C0t15Y9SRX6qP1EqmQFZ4ZSMm88TPvNDymasu4ahQ= aead.dev/mtls v0.4.0 h1:s+nuIKH0ZZIZ1RsfOjYVx0q2OcfKFKS3UaJGngrTLFY=
aead.dev/mtls v0.3.0/go.mod h1:rZvRApIcPkCNu2AgpFoaMxKBee/XVkKs7wEuYgqLI3Q= aead.dev/mtls v0.4.0/go.mod h1:ZuSTM2+kagQsjaWoHn7yTU36MAaEaH/5Aq5XR4uYMk4=
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.24.1 h1:NTmd2iJb4DsGIav0KiqFMYI88nMshA2wnFMBatRmoCs=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth v0.24.1/go.mod h1:1dmBXa9xfBvBr39ZnDUDkaaA91ccq9CcqENDgJA4d7c=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.15.0 h1:U/wqjfrM0ceBFdPlOI2+cu68yPAxh72M8fe9uLG0+ZE=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/iam v1.15.0/go.mod h1:HAVf7PNhTrNTrEc6fVB21l8ejkbzNTulJHYf7QtD424=
cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= cloud.google.com/go/logging v1.20.0 h1:l7yzCB8/JSY5PHT9RsaqdT1jaBGbM6KlSJDX210HlXM=
cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= cloud.google.com/go/logging v1.20.0/go.mod h1:ALUaJQZM691Eeujy9kFx0xREq3AMPuGd+pTRZuTYv+w=
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v1.3.0 h1:lZ6c8SJJBmILp/Wnr3vGnBLa0q+cYfSHRCcUfVI2hpI=
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/longrunning v1.3.0/go.mod h1:wpgLxCn+UA3/TZ1bj/fW39mfDpwVlMPu1xKJyoMt7V0=
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.32.0 h1:0bXxoh3o751jZnKDHgUDajaG8ZtqtKPMxhVmohRHI0Q=
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/monitoring v1.32.0/go.mod h1:FxmFKr7SUhb60LDuYvjUc6bk9i1+LyXaENR5qAEibrM=
cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= cloud.google.com/go/storage v1.70.0 h1:LQNHn0rRF94jVFfF3e416IzdQqIufa5GfF6HilQxb80=
cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= cloud.google.com/go/storage v1.70.0/go.mod h1:W84cNa070TZIegvTW67CBY+f5rxux8EJMb7YyiHdrls=
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.15.0 h1:w3gDUJlJ0BfBsHH02DCBDwbkmMSXJqBj6nBOCo9iocY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.15.0/go.mod h1:7scl8mG/IFLVjJ9ef/ltdND2T1z0CkgVSaQoUxLvmeU=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 h1:Terj1CMW4+NY5CpWjSRkqLoxh2DkYDdv/FH2z0897Uc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0/go.mod h1:7Ut2Qw9vcwpLW5Q4MBy43uSxXS7tK2yjs94OJ6j5thE=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.2 h1:tQJdgQjbKKffEiMOKJmcQPkY01eFHRqWIZVefqP44nI=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.2/go.mod h1:kTkpJchXAdO7LGkKZP97a+Ou8TgaF4NT6MHwOIy5uSQ=
github.com/Azure/azure-sdk-for-go/sdk/storage/internal v0.1.0 h1:X/bTwe0qpvmvPPutc+LWo7T4cKMqDP9JHP4k+Co9ZB4=
github.com/Azure/azure-sdk-for-go/sdk/storage/internal v0.1.0/go.mod h1:+kX7ahyFelQ42F3RScM2CFgHJljp0phc/FieKLYVpFI=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1 h1:0O6j18nQDoIff6/mtosT5LNU2WehEwp+LqfX6699oqY=
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1/go.mod h1:xdYAf5bjkOpsd3auA8riiJW4vneBubt9caavL7626jQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 h1:5y2LR2pripbccOte6BpZ+guv5O5W0OQIfKvUYZrGaAQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0/go.mod h1:b3f7EifFRqkVt142USoGIP62uUeZm7ALCPrs1ZjWXYo=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0 h1:tRsXhJnn0w0TNwJpVrB94wsrimzjWKFzdQlUhRdNUzc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0/go.mod h1:ifhe5teRNcbxg9p353DhOkOgczzL/IBLYFqT1cm1zpM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 h1:aPMa5qZU9dp4fU34o+jQgVIPFIOi20oxYg8TLigmZLg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0/go.mod h1:sg6Wqbl0BKKZ5zKnQj2q0qic/3ziF894SCx1LpbX37Y=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/IBM/sarama v1.61.1 h1:I59MWPHQUWqJNdRpsDUcbeCriog8SxjQaPfHNWxidEg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/IBM/sarama v1.61.1/go.mod h1:dITlGHIiCQL/maGtBfDHNMDvyWgC9Ww//8pmlsU3RUs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/IBM/sarama v1.45.1 h1:nY30XqYpqyXOXSNoe2XCgjj9jklGM1Ye94ierUb1jQ0=
github.com/IBM/sarama v1.45.1/go.mod h1:qifDhA3VWSrQ1TjSMyxDl3nYL3oX2C83u+G6L79sq4w=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
@@ -68,27 +67,29 @@ github.com/alecthomas/participle v0.7.1/go.mod h1:HfdmEuwvr12HXQN44HPWXR0lHmVolV
github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE= github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/apache/arrow-go/v18 v18.8.0 h1:BLOzbPv7bxMPgXPacAg6HQjnxupYsZzC4tf+FkqPU/M=
github.com/apache/arrow-go/v18 v18.8.0/go.mod h1:uJCFfCwq0KsxCmsCfQg4ft+LsW+iHYzAXiSDh5ug/8U=
github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
github.com/apache/thrift v0.24.0 h1:zy31L1a49QTNB2bG1BBfMXol3yJrTH975G3pPubQVLQ= github.com/apache/thrift v0.25.0 h1:6LLlvoPjy8Cc/0b5TYRcTR3RZa2W0at3WQRcOkDwVGs=
github.com/apache/thrift v0.24.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= github.com/apache/thrift v0.25.0/go.mod h1:4lwyToijD9T70w7GlevFk7FnpY60Wv8I8krMkF6FkAo=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg=
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/beevik/ntp v1.5.0 h1:y+uj/JjNwlY2JahivxYvtmv4ehfi3h74fAuABB9ZSM4= github.com/beevik/ntp v1.6.0 h1:bW359Vvw6bicGhKsVi+ZDLMsMYzb2ohUpLaF/Juv0k8=
github.com/beevik/ntp v1.5.0/go.mod h1:mJEhBrwT76w9D+IfOEGvuzyuudiW9E52U2BaTrMOYow= github.com/beevik/ntp v1.6.0/go.mod h1:VTAbDFySkuc8cQiAYHhXslAHjJdenICebskhYqBWC4k=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.6.1 h1:I0phFv0PlbLHnM7TZAVjZ2MJ2/eWRTDyuO7GLR98IEs=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/buger/jsonparser v1.6.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
@@ -101,8 +102,8 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
@@ -117,10 +118,9 @@ github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moA
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
@@ -151,25 +151,20 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA=
github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho=
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws=
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0=
github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/elastic/go-elasticsearch/v7 v7.17.10 h1:TCQ8i4PmIJuBunvBS6bwT2ybzVFxxUhhltAs3Gyu1yo= github.com/elastic/go-elasticsearch/v7 v7.17.10 h1:TCQ8i4PmIJuBunvBS6bwT2ybzVFxxUhhltAs3Gyu1yo=
github.com/elastic/go-elasticsearch/v7 v7.17.10/go.mod h1:OJ4wdbtDNk5g503kvlHLyErCgQwwzmDtaFC4XyOxXA4= github.com/elastic/go-elasticsearch/v7 v7.17.10/go.mod h1:OJ4wdbtDNk5g503kvlHLyErCgQwwzmDtaFC4XyOxXA4=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.40.0 h1:G4dcl8KE/HVPnZ25BBdvGZWi8JjyeBfQSvm95U5P1Qc=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/envoy v1.40.0/go.mod h1:UZVGjwWu2QV8PkGSlXmqWiSgqCGZe16TgIu4jDl3T5w=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
@@ -179,17 +174,15 @@ github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/fraugster/parquet-go v0.12.0 h1:1slnC5y2VWEOUSlzbeXatM0BvSWcLUDsR/EcZsXXCZc= github.com/fraugster/parquet-go v0.12.0 h1:1slnC5y2VWEOUSlzbeXatM0BvSWcLUDsR/EcZsXXCZc=
github.com/fraugster/parquet-go v0.12.0/go.mod h1:dGzUxdNqXsAijatByVgbAWVPlFirnhknQbdazcUIjY0= github.com/fraugster/parquet-go v0.12.0/go.mod h1:dGzUxdNqXsAijatByVgbAWVPlFirnhknQbdazcUIjY0=
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=
@@ -198,59 +191,58 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/analysis v0.26.2 h1:Q6wOwXW8mcVAkpDFMshj/F4PlK2Fx86tmLJjZW4vyEs= github.com/go-openapi/analysis v1.0.1 h1:JTEyujgl4GyFj8pbMUY2yZx5ak1Ozt/DaqzQjQBnBWM=
github.com/go-openapi/analysis v0.26.2/go.mod h1:KAxGydTYCbs6dK9zjCMQ1AoEVD2RzSWEQNgmb64dRpI= github.com/go-openapi/analysis v1.0.1/go.mod h1:f3gYbXmnXSQIZB02DqE4f8JX969PdOEnaY7gDBLeJjI=
github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= github.com/go-openapi/errors v0.22.9 h1:HI9+SyVYiRzyeBQGv0CbWbQa+u2S0nny7G7AhY+UMiM=
github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= github.com/go-openapi/errors v0.22.9/go.mod h1:R73q66sXP2smzAGgFOI4nqtusRK2xN5pYCjsij+KX58=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8=
github.com/go-openapi/jsonreference v1.0.1 h1:4zJ7AmYDKNmD3aSpfPnFNCFA5E80/xMHUNKgydaLh38= github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ=
github.com/go-openapi/jsonreference v1.0.1/go.mod h1:dYplQXa6p5lXprLcJ8LE2iU7vNpXsAHDQ5ZAgL+Qx3A= github.com/go-openapi/jsonreference v1.0.3/go.mod h1:OJSlMXHysgH272D9z2tiy2MGe5A9vQLHaCPb6KbYdE4=
github.com/go-openapi/loads v0.25.2 h1:+uNsDlRQfYtZTrh+3pdwampcAqZVPuBJW0IA82aZHII= github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34P0=
github.com/go-openapi/loads v0.25.2/go.mod h1:RXsfJEQGGNv4uw8u/KdmF8Vh8OR4fTfs9ZO4QrzNohA= github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo=
github.com/go-openapi/runtime v0.33.1 h1:jCvhI+wAdsn29byy+RgcPcg+j39YT6E304QOE/WqIVk= github.com/go-openapi/runtime v0.33.3 h1:qVgTDaFH+pzO8UqlwYNVmx2DD+nwVK3rB65V29ednD0=
github.com/go-openapi/runtime v0.33.1/go.mod h1:Dl5SMVRnJz+d8bX6Y1zxy0QKpqe/ysvVeUEh1nCpEZ4= github.com/go-openapi/runtime v0.33.3/go.mod h1:57crQFXXnCtT68LuAd+NOELX5tSF4ZE1GFW3Z+r/KCk=
github.com/go-openapi/runtime/server-middleware v0.33.1 h1:IAeKbwWnBnpsYTpuPVS8t73ZrPpKvRZnK2iJ2KJGUV0= github.com/go-openapi/runtime/server-middleware v0.33.3 h1:Td5UBla/J/KCjlsD0tmZYWQWxnT4gHpfXbueLQv9Umw=
github.com/go-openapi/runtime/server-middleware v0.33.1/go.mod h1:2Gej5fDxqeJxY+w38vxXYW0BgFASfgBsJ5rXwN1Fseg= github.com/go-openapi/runtime/server-middleware v0.33.3/go.mod h1:dopwrwqIqXe4xWtovDZkkXJzYqKw+Ybmy7G/hIDUWKY=
github.com/go-openapi/spec v1.0.0 h1:JtB/GHOj+eetjse6YvxqLze88oEekl/4uPBethvzRrA= github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk=
github.com/go-openapi/spec v1.0.0/go.mod h1:boj1PRhqS0x5jylgcNp9BRWxenphrh7vVNYZ90IRCoo= github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8=
github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= github.com/go-openapi/strfmt v0.27.4 h1:E1xsvHb8R9vsuYru4TP2PVVTv9AIgSFAUACUZrFQfCI=
github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E= github.com/go-openapi/strfmt v0.27.4/go.mod h1:SCOff81PPhU26acBiW96jHaFycEbT2nkQRvojM+V2z0=
github.com/go-openapi/swag v0.29.1 h1:C6EeWzUwQtcWEhE9eqBdUubGXxhWY4PlzHMLD7kLaiQ= github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4=
github.com/go-openapi/swag v0.29.1/go.mod h1:BzxEXKiPlSXRsRTv1KSBF/BpGKHxA/YciCnr4tv9bvA= github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo=
github.com/go-openapi/swag/cmdutils v0.29.1 h1:3DorPGfUdE80BogKY22EzoHBcHMrkVomZMoV7kS4ANY= github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro=
github.com/go-openapi/swag/cmdutils v0.29.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw=
github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA= github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0=
github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM= github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q=
github.com/go-openapi/swag/fileutils v0.29.1 h1:ZcPzMceVhU1WPbK6N1G6sNQKdd1CWJlf3cA08UHuoM0= github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI=
github.com/go-openapi/swag/fileutils v0.29.1/go.mod h1:/wofKYckbtRl2p3+EwQsosie5CT1B38+dQ+PS579BzI= github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0=
github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c= github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k=
github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM= github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o= github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg= github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM=
github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc= github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY=
github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw= github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI=
github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I= github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog=
github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s= github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8=
github.com/go-openapi/swag/netutils v0.29.1 h1:IjIvdEP5duKcghFqJEPSUraRnkKYHoM65kTluTu+Jb4= github.com/go-openapi/swag/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8=
github.com/go-openapi/swag/netutils v0.29.1/go.mod h1:DUde7x4Bx00k5jYl2AdRpNAO0m7atUvD2x6X+bWkbno= github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE=
github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg= github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c=
github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc= github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc=
github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0= github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk=
github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc= github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI=
github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M= github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I=
github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE=
github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk= github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM=
github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA= github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs= github.com/go-openapi/testify/enable/yaml/v2 v2.8.0 h1:dvOQNZ2ovOdogn902NYq7i6jUI9Dma4qyjTXABY5YTY=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= github.com/go-openapi/testify/enable/yaml/v2 v2.8.0/go.mod h1:3VwZZUT6nWcAjhIr2RypXxlsxd3mPPtXh3T8z65aRlQ=
github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8= github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho=
github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak=
github.com/go-openapi/validate v0.26.5 h1:Vm02dSmhevDx/4v4m8KAtMwffHGfq9wRLqICeebE/D4= github.com/go-openapi/validate v1.0.1 h1:Z337eNvBb5KhZiKtjBKfpk2ZHPI8otENKyp/I60XVos=
github.com/go-openapi/validate v0.26.5/go.mod h1:2VVi2kpxSvInv/OOI6echOcYddydjXOxJvkHQ5VsoZw= github.com/go-openapi/validate v1.0.1/go.mod h1:2ZKOLz7jBYipFCn3IDGwppZwtbUHlQTMuKfSWTO7Nqk=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
@@ -260,8 +252,8 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
@@ -270,12 +262,6 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -284,10 +270,8 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -296,54 +280,47 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.11 h1:7v1V1TraNGP63WptsAkNqr2w8jgm4vM6A6qx6Lsw9iM=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/s2a-go v0.1.11/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.18 h1:hvVi34VucdrV1IIsiWuqYM8kutw/92MxNEFxCJZEh0k= github.com/googleapis/enterprise-certificate-proxy v0.3.24 h1:G9iCJIY+AYVmUskKdjX8FVy7sG+nbzjOQ41cvlmhcvg=
github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= github.com/googleapis/enterprise-certificate-proxy v0.3.24/go.mod h1:hEjCmnoakQzv5ImLUp6alkY7XgLlNoAxwTXmhHHNIsI=
github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= github.com/googleapis/gax-go/v2 v2.27.0 h1:qe2iQOUzxb3fd6x6ewpIEixPQhJ+0Ihc/RRZhKA/2g0=
github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/googleapis/gax-go/v2 v2.27.0/go.mod h1:RofiqDw3ehcG4L0H1zO3D2Itpwd3/uS/7wdmbNDF1DE=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM= github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM=
github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 h1:QMILzF8BA7xftE4zVORkRrlv08m12St3csK4RsyRgRs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0/go.mod h1:+ZQ6iUL8+ATyxGDQNuRKpNwJFdGDUi0e7E1QL9MvI7U=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-hclog v0.9.1/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v1.1.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-msgpack/v2 v2.1.1 h1:xQEY9yB2wnHitoSzk/B9UjXWRQ67QKu5AOm8aFp8N3I=
github.com/hashicorp/go-msgpack v1.1.5 h1:9byZdVjKTe5mce63pRVNP1L7UAmdHOTEMGehn6KvJWs= github.com/hashicorp/go-msgpack/v2 v2.1.1/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4=
github.com/hashicorp/go-msgpack v1.1.5/go.mod h1:gWVc3sv/wbDmR3rQsj1CAktEZzoz1YNK9NfGLXJ69/4=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/go-uuid v1.0.4 h1:ZrN80XjMzpRYk+2FxMDy2A2zz0d5QjJ7GMFSkZLj12A=
github.com/hashicorp/go-uuid v1.0.4/go.mod h1:x2Ds7vSkQ2n/yQj8Synnxmt0zt1l26uCAjxIhChisLU=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/raft v1.3.9 h1:9yuo1aR0bFTr1cw7pj3S2Bk6MhJCsnr2NAxvIBrP2x4= github.com/hashicorp/raft v1.6.0 h1:tkIAORZy2GbJ2Trp5eUSggLXDPOJLXC+JJLNMMqtgtM=
github.com/hashicorp/raft v1.3.9/go.mod h1:4Ak7FSPnuvmb0GV6vgIAJ4vYT4bek9bb6Q+7HVbyzqM= github.com/hashicorp/raft v1.6.0/go.mod h1:Xil5pDgeGwRWuX4uPUmwa+7Vagg4N804dz6mhNi6S7o=
github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -373,22 +350,21 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
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.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.0/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=
github.com/klauspost/filepathx v1.1.1/go.mod h1:XWxdp8rEw4gupPBrxrV5Q57dL/71xj0OgV1gKt2zTfU= github.com/klauspost/filepathx v1.1.1/go.mod h1:XWxdp8rEw4gupPBrxrV5Q57dL/71xj0OgV1gKt2zTfU=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.3.0 h1:8t+KjjzD/0n/8vNlJGGZmTeK4njuwffOQvJm8tOSc8o=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.3.0/go.mod h1:Xx7tAv5DH8ObLHUDQKXoE/nd8i/FYdNDTVMvi3SLTKI=
github.com/klauspost/readahead v1.4.0 h1:w4hQ3BpdLjBnRQkZyNi+nwdHU7eGP9buTexWK9lU7gY= github.com/klauspost/readahead v1.4.0 h1:w4hQ3BpdLjBnRQkZyNi+nwdHU7eGP9buTexWK9lU7gY=
github.com/klauspost/readahead v1.4.0/go.mod h1:7bolpMKhT5LKskLwYXGSDOyA2TYtMFgdgV0Y8gy7QhA= github.com/klauspost/readahead v1.4.0/go.mod h1:7bolpMKhT5LKskLwYXGSDOyA2TYtMFgdgV0Y8gy7QhA=
github.com/klauspost/reedsolomon v1.13.3 h1:01GwnO2xoCSaM0ShP4qwl+FsHg3csFShC6Tu/RS1ji0= github.com/klauspost/reedsolomon v1.14.2 h1:SafJYwpBBQBI6amHUygcjxZjXeN2HpiENHQDwuPWCCQ=
github.com/klauspost/reedsolomon v1.13.3/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ= github.com/klauspost/reedsolomon v1.14.2/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -411,15 +387,14 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4= github.com/lestrrat-go/jwx/v3 v3.4.0 h1:2YSO4sfAj+M90XUuEJZsMibRNpoh1KXP1iL+C/tFebg=
github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8= github.com/lestrrat-go/jwx/v3 v3.4.0/go.mod h1:jrZZIM3dgHB780ZrD/3H+ARR5ou4w3X3wFjWE+JWGMU=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v4 v4.3.0 h1:XRr80OYPOlqxgnCv2/iuElkl/ZtXtPtLKk2AvpXGdWA=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c= github.com/lithammer/shortuuid/v4 v4.3.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
@@ -427,24 +402,18 @@ github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIv
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-ieproxy v0.0.12 h1:OZkUFJC3ESNZPQ+6LzC3VJIFSnreeFLQyqvBWtvfL2M= github.com/mattn/go-ieproxy v0.0.12 h1:OZkUFJC3ESNZPQ+6LzC3VJIFSnreeFLQyqvBWtvfL2M=
github.com/mattn/go-ieproxy v0.0.12/go.mod h1:Vn+N61199DAnVeTgaF8eoB9PvLO8P3OBnG95ENh7B7c= github.com/mattn/go-ieproxy v0.0.12/go.mod h1:Vn+N61199DAnVeTgaF8eoB9PvLO8P3OBnG95ENh7B7c=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ= github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
@@ -463,29 +432,28 @@ github.com/minio/dperf v0.7.1 h1:eBwtaBBjuANwgUy1waWoS+wP+0i5fkXJOdGU2RXuDxo=
github.com/minio/dperf v0.7.1/go.mod h1:hH1b9YthgQFNg9X36a8/rqTkntQBhqLgv9eZMBKxa6M= github.com/minio/dperf v0.7.1/go.mod h1:hH1b9YthgQFNg9X36a8/rqTkntQBhqLgv9eZMBKxa6M=
github.com/minio/filepath v1.0.0 h1:fvkJu1+6X+ECRA6G3+JJETj4QeAYO9sV43I79H8ubDY= github.com/minio/filepath v1.0.0 h1:fvkJu1+6X+ECRA6G3+JJETj4QeAYO9sV43I79H8ubDY=
github.com/minio/filepath v1.0.0/go.mod h1:/nRZA2ldl5z6jT9/KQuvZcQlxZIMQoFFQPvEXx9T/Bw= github.com/minio/filepath v1.0.0/go.mod h1:/nRZA2ldl5z6jT9/KQuvZcQlxZIMQoFFQPvEXx9T/Bw=
github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/minio/kms-go/kes v0.3.1 h1:K3sPFAvFbJx33XlCTUBnQo8JRmSZyDvT6T2/MQ2iC3A= github.com/minio/kms-go/kes v0.3.1 h1:K3sPFAvFbJx33XlCTUBnQo8JRmSZyDvT6T2/MQ2iC3A=
github.com/minio/kms-go/kes v0.3.1/go.mod h1:Q9Ct0KUAuN9dH0hSVa0eva45Jg99cahbZpPxeqR9rOQ= github.com/minio/kms-go/kes v0.3.1/go.mod h1:Q9Ct0KUAuN9dH0hSVa0eva45Jg99cahbZpPxeqR9rOQ=
github.com/minio/kms-go/kms v0.6.0 h1:oGdGUyjfCZwRIi7em0aj4wk+oOm7+4a0lzSZny7ZIDU= github.com/minio/kms-go/kms v0.7.0 h1:vxkStC2gJUHvrooDn0Ep8mPP8bWkiZcAtSUWo6boQ5o=
github.com/minio/kms-go/kms v0.6.0/go.mod h1:me+0DQoVzfERHCBrGLLnhq1NCqj7a+BNWcnigSLPnpQ= github.com/minio/kms-go/kms v0.7.0/go.mod h1:d5gRRxBOA6Nl/ded81AuraypXYRTYPY9b0Ti9RVuCaQ=
github.com/minio/madmin-go/v3 v3.0.110 h1:FIYekj7YPc430ffpXFWiUtyut3qBt/unIAcDzJn9H5M= github.com/minio/madmin-go/v3 v3.0.110 h1:FIYekj7YPc430ffpXFWiUtyut3qBt/unIAcDzJn9H5M=
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.2-0.20261020194015-ab93db7ba435 h1:BIn1BJi2zZ+z5BIMsr02USFB+Wmipc4SrIAA8nksNH0=
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.2-0.20261020194015-ab93db7ba435/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.11.0 h1:WjzWGP7lyuupJgUSRim1RDtNujHtESyMQF7qSR4r5Dg=
github.com/minio/pkg/v3 v3.6.1/go.mod h1:fYlexVD0GMD0XNeBHeefFI6YBE0Oo8oDbDPWm3Jd68I= github.com/minio/pkg/v3 v3.11.0/go.mod h1:gBNHbob7QUCu/5nOtkAEEpN6diu1o1lwFpEnzU35s9U=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/minio/simdjson-go v0.4.5 h1:r4IQwjRGmWCQ2VeMc7fGiilu1z5du0gJ/I/FsKwgo5A= github.com/minio/simdjson-go v0.4.5 h1:r4IQwjRGmWCQ2VeMc7fGiilu1z5du0gJ/I/FsKwgo5A=
github.com/minio/simdjson-go v0.4.5/go.mod h1:eoNz0DcLQRyEDeaPr4Ru6JpjlZPzbA0IodxVJk8lO8E= github.com/minio/simdjson-go v0.4.5/go.mod h1:eoNz0DcLQRyEDeaPr4Ru6JpjlZPzbA0IodxVJk8lO8E=
github.com/minio/sio v0.4.3 h1:JqyID1XM86KwBZox5RAdLD4MLPIDoCY2cke2CXCJCkg= github.com/minio/sio v0.5.1 h1:sqtImrnCSHbDqO/lVy3tfbsctHzfelDv3NbXWEVcWT8=
github.com/minio/sio v0.4.3/go.mod h1:4ANoe4CCXqnt1FCiLM0+vlBUhhWZzVOhYCz0069KtFc= github.com/minio/sio v0.5.1/go.mod h1:4ANoe4CCXqnt1FCiLM0+vlBUhhWZzVOhYCz0069KtFc=
github.com/minio/websocket v1.6.0 h1:CPvnQvNvlVaQmvw5gtJNyYQhg4+xRmrPNhBbv8BdpAE= github.com/minio/websocket v1.6.0 h1:CPvnQvNvlVaQmvw5gtJNyYQhg4+xRmrPNhBbv8BdpAE=
github.com/minio/websocket v1.6.0/go.mod h1:COH1CePZfHT9Ec1O7vZjTlX5uEPpyYnrifPNbu665DM= github.com/minio/websocket v1.6.0/go.mod h1:COH1CePZfHT9Ec1O7vZjTlX5uEPpyYnrifPNbu665DM=
github.com/minio/xxml v0.0.3 h1:ZIpPQpfyG5uZQnqqC0LZuWtPk/WT8G/qkxvO6jb7zMU= github.com/minio/xxml v0.0.3 h1:ZIpPQpfyG5uZQnqqC0LZuWtPk/WT8G/qkxvO6jb7zMU=
@@ -511,26 +479,20 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/jwt/v2 v2.2.1-0.20220330180145-442af02fd36a/go.mod h1:0tqz9Hlu6bCBFLWAASKhE5vUA4c24L9KPUUgvwumE/k= github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= github.com/nats-io/nats-server/v2 v2.15.1 h1:fK3AeYFIji1Smng60jB0hVoAPZezA/UzsaI3BqXFNEI=
github.com/nats-io/nats-server/v2 v2.8.2/go.mod h1:vIdpKz3OG+DCg4q/xVPdXHoztEyKDWRtykQ4N7hd7C4= github.com/nats-io/nats-server/v2 v2.15.1/go.mod h1:yeuYRtKVKIFtmMz2/YPk+6hvmNBnk+WwTUzErBdH6IE=
github.com/nats-io/nats-server/v2 v2.11.15 h1:StSf9TINInaZtr4oww2+kXmfwa9SkN//g/LwS19/UJ0= github.com/nats-io/nats-streaming-server v0.25.6 h1:8OBRaIl64u+DFvZYpF50RRzwG/yLcJZL0R7VMc7tp4Y=
github.com/nats-io/nats-server/v2 v2.11.15/go.mod h1:zwhv8Y0PE3KHyKgznJc/9Xoai638SaJd83zzJ5GJn74= github.com/nats-io/nats-streaming-server v0.25.6/go.mod h1:LEcu6uGSDBB4O/IBUsDBHYk/O0K7XZza8nMjCIXicLk=
github.com/nats-io/nats-streaming-server v0.24.6 h1:iIZXuPSznnYkiy0P3L0AP9zEN9Etp+tITbbX1KKeq4Q=
github.com/nats-io/nats-streaming-server v0.24.6/go.mod h1:tdKXltY3XLeBJ21sHiZiaPl+j8sK3vcCKBWVyxeQs10=
github.com/nats-io/nats.go v1.13.0/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w=
github.com/nats-io/nats.go v1.14.0/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w=
github.com/nats-io/nats.go v1.15.0/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w=
github.com/nats-io/nats.go v1.22.1/go.mod h1:tLqubohF7t4z3du1QDPYJIQQyhb4wl6DhjxEajSI7UA= github.com/nats-io/nats.go v1.22.1/go.mod h1:tLqubohF7t4z3du1QDPYJIQQyhb4wl6DhjxEajSI7UA=
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= github.com/nats-io/nats.go v1.55.0 h1:Ng8QKHdvdHIWoaiq0+d/aH1BWkxvcJxWJ0Y/9BPKx3M=
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= github.com/nats-io/nats.go v1.55.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/nats-io/stan.go v0.10.2/go.mod h1:vo2ax8K2IxaR3JtEMLZRFKIdoK/3o1/PKueapB7ezX0=
github.com/nats-io/stan.go v0.10.4 h1:19GS/eD1SeQJaVkeM9EkvEYattnvnWrZ3wkSWSw4uXw= github.com/nats-io/stan.go v0.10.4 h1:19GS/eD1SeQJaVkeM9EkvEYattnvnWrZ3wkSWSw4uXw=
github.com/nats-io/stan.go v0.10.4/go.mod h1:3XJXH8GagrGqajoO/9+HgPyKV5MWsv7S5ccdda+pc6k= github.com/nats-io/stan.go v0.10.4/go.mod h1:3XJXH8GagrGqajoO/9+HgPyKV5MWsv7S5ccdda+pc6k=
github.com/ncw/directio v1.0.5 h1:JSUBhdjEvVaJvOoyPAbcW0fnd0tvRXD76wEfZ1KcQz4= github.com/ncw/directio v1.0.5 h1:JSUBhdjEvVaJvOoyPAbcW0fnd0tvRXD76wEfZ1KcQz4=
@@ -542,26 +504,24 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
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-20261025104502-dcc78d506f94 h1:M5n0rCPOTyo3RiQBeSCQW/klx08efFpiVYmPq6eoM3w=
github.com/pgsty/mc v0.0.0-20260916070421-e952aa78f10a/go.mod h1:6z9lX8kOvaWz2vLlP7BjjF3JWHnIH86XDA2HwcmpZH8= github.com/pgsty/mc v0.0.0-20261025104502-dcc78d506f94/go.mod h1:H3Uwlm1Qj3kPsbmojhNnv3aj5a+QdjdTKwv4w4gMSF8=
github.com/pgsty/silo-console v0.0.0-20261015132217-e5b165e4c0ee h1:e51AnrGe0Zq+nHTYaGfv/GkehQMO9Mekuw/ew6zYwOM= github.com/pgsty/silo-console v0.0.0-20261025140954-258717be077b h1:/RQ+D5TRP8/GGZaY5cbwIixd1RFE0eQjEVLBpIl3+8k=
github.com/pgsty/silo-console v0.0.0-20261015132217-e5b165e4c0ee/go.mod h1:jpt39bx4wjhXoFB+BTOa/bx/pmU3pXzp2cT7PnT21Lo= github.com/pgsty/silo-console v0.0.0-20261025140954-258717be077b/go.mod h1:fMxNcVxan3mu9mc7HCRiczSyQoFrbZEGumEmpatxtds=
github.com/pgsty/silo-pkg/v3 v3.14.1 h1:8MONT3Hky9EOnsuPk29590pEBE6MILaOnJtaejPPi7M= github.com/pgsty/silo-pkg/v3 v3.15.1 h1:T5rqY3HR1WGybcjnx/ghBvliEgo7yNCA7JAC3f1LpTk=
github.com/pgsty/silo-pkg/v3 v3.14.1/go.mod h1:jxKWxi52jSsDaDhXXPqjOyPDYdxx2iqdBXyLfM/xSIw= github.com/pgsty/silo-pkg/v3 v3.15.1/go.mod h1:GmTF6fIEkTrzgM+ysWWfDZIlVRD2DYAJkKjA5bjiWpQ=
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.33 h1:GjG1TJ1V4IzKP8L96muuuDNpTwd7D+l2ccXrjAbe014=
github.com/pierrec/lz4/v4 v4.1.30/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.33/go.mod h1:7SE9MC2STkNtL4PIwGhjmyVwvILaGI9/COYQNBhKM/c=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= github.com/pkg/sftp v1.13.12 h1:Myhzy/ZzLPnCtE2F2WtDHsgs8RfgMnH2gfbHuCJhkMU=
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= github.com/pkg/sftp v1.13.12/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
@@ -569,32 +529,26 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU= github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 h1:XA01Vk/wv9YikCi1V51yRzIHPMT5of9+cMpZoZvDn/M=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.72.1 h1:ZAiQed/nk8IIcsdzb2TlJu23NEUDyDLQ9TXTN2WRK6Y=
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= github.com/prometheus/common v0.72.1/go.mod h1:1IoBY4IRaJnhs2ESjNzjilP1cJ/P8pimGoomq7zRXrA=
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= github.com/prometheus/procfs v0.23.0 h1:ad+S6FTkzwaMgacKLM5h6bNKExS+DMH0YiUwXItH/o4=
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.23.0/go.mod h1:3AVXO0FqLafyYFU/V/IQRZlC8Rgqz8Dm/x6pKLK+RIk=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/prometheus/prom2json v1.5.0 h1:WIcAOjLE1x476W3dUlmTL6E/e98CgVGuwwYusl6MPP8= github.com/prometheus/prom2json v1.5.0 h1:WIcAOjLE1x476W3dUlmTL6E/e98CgVGuwwYusl6MPP8=
github.com/prometheus/prom2json v1.5.0/go.mod h1:xPp6KDhCA30btxmqEfg/K3DAwgTIkp7TJKi4+7jaYd8= github.com/prometheus/prom2json v1.5.0/go.mod h1:xPp6KDhCA30btxmqEfg/K3DAwgTIkp7TJKi4+7jaYd8=
github.com/prometheus/prometheus v0.314.0 h1:YjsimqsIi6/mOtzZcrPEYUALO6zpfaht9O5sXqDz2vg= github.com/prometheus/prometheus v0.315.1 h1:ZXaNKyUO5szJhhw7gKiP6b2jpx1QkS0S3CI1R1bPqIA=
github.com/prometheus/prometheus v0.314.0/go.mod h1:zjg3pMTAkY0/JG8jy/h8/YgSQUVB+aCXMhUqN6l64jg= github.com/prometheus/prometheus v0.315.1/go.mod h1:6ISUXTjRxj9U2d8ssng8khR1b2dDJ+MpftZD190149I=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek= github.com/rabbitmq/amqp091-go v1.16.0 h1:1O8oQqnj9hLACoo6CVMgn57z2pLWXvgemhh4GsquV+Y=
github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rabbitmq/amqp091-go v1.16.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -616,10 +570,10 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.2.2 h1:4nc55oVv7nygGnfI9bhLCLzUEs4794y0Bkqx4q2zy7Y= github.com/shoenig/go-m1cpu v0.2.4 h1:F5esY3x6L2iIxmqHnMZ5afJwXBy573/fwkPgvatExgE=
github.com/shoenig/go-m1cpu v0.2.2/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= github.com/shoenig/go-m1cpu v0.2.4/go.mod h1:38ReYghj5Sqaa2nPBh2aJAHyFSi2vpTQJLM7TFJRxNM=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= github.com/shoenig/test v1.13.2 h1:SaGxHxg7xkRuKuNtuFmHf0LgNGaAgcBT7HN4WHCKfqU=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/shoenig/test v1.13.2/go.mod h1:MKmiRyEeuFl8y9PCoThaRDgYQZeWBhRQlH99poXz5LI=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
@@ -627,8 +581,8 @@ github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb6
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= github.com/spiffe/go-spiffe/v2 v2.9.0 h1:g5qTg3qIP1HXV+GzuyEfSJFVxUCpEP5Ui1OINVJHPB4=
github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/spiffe/go-spiffe/v2 v2.9.0/go.mod h1:w2CLWKLMTX/PPYUEUPv3ltH0RXsw5S8suwNF46w9/Aw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -644,19 +598,18 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= 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.2 h1:dz1jrRuE7or/74V490B4/GP1pZm5WKlt2bgCP5A83w8=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.2/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=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
@@ -664,16 +617,16 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vbauerster/cupwriter v0.0.4 h1:9sBPe0uXWLZuWQU5lqVbhyFlxX6c09asST/YfatFAys= github.com/vbauerster/cupwriter v0.0.5 h1:pxlPgxPLIebXh8lJPOZmn2TmjPE8+PqJIFkKkr7hI9I=
github.com/vbauerster/cupwriter v0.0.4/go.mod h1:IFyzS6Xis5dnBH/rdAhrnuzg3c+KkUqEN6yE8lhJlDw= github.com/vbauerster/cupwriter v0.0.5/go.mod h1:ptJDgOtLtGZp1gLzycD0TFnZztDhrQdsikG6EAA4xYQ=
github.com/vbauerster/mpb/v8 v8.16.1 h1:gNYmwMip9xRWNGAiblZOgUNXWeU2P0NIGd5x0f8ffbc= github.com/vbauerster/mpb/v8 v8.16.3 h1:C9AShAmENWKGHRPFU47CGMhVGUkLh38kzK7dcTKcH8g=
github.com/vbauerster/mpb/v8 v8.16.1/go.mod h1:gnU8zNF/JWltFepqwko/ulMEUIDrydIq7T4UdMN26Nw= github.com/vbauerster/mpb/v8 v8.16.3/go.mod h1:S4Cat6c3PMnKEE9NXhyhc02DCn60oAeh1+44gr3ujXU=
github.com/xdg/scram v1.0.5 h1:TuS0RFmt5Is5qm9Tm2SoD89OPqe4IRiFtyFY4iwWXsw= github.com/xdg/scram v1.0.5 h1:TuS0RFmt5Is5qm9Tm2SoD89OPqe4IRiFtyFY4iwWXsw=
github.com/xdg/scram v1.0.5/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/scram v1.0.5/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I=
github.com/xdg/stringprep v1.0.3 h1:cmL5Enob4W83ti/ZHuZLuKD/xqJfus4fVPwE+/BDm+4= github.com/xdg/stringprep v1.0.3 h1:cmL5Enob4W83ti/ZHuZLuKD/xqJfus4fVPwE+/BDm+4=
github.com/xdg/stringprep v1.0.3/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= github.com/xdg/stringprep v1.0.3/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -684,38 +637,40 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= 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.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
go.etcd.io/etcd/api/v3 v3.7.1 h1:KJG0/DcWGfe3Y1otDf/fsBf0TSSgpxZ5RO/L8SFt73E= go.etcd.io/etcd/api/v3 v3.7.3 h1:2tumepXe+wi7Wab64uUO71K7ISQyUWLGC1DJbbc59sY=
go.etcd.io/etcd/api/v3 v3.7.1/go.mod h1:8bXIpCMeV7E3/XL0Ix123ATn3dB+0V7d9zklHbB0m78= go.etcd.io/etcd/api/v3 v3.7.3/go.mod h1:HOR9Qn4gb/1/hVAmYprmlLpvTe6ZgfgNS9Z/+Ty65po=
go.etcd.io/etcd/client/pkg/v3 v3.7.1 h1:rKYsj3pRkR0eK3yjT3XOgrhqfmIfj9pzNgxjh7mfFv4= go.etcd.io/etcd/client/pkg/v3 v3.7.3 h1:5zMwNu3ycrzCeA5NwOfQbxFRhqq974chq1DrYMua4QI=
go.etcd.io/etcd/client/pkg/v3 v3.7.1/go.mod h1:cnzZGIUzSfjEwLC6UBVsSXlEK1eepS/JUD7wE6PLRT0= go.etcd.io/etcd/client/pkg/v3 v3.7.3/go.mod h1:/OhzqYrF0U7d1NOk55VMyUQGbYD1WoIe4bJwxb47wOk=
go.etcd.io/etcd/client/v3 v3.7.1 h1:0PEMMC0KuZmVIN+RAbdqfkZ45pYTgKVtmBEbRCvZFUg= go.etcd.io/etcd/client/v3 v3.7.3 h1:UrXcetgkuSEocaBlQlulQRJlXLEnUAHuBRbHh0lue1Q=
go.etcd.io/etcd/client/v3 v3.7.1/go.mod h1:ffNqALa8tRCYhYo1F9oR489y23K39Gz+BSR3ApAGYq0= go.etcd.io/etcd/client/v3 v3.7.3/go.mod h1:DcPxpqcpV8WIaRi/2waPCx0/Uq8rQUweDJaDYZk1gEE=
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.47.0 h1:Ct6+aaiSO6Nk5SOb6PYcR7iWp+83YF/pL1OJcGqOmbg=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= go.opentelemetry.io/contrib/detectors/gcp v1.47.0/go.mod h1:3N259ss0b1AVOs+fYw7lOO0mcUwNqSIpQ+Zitpuqx00=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0/go.mod h1:A+EVhDakAj1waJBPhmTszZaSr08ww8MRRKy1nB1rqck=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -737,20 +692,17 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP
golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/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-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=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -762,42 +714,34 @@ 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-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/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-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-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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -805,56 +749,48 @@ 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.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=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190424220101-1e8e1cfdf96b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
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=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.290.0 h1:eMw0Xo+IfbbMlKmW7aHvpyQRv9RCXuWx/vs8AD+0x9A= google.golang.org/api v0.308.0 h1:iGb+4qT3nVb3uuIzwor3yP2nyu+rMZr31PvLFiRNJQk=
google.golang.org/api v0.290.0/go.mod h1:weJZ3lldHFYI0DBFNKpJelUDNnusTt5YaOEgxvt8ci8= google.golang.org/api v0.308.0/go.mod h1:4XMH+b/ZEfuTbK7FnrFRY9c4xfFHVflGTKuy/i5wUmE=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b h1:+t2qGqt6R8CO622uiX1JpnRvRvSOQoVRV0DiPzUjKXg=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b/go.mod h1:XRIfc1yr5xsbF8xFw5+g7SEmLJX/eQ7MMazTRbnMMvI=
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs= google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:nB3spRIaSPtxJpxb0k41yljROvm2Jrwfs2pSNdKS5y8=
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8= google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+1 -1
View File
@@ -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.
+7 -4
View File
@@ -48,8 +48,8 @@ func EncryptBytes(k *kms.KMS, plaintext []byte, context kms.Context) ([]byte, er
// DecryptBytes decrypts the ciphertext using a key managed by the KMS. // DecryptBytes decrypts the ciphertext using a key managed by the KMS.
// The same context that have been used during encryption must be // The same context that have been used during encryption must be
// provided. // provided.
func DecryptBytes(k *kms.KMS, ciphertext []byte, context kms.Context) ([]byte, error) { func DecryptBytes(ctx context.Context, k *kms.KMS, ciphertext []byte, associatedData kms.Context) ([]byte, error) {
plaintext, err := Decrypt(k, bytes.NewReader(ciphertext), context) plaintext, err := Decrypt(ctx, k, bytes.NewReader(ciphertext), associatedData)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -115,7 +115,10 @@ func Encrypt(k *kms.KMS, plaintext io.Reader, ctx kms.Context) (io.Reader, error
// Decrypt decrypts the ciphertext using a key managed by the KMS. // Decrypt decrypts the ciphertext using a key managed by the KMS.
// The same context that have been used during encryption must be // The same context that have been used during encryption must be
// provided. // provided.
func Decrypt(k *kms.KMS, ciphertext io.Reader, associatedData kms.Context) (io.Reader, error) { func Decrypt(ctx context.Context, k *kms.KMS, ciphertext io.Reader, associatedData kms.Context) (io.Reader, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
const ( const (
MaxMetadataSize = 1 << 20 // max. size of the metadata MaxMetadataSize = 1 << 20 // max. size of the metadata
Version = 1 Version = 1
@@ -145,7 +148,7 @@ func Decrypt(k *kms.KMS, ciphertext io.Reader, associatedData kms.Context) (io.R
return nil, err return nil, err
} }
key, err := k.Decrypt(context.TODO(), &kms.DecryptRequest{ key, err := k.Decrypt(ctx, &kms.DecryptRequest{
Name: metadata.KeyID, Name: metadata.KeyID,
Ciphertext: metadata.KMSKey, Ciphertext: metadata.KMSKey,
AssociatedData: associatedData, AssociatedData: associatedData,
+2 -1
View File
@@ -19,6 +19,7 @@ package config
import ( import (
"bytes" "bytes"
"context"
"encoding/hex" "encoding/hex"
"io" "io"
"testing" "testing"
@@ -68,7 +69,7 @@ func TestEncryptDecrypt(t *testing.T) {
t.Fatalf("Test %d: failed to encrypt stream: %v", i, err) t.Fatalf("Test %d: failed to encrypt stream: %v", i, err)
} }
plaintext, err := Decrypt(KMS, bytes.NewReader(data), test.Context) plaintext, err := Decrypt(context.Background(), KMS, bytes.NewReader(data), test.Context)
if err != nil { if err != nil {
t.Fatalf("Test %d: failed to decrypt stream: %v", i, err) t.Fatalf("Test %d: failed to decrypt stream: %v", i, err)
} }
+1 -1
View File
@@ -25,7 +25,7 @@ import (
// DARECiphers returns a list of supported cipher suites // DARECiphers returns a list of supported cipher suites
// for the DARE object encryption. // for the DARE object encryption.
func DARECiphers() []byte { return []byte{sio.AES_256_GCM, sio.CHACHA20_POLY1305} } func DARECiphers() []byte { return []byte{sio.AES_GCM, sio.CHACHA20_POLY1305} }
// TLSCiphers returns a list of supported TLS transport // TLSCiphers returns a list of supported TLS transport
// cipher suite IDs. // cipher suite IDs.
+88
View File
@@ -0,0 +1,88 @@
// Copyright (c) 2026 Ruohang Feng
// 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 crypto
import (
"bytes"
"encoding/hex"
"testing"
"github.com/minio/sio"
)
// These sealed keys were generated with minio/sio v0.4.3 before the v0.5 API
// change, using a 32-byte zero external key and IV bytes 32 through 63. They
// provide positive legacy DARE coverage instead of only rejecting bad keys.
func TestUnsealLegacyDAREKeys(t *testing.T) {
for _, test := range []struct{ name, algorithm, ciphertext string }{
{"v1-aes256", InsecureSealAlgorithm, "10001f000000000001020304050607081e469edeb77bba11c91cf3132fc00ab28b8effbaa92784ea004f07a1cf1c9af28b5b725e1e5c96819a94703b10aa5943"},
{"v1-chacha20", InsecureSealAlgorithm, "10011f00000000000102030405060708948d4d3ed99f047010c7c9b54ec2c58d0b437a9e9a7df3c032fcdf99e863b2bda7c9f995a3313f65bff8b941cb689e0d"},
{"v2-aes256", SealAlgorithm, "20001f008102030405060708090a0b0c990455a32f26fd5a3417b4357ef3b92455c13e37947e0fac9e85380daa7b2a3eaffb032630b8b4652f66f01322abb449"},
{"v2-chacha20", SealAlgorithm, "20011f008102030405060708090a0b0c34f030b5744d4ad777d03b1ec5e0771480911ec60cc796b4b1842ee9d3f8aa96a047bd4d8c26896613bca7e0dbefa85c"},
} {
t.Run(test.name, func(t *testing.T) {
raw, err := hex.DecodeString(test.ciphertext)
if err != nil {
t.Fatal(err)
}
sealed := SealedKey{Algorithm: test.algorithm}
copy(sealed.Key[:], raw)
var expected ObjectKey
for i := range expected {
expected[i] = byte(i)
sealed.IV[i] = byte(i + 32)
}
var key ObjectKey
if err := key.Unseal(make([]byte, 32), sealed, "SSE-S3", "bucket", "object"); err != nil {
t.Fatal(err)
}
if key != expected {
t.Fatalf("unsealed key: got %x, want %x", key, expected)
}
sealed.Key[32] ^= 1
if err := key.Unseal(make([]byte, 32), sealed, "SSE-S3", "bucket", "object"); err == nil {
t.Fatal("tampered sealed key was accepted")
}
})
}
}
func TestDARE32ByteKeyRoundTrip(t *testing.T) {
var key ObjectKey
for i := range key {
key[i] = byte(i)
}
// Include both sides of a DARE package boundary and a multi-package payload.
for _, size := range []int{0, 1, 65535, 65536, 65537, 131073} {
plaintext := bytes.Repeat([]byte{0xa5}, size)
for _, cipher := range DARECiphers() {
var ciphertext, decoded bytes.Buffer
if _, err := sio.Encrypt(&ciphertext, bytes.NewReader(plaintext), sio.Config{Key: key[:], CipherSuites: []byte{cipher}}); err != nil {
t.Fatal(err)
}
if size > 0 && ciphertext.Bytes()[0] != sio.Version20 {
t.Fatal("new objects must retain DARE v2 format")
}
if _, err := sio.Decrypt(&decoded, bytes.NewReader(ciphertext.Bytes()), sio.Config{Key: key[:], CipherSuites: DARECiphers()}); err != nil {
t.Fatal(err)
}
if !bytes.Equal(decoded.Bytes(), plaintext) {
t.Fatalf("cipher %d size %d did not round-trip", cipher, size)
}
}
}
}
+4 -1
View File
@@ -130,9 +130,12 @@ func (key *ObjectKey) Unseal(extKey []byte, sealedKey SealedKey, domain, bucket,
unsealConfig = sio.Config{MinVersion: sio.Version10, Key: sha.Sum(nil)} unsealConfig = sio.Config{MinVersion: sio.Version10, Key: sha.Sum(nil)}
} }
if out, err := sio.DecryptBuffer(key[:0], sealedKey.Key[:], unsealConfig); len(out) != 32 || err != nil { out, err := sio.DecryptBuffer(key[:0], sealedKey.Key[:], unsealConfig)
if len(out) != 32 || err != nil {
return ErrSecretKeyMismatch return ErrSecretKeyMismatch
} }
// DARE v1 may allocate a new buffer instead of reusing key's backing array.
copy(key[:], out)
return nil return nil
} }
+14 -2
View File
@@ -72,6 +72,15 @@ func (sses3) IsEncrypted(metadata map[string]string) bool {
// from the metadata using KMS and returns the decrypted object // from the metadata using KMS and returns the decrypted object
// key. // key.
func (s3 sses3) UnsealObjectKey(k *kms.KMS, metadata map[string]string, bucket, object string) (key ObjectKey, err error) { func (s3 sses3) UnsealObjectKey(k *kms.KMS, metadata map[string]string, bucket, object string) (key ObjectKey, err error) {
return s3.unsealObjectKey(context.Background(), k, metadata, bucket, object)
}
// unsealObjectKey carries the listing request's cancellation through the KMS.
// Keep the existing single-object API for callers without a request context.
func (s3 sses3) unsealObjectKey(ctx context.Context, k *kms.KMS, metadata map[string]string, bucket, object string) (key ObjectKey, err error) {
if err := ctx.Err(); err != nil {
return key, err
}
if k == nil { if k == nil {
return key, Errorf("KMS not configured") return key, Errorf("KMS not configured")
} }
@@ -79,7 +88,7 @@ func (s3 sses3) UnsealObjectKey(k *kms.KMS, metadata map[string]string, bucket,
if err != nil { if err != nil {
return key, err return key, err
} }
unsealKey, err := k.Decrypt(context.TODO(), &kms.DecryptRequest{ unsealKey, err := k.Decrypt(ctx, &kms.DecryptRequest{
Name: keyID, Name: keyID,
Ciphertext: kmsKey, Ciphertext: kmsKey,
AssociatedData: kms.Context{bucket: path.Join(bucket, object)}, AssociatedData: kms.Context{bucket: path.Join(bucket, object)},
@@ -106,12 +115,15 @@ func (s3 sses3) UnsealObjectKeys(ctx context.Context, k *kms.KMS, metadata []map
} }
keys := make([]ObjectKey, 0, len(metadata)) keys := make([]ObjectKey, 0, len(metadata))
for i := range metadata { for i := range metadata {
key, err := s3.UnsealObjectKey(k, metadata[i], buckets[i], objects[i]) key, err := s3.unsealObjectKey(ctx, k, metadata[i], buckets[i], objects[i])
if err != nil { if err != nil {
return nil, err return nil, err
} }
keys = append(keys, key) keys = append(keys, key)
} }
if err := ctx.Err(); err != nil {
return nil, err
}
return keys, nil return keys, nil
} }
+2
View File
@@ -341,6 +341,8 @@ func (target *KafkaTarget) initKafka() error {
args := target.args args := target.args
config := sarama.NewConfig() config := sarama.NewConfig()
// Preserve SILO's default broker protocol when no version is configured.
config.Version = sarama.V2_1_0_0
if args.Version != "" { if args.Version != "" {
kafkaVersion, err := sarama.ParseKafkaVersion(args.Version) kafkaVersion, err := sarama.ParseKafkaVersion(args.Version)
if err != nil { if err != nil {
+101
View File
@@ -0,0 +1,101 @@
// Copyright (c) 2026 Ruohang Feng
// 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 target
import (
"context"
"testing"
"github.com/IBM/sarama"
"github.com/minio/minio/internal/event"
xnet "github.com/pgsty/silo-pkg/v3/net"
)
// TestKafkaBrokerVersionCompatibility exercises the actual target constructor
// and producer: dependency upgrades must not raise the default broker protocol.
func TestKafkaBrokerVersionCompatibility(t *testing.T) {
for _, test := range []struct {
name, version string
want sarama.KafkaVersion
metadata int16
sasl bool
}{
{"default", "", sarama.V2_1_0_0, 7, false},
{"explicit-old", "0.10.2.0", sarama.V0_10_2_0, 2, false},
{"explicit-new", "2.8.0", sarama.V2_8_0_0, 11, false},
{"default-sasl", "", sarama.V2_1_0_0, 7, true},
} {
t.Run(test.name, func(t *testing.T) {
broker := sarama.NewMockBroker(t, 1)
defer broker.Close()
broker.SetHandlerByMap(map[string]sarama.MockResponse{
"ApiVersionsRequest": sarama.NewMockApiVersionsResponse(t).SetApiKeys([]sarama.ApiVersionsResponseKey{
{ApiKey: 0, MinVersion: 0, MaxVersion: 8},
{ApiKey: 3, MinVersion: 0, MaxVersion: 11},
{ApiKey: 17, MinVersion: 0, MaxVersion: 1},
{ApiKey: 18, MinVersion: 0, MaxVersion: 3},
{ApiKey: 36, MinVersion: 0, MaxVersion: 2},
}),
"MetadataRequest": sarama.NewMockMetadataResponse(t).SetBroker(broker.Addr(), broker.BrokerID()).SetLeader("events", 0, broker.BrokerID()),
"ProduceRequest": sarama.NewMockProduceResponse(t),
"SaslHandshakeRequest": sarama.NewMockSaslHandshakeResponse(t).SetEnabledMechanisms([]string{sarama.SASLTypePlaintext}),
"SaslAuthenticateRequest": sarama.NewMockSaslAuthenticateResponse(t),
})
host, err := xnet.ParseHost(broker.Addr())
if err != nil {
t.Fatal(err)
}
target := &KafkaTarget{args: KafkaArgs{Brokers: []xnet.Host{*host}, Topic: "events", Version: test.version}}
target.args.SASL.Enable = test.sasl
target.args.SASL.User = "user"
target.args.SASL.Password = "password"
target.loggerOnce = func(context.Context, error, string, ...any) {}
if err := target.Save(event.Event{EventName: event.ObjectCreatedPut}); err != nil {
t.Fatal(err)
}
defer target.client.Close()
defer target.producer.Close()
if target.config.Version != test.want {
t.Fatalf("broker protocol: got %v, want %v", target.config.Version, test.want)
}
if target.config.Net.SASL.Version != sarama.SASLHandshakeV1 || !target.config.ApiVersionsRequest {
t.Fatal("SASL/API version negotiation changed")
}
if err := target.config.Validate(); err != nil {
t.Fatal(err)
}
var metadata, produced, authenticated bool
for _, exchange := range broker.History() {
switch req := exchange.Request.(type) {
case *sarama.MetadataRequest:
metadata = true
if req.Version != test.metadata {
t.Fatalf("metadata request version: got %d, want %d", req.Version, test.metadata)
}
case *sarama.ProduceRequest:
produced = true
case *sarama.SaslAuthenticateRequest:
authenticated = true
}
}
if !metadata || !produced || (test.sasl && !authenticated) {
t.Fatalf("incomplete broker exchange: metadata=%v produce=%v authenticated=%v", metadata, produced, authenticated)
}
})
}
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright (c) 2026 Ruohang Feng
// 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 kms
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
"aead.dev/mtls"
)
// Exercise the configured MinKMS transport, including the API-key certificate.
// A KES integration test does not cover this separate client implementation.
func TestConnectMinKMSTLSIdentity(t *testing.T) {
for _, key := range []string{EnvKESEndpoint, EnvKMSSecretKey, EnvKMSSecretKeyFile} {
t.Setenv(key, "") // Restore the original value after testing absence.
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
}
key, err := mtls.GenerateKey()
if err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var connections []tls.ConnectionState
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/version" {
t.Errorf("unexpected KMS request: %s %s", r.Method, r.URL)
w.WriteHeader(http.StatusNotFound)
return
}
identity, err := mtls.PeerIdentity(r.TLS)
if err != nil || identity != key.Identity() {
t.Errorf("API key identity: got %s, error %v", identity, err)
w.WriteHeader(http.StatusForbidden)
return
}
mu.Lock()
connections = append(connections, *r.TLS)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
// Force a new TLS connection so the second request checks session reuse.
w.Header().Set("Connection", "close")
_, _ = io.WriteString(w, `{"version":"2026-10-22T00-00-00Z","api_version":"v1"}`)
}))
server.Config.ErrorLog = log.New(io.Discard, "", 0)
server.TLS = &tls.Config{MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12, ClientAuth: tls.RequireAnyClientCert}
server.StartTLS()
defer server.Close()
caDir := t.TempDir()
if err := os.WriteFile(filepath.Join(caDir, "ca.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(EnvKMSEndpoint, server.URL)
t.Setenv(EnvKMSAPIKey, key.String())
t.Setenv(EnvKMSDefaultKey, "silo-test-key")
t.Setenv(EnvKMSEnclave, "silo-test-enclave")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := Connect(ctx, &ConnectionOptions{CADir: caDir})
if err != nil {
t.Fatal(err)
}
if client.Type != MinKMS || client.DefaultKey != "silo-test-key" || client.conn.(*kmsConn).enclave != "silo-test-enclave" {
t.Fatal("MinKMS connection lost its configured identity or key scope")
}
for range 2 {
version, err := client.Version(ctx)
if err != nil || version != "2026-10-22T00-00-00Z" {
t.Fatalf("trusted MinKMS: version %q, error %v", version, err)
}
}
mu.Lock()
if len(connections) != 2 || connections[0].DidResume || !connections[1].DidResume || connections[1].Version != tls.VersionTLS12 {
t.Errorf("TLS 1.2 client session cache was not preserved: %+v", connections)
}
mu.Unlock()
// An empty CA directory cannot authenticate this private server. The client
// must reject it instead of silently falling back to insecure TLS.
untrusted, err := Connect(ctx, &ConnectionOptions{CADir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
_, err = untrusted.Version(ctx)
var unknownCA x509.UnknownAuthorityError
if !errors.As(err, &unknownCA) {
t.Fatalf("untrusted KMS server: got %v, want unknown certificate authority", err)
}
}
+4 -1
View File
@@ -235,8 +235,11 @@ func (c *kesConn) Decrypt(ctx context.Context, req *DecryptRequest) ([]byte, err
return nil, err return nil, err
} }
plaintext, err := c.client.Decrypt(context.Background(), req.Name, req.Ciphertext, aad) plaintext, err := c.client.Decrypt(ctx, req.Name, req.Ciphertext, aad)
if err != nil { if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if errors.Is(err, kes.ErrKeyNotFound) { if errors.Is(err, kes.ErrKeyNotFound) {
return nil, ErrKeyNotFound return nil, ErrKeyNotFound
} }
+2
View File
@@ -238,6 +238,8 @@ func (h *Target) init() error {
} }
sconfig := sarama.NewConfig() sconfig := sarama.NewConfig()
// Preserve SILO's default broker protocol when no version is configured.
sconfig.Version = sarama.V2_1_0_0
if h.kconfig.Version != "" { if h.kconfig.Version != "" {
kafkaVersion, err := sarama.ParseKafkaVersion(h.kconfig.Version) kafkaVersion, err := sarama.ParseKafkaVersion(h.kconfig.Version)
if err != nil { if err != nil {
@@ -0,0 +1,98 @@
// Copyright (c) 2026 Ruohang Feng
// 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 kafka
import (
"testing"
"github.com/IBM/sarama"
xnet "github.com/pgsty/silo-pkg/v3/net"
)
// TestKafkaBrokerVersionCompatibility exercises the actual target constructor
// and producer: dependency upgrades must not raise the default broker protocol.
func TestKafkaBrokerVersionCompatibility(t *testing.T) {
for _, test := range []struct {
name, version string
want sarama.KafkaVersion
metadata int16
sasl bool
}{
{"default", "", sarama.V2_1_0_0, 7, false},
{"explicit-old", "0.10.2.0", sarama.V0_10_2_0, 2, false},
{"explicit-new", "2.8.0", sarama.V2_8_0_0, 11, false},
{"default-sasl", "", sarama.V2_1_0_0, 7, true},
} {
t.Run(test.name, func(t *testing.T) {
broker := sarama.NewMockBroker(t, 1)
defer broker.Close()
broker.SetHandlerByMap(map[string]sarama.MockResponse{
"ApiVersionsRequest": sarama.NewMockApiVersionsResponse(t).SetApiKeys([]sarama.ApiVersionsResponseKey{
{ApiKey: 0, MinVersion: 0, MaxVersion: 8},
{ApiKey: 3, MinVersion: 0, MaxVersion: 11},
{ApiKey: 17, MinVersion: 0, MaxVersion: 1},
{ApiKey: 18, MinVersion: 0, MaxVersion: 3},
{ApiKey: 36, MinVersion: 0, MaxVersion: 2},
}),
"MetadataRequest": sarama.NewMockMetadataResponse(t).SetBroker(broker.Addr(), broker.BrokerID()).SetLeader("events", 0, broker.BrokerID()),
"ProduceRequest": sarama.NewMockProduceResponse(t),
"SaslHandshakeRequest": sarama.NewMockSaslHandshakeResponse(t).SetEnabledMechanisms([]string{sarama.SASLTypePlaintext}),
"SaslAuthenticateRequest": sarama.NewMockSaslAuthenticateResponse(t),
})
host, err := xnet.ParseHost(broker.Addr())
if err != nil {
t.Fatal(err)
}
target := &Target{kconfig: Config{Brokers: []xnet.Host{*host}, Topic: "events", Version: test.version}}
target.kconfig.SASL.Enable = test.sasl
target.kconfig.SASL.User = "user"
target.kconfig.SASL.Password = "password"
if err := target.send(map[string]string{"event": "probe"}); err != nil {
t.Fatal(err)
}
defer target.client.Close()
defer target.producer.Close()
if target.config.Version != test.want {
t.Fatalf("broker protocol: got %v, want %v", target.config.Version, test.want)
}
if target.config.Net.SASL.Version != sarama.SASLHandshakeV1 || !target.config.ApiVersionsRequest {
t.Fatal("SASL/API version negotiation changed")
}
if err := target.config.Validate(); err != nil {
t.Fatal(err)
}
var metadata, produced, authenticated bool
for _, exchange := range broker.History() {
switch req := exchange.Request.(type) {
case *sarama.MetadataRequest:
metadata = true
if req.Version != test.metadata {
t.Fatalf("metadata request version: got %d, want %d", req.Version, test.metadata)
}
case *sarama.ProduceRequest:
produced = true
case *sarama.SaslAuthenticateRequest:
authenticated = true
}
}
if !metadata || !produced || (test.sasl && !authenticated) {
t.Fatalf("incomplete broker exchange: metadata=%v produce=%v authenticated=%v", metadata, produced, authenticated)
}
})
}
}