mirror of
https://github.com/pgsty/minio.git
synced 2026-10-28 19:47:23 +03:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41822710e4 | |||
| 79eca5e920 | |||
| 475640e7e1 | |||
| 62124c779c | |||
| 030c6d740e | |||
| 5f1f4178ae | |||
| 0828861bd0 | |||
| d16f5ed9c8 | |||
| 6d890fc860 | |||
| aafe4f8886 | |||
| a2073d2514 | |||
| 3c82748e14 | |||
| 1e94c5a30d | |||
| 000e653038 | |||
| 945dc16060 | |||
| a30270d9ba | |||
| 1841fce4c4 | |||
| f2b8d4b9cb | |||
| 8ed5c90dcd | |||
| 789a2327ba | |||
| e3e0c6215d | |||
| b91e1c6f2a | |||
| 969a6a42d3 | |||
| 1b5477f3df | |||
| a07d655dc6 | |||
| d43adc0f2b | |||
| 3df51549bb | |||
| 324bb308dd | |||
| 625c11e7e1 | |||
| 4059c25e22 | |||
| 011b0df5f5 | |||
| 9faeb7a114 | |||
| 856f8f4c55 | |||
| 0cd388c666 | |||
| 1748f62c7e | |||
| 60c7179ddb | |||
| 7007c164a5 | |||
| 51b7de6a0d | |||
| 4733e1423c | |||
| 4189869e43 | |||
| 0f9199e0c3 | |||
| bb60871f57 | |||
| 2c820895d7 | |||
| 2080b3d031 | |||
| 9954392605 | |||
| 3dd53eb81f | |||
| 86fb2b833f | |||
| fbcb511978 | |||
| fec1e0804a |
@@ -27,6 +27,10 @@ archives:
|
||||
ids:
|
||||
- silo
|
||||
name_template: "silo_{{ .Env.PKG_VERSION }}_{{ .Os }}_{{ .Arch }}"
|
||||
formats: ["tar.gz"]
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: ["zip"]
|
||||
# Explicit so the license materials cannot silently drop out of the
|
||||
# binary archives: GoReleaser's default file globs would miss NOTICE.
|
||||
# CREDITS stays out deliberately -- at 1.8MB it would dominate the
|
||||
|
||||
@@ -93,8 +93,13 @@ jobs:
|
||||
- name: Run conditional PUT tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(PoolsConditionalPut|SinglePoolConditionalPutHTTP)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run multipart listing and cancellation tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(MultipartListing|MultipartAbort|PaginateMultipartUploads|ListMultipartUploads)' -count=1 -timeout=5m
|
||||
- name: Run multi-pool delete consistency tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(Pools(Delete|BulkDelete|RetiringCopyPreservesSharedTierObject)|BulkDeleteQuorumEvidence)' -count=1 -timeout=5m
|
||||
|
||||
- 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
|
||||
run: go test -race ./cmd -run '^Test(ListFallback|ListingCache|ListingMerge|ListingRecovery|ListingPending|ListingEntry|ListingSecondLock|StreamMetadataParts|LocalLockerListing|DistributedListing|MergeXLV2LatestNullCrossParity|CountXLV2MixedECGroups|ListCrossParity)' -count=1 -timeout=5m
|
||||
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
- name: Build Draft release with GoReleaser
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
version: v2.18.1
|
||||
version: v2.18.3
|
||||
args: release --clean --skip=validate --config .github/goreleaser.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
- name: Install nFPM
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0
|
||||
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.48.0
|
||||
echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Build nFPM packages
|
||||
|
||||
@@ -34,10 +34,11 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: python -m pip install -r buildscripts/repository-cards/requirements.txt
|
||||
- name: Use the runner's Python in an isolated environment
|
||||
run: |
|
||||
python3 -m venv "$RUNNER_TEMP/repository-cards-venv"
|
||||
"$RUNNER_TEMP/repository-cards-venv/bin/python" -m pip install -r buildscripts/repository-cards/requirements.txt
|
||||
echo "$RUNNER_TEMP/repository-cards-venv/bin" >> "$GITHUB_PATH"
|
||||
- run: python -m unittest discover -s buildscripts/repository-cards -p 'test_*.py' -v
|
||||
|
||||
publish:
|
||||
@@ -54,10 +55,11 @@ jobs:
|
||||
OUTPUT_BRANCH: codex/repository-cards
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: python -m pip install -r buildscripts/repository-cards/requirements.txt
|
||||
- name: Use the runner's Python in an isolated environment
|
||||
run: |
|
||||
python3 -m venv "$RUNNER_TEMP/repository-cards-venv"
|
||||
"$RUNNER_TEMP/repository-cards-venv/bin/python" -m pip install -r buildscripts/repository-cards/requirements.txt
|
||||
echo "$RUNNER_TEMP/repository-cards-venv/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Load the generated-assets branch
|
||||
shell: bash
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
docker build --build-arg TARGETARCH=${{ matrix.arch }} \
|
||||
--target curl-runtime -f Dockerfile.goreleaser -t silo-curl-test .
|
||||
docker run --rm silo-curl-test --version | tee curl-version.txt
|
||||
grep -F 'curl 8.22.0 ' curl-version.txt
|
||||
grep -F 'curl 8.23.0 ' curl-version.txt
|
||||
grep -F 'HTTP2' curl-version.txt
|
||||
docker run --rm silo-curl-test --fail --silent --show-error \
|
||||
--connect-timeout 15 --max-time 60 https://curl.se/robots.txt
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: GoReleaser config check
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
version: v2.18.1
|
||||
version: v2.18.3
|
||||
args: check --config .github/goreleaser.yml
|
||||
|
||||
- name: Validate Helm chart and legacy upgrade identity
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
- name: Build snapshot artifacts
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
version: v2.18.1
|
||||
version: v2.18.3
|
||||
# A pull-request snapshot has no trusted release identity. Exercise
|
||||
# the SBOM/checksum pipeline here, and reserve keyless signing for
|
||||
# the tag-triggered release workflow with GitHub OIDC.
|
||||
@@ -120,13 +120,34 @@ jobs:
|
||||
LDFLAGS: ${{ env.LDFLAGS }}
|
||||
PKG_VERSION: ${{ env.PKG_VERSION }}
|
||||
|
||||
- name: Verify archive SBOM and checksum coverage
|
||||
- name: Verify archive formats, documentation, SBOMs, and checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t archives < <(find dist -maxdepth 1 -type f -name 'silo_*.tar.gz' | sort)
|
||||
mapfile -t sboms < <(find dist -maxdepth 1 -type f -name 'silo_*.tar.gz.sbom.json' | sort)
|
||||
mapfile -t archives < <(find dist -maxdepth 1 -type f \( -name 'silo_*.tar.gz' -o -name 'silo_*.zip' \) | sort)
|
||||
mapfile -t sboms < <(find dist -maxdepth 1 -type f \( -name 'silo_*.tar.gz.sbom.json' -o -name 'silo_*.zip.sbom.json' \) | sort)
|
||||
test "${#archives[@]}" -eq 6
|
||||
test "${#sboms[@]}" -eq 6
|
||||
for os in linux darwin windows; do
|
||||
format=tar.gz
|
||||
binary=silo
|
||||
if [ "${os}" = windows ]; then
|
||||
format=zip
|
||||
binary=silo.exe
|
||||
fi
|
||||
for arch in amd64 arm64; do
|
||||
archive="dist/silo_${PKG_VERSION}_${os}_${arch}.${format}"
|
||||
test -s "${archive}"
|
||||
jq -e '.spdxVersion | startswith("SPDX-")' "${archive}.sbom.json" >/dev/null
|
||||
if [ "${format}" = zip ]; then
|
||||
listing="$(unzip -Z1 "${archive}")"
|
||||
else
|
||||
listing="$(tar -tzf "${archive}")"
|
||||
fi
|
||||
for member in "${binary}" LICENSE NOTICE README.md; do
|
||||
grep -Fx "${member}" <<< "${listing}"
|
||||
done
|
||||
done
|
||||
done
|
||||
manifest="dist/silo_${PKG_VERSION}_checksums.txt"
|
||||
test -s "${manifest}"
|
||||
(
|
||||
@@ -143,7 +164,7 @@ jobs:
|
||||
- name: Install package validation tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0
|
||||
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.48.0
|
||||
echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}"
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes rpm binutils
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
- name: Install govulncheck
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@v1.8.0
|
||||
go install golang.org/x/vuln/cmd/govulncheck@v1.9.0
|
||||
echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Run govulncheck
|
||||
|
||||
+112
-204
@@ -2,221 +2,129 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
Preparation target: `RELEASE.2026-09-16T00-00-00Z` (package version
|
||||
`20260916000000.0.0`). The entries below describe the candidate changes since
|
||||
the latest published Server.
|
||||
**The latest published Server remains 20260903.** These changes are not in its
|
||||
binaries, packages or images. See the [component matrix](https://silo.pgsty.com/compatibility/versions/)
|
||||
and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-09-03T13-18-01Z...main).
|
||||
- Fix LDAP login for long normalized DNs without hiding unreadable IAM
|
||||
revocation records. Only length errors for valid keys with an ASCII path
|
||||
component over 255 bytes are treated as absent; long-DN writes still fail.
|
||||
Thanks to [@910patin](https://github.com/910patin) for the original fix and
|
||||
reproduction in [#250](https://github.com/pgsty/silo/pull/250).
|
||||
- Build the server and standalone debugging tools with Go 1.27.3 and refresh
|
||||
Go dependencies, including silo-pkg v3.15.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
|
||||
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).
|
||||
## RELEASE.2026-10-18T00-00-00Z
|
||||
|
||||
### 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):
|
||||
apply prefix, delimiter, markers, ordering and the 1,000-entry page limit
|
||||
globally across pools and sets, independently of node-local caches. Omit
|
||||
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.
|
||||
Full [English release notes](https://silo.pgsty.com/blog/release/silo-20261018/)
|
||||
and [Chinese release notes](https://silo.pgsty.com/zh/blog/release/silo-20261018/)
|
||||
cover the upgrade contract, evidence and remaining limits.
|
||||
|
||||
- Make exact-version delete-marker purges converge in replicated buckets
|
||||
(`eb4f5e5b3`, `254b19ac0`, `358ab38fb`). A purge no longer creates the
|
||||
marker on drives that lacked it; a retried purge of a missing version is
|
||||
acknowledged only when a write-quorum majority of drives report it absent;
|
||||
purge results merge removed and reliably absent replies; healing a marker
|
||||
preserves its stored replication and purge metadata; and a queued marker
|
||||
creation is re-checked against the source under the replication lock before
|
||||
it is sent, so a purge that already reached the targets is not undone by a
|
||||
stale task from another frontend, a GET/LIST heal, the scanner or MRF.
|
||||
Purging a data version whose earlier purge is still pending reports it as a
|
||||
data version. **Known limitations:** creations already in flight or replayed
|
||||
from another site, and minority marker copies left by a crash after a
|
||||
majority-acknowledged purge, are tracked in #217. See
|
||||
[the replication reliability record](https://silo.pgsty.com/blog/design/replication-reliability/).
|
||||
- Keep a null object version that has listing quorum when a newer minority of
|
||||
drives sorts first (`8d06424b1`). The resolver recounts per header only when
|
||||
the original selection lacks quorum, every non-empty drive stream holds
|
||||
exactly one ordinary null version and all share the same erasure layout;
|
||||
mixed histories keep their previous behavior. **Known limitation:** a
|
||||
successful ListObjects can still omit readable keys during rolling restarts
|
||||
with concurrent overwrites (#218). Do not run destination-deleting sync tools
|
||||
against a listing taken during a rolling restart; list again once the
|
||||
cluster is stable.
|
||||
- Carry object tags through rebalance and decommission for ordinary and
|
||||
multipart writes (`fced86303`). Both migration entry points restore the tags
|
||||
and their revision fields when rewriting the object in the destination pool.
|
||||
Tags dropped by earlier migrations are not recovered; audit tag-dependent
|
||||
lifecycle and policy rules for pools migrated with an older build.
|
||||
- **Object Lock (#234, #235):** require `s3:PutObjectRetention` with request-body
|
||||
conditions before object lookup; weakening active GOVERNANCE additionally
|
||||
requires bypass intent and permission. Protect caller-addressed write versions,
|
||||
directory null versions and persisted multipart targets under the write lock;
|
||||
fail closed on unreadable lock state. See SN-2026-015/016 in the
|
||||
[advisory ledger](https://silo.pgsty.com/about/security-advisories/).
|
||||
- **IAM Admin authorization (#242, `995439260`):** enforce explicit self denies,
|
||||
session restrictions and plugin decisions; prevent restricted delegation and
|
||||
non-root mutation of root-derived credentials under built-in authorization.
|
||||
Preserve empty-policy inheritance and damaged-key cleanup. Normalize bulk
|
||||
self-list aliases and allow ordinary policyless self inspection. See
|
||||
[SN-2026-017](https://silo.pgsty.com/blog/security/20261018-release-hardening/#sn-2026-017).
|
||||
- **Scoped STS revocation (`0cd388c66`):** enumerate durable parent/type-selected
|
||||
tokens, propagate each committed tombstone to sibling nodes and sites, and
|
||||
recover missed notifications through existing refresh/healing. Tokens issued
|
||||
only at an isolated site are outside the requesting cluster's snapshot;
|
||||
propagation is eventual and newly issued keys remain valid.
|
||||
- **Pool migration (#248, `60c7179dd`, `1748f62c7`):** prevent stale moved
|
||||
payloads from overwriting newer copies, preserve null/UUID delete markers,
|
||||
recheck source versions and clean only confirmed moved versions under the
|
||||
object lock. Added per-pool reads and per-version cleanup have maintenance
|
||||
costs; earlier data loss is not reconstructed.
|
||||
|
||||
- Evaluate conditional multipart completion against the logical current object
|
||||
across all pools while holding the existing object lock. A stale `If-Match`
|
||||
can no longer replace newer data in another pool, and the current ETag is no
|
||||
longer rejected because the upload resides next to an older copy. Conditions
|
||||
are evaluated once; a current delete marker counts as an absent object.
|
||||
**Availability change:** if any pool's metadata cannot be read, conditional
|
||||
completion fails even when another pool can still serve GET/HEAD. This also
|
||||
applies when the unreadable pool may not hold the object: absence cannot be
|
||||
verified. Retry after the pool recovers. Unconditional completion and the
|
||||
single-pool path retain their existing behavior.
|
||||
- **LIST recovery (#239, `3dd53eb81`, `2080b3d03`):** confirm eligible uncertain
|
||||
latest-object groups under the appropriate read locks, distinguish absence
|
||||
from failure, preserve directory/common-prefix entries and terminal pagination
|
||||
errors, and make lock backoff cancelable. Resolved entries keep their fast path;
|
||||
version history, quorum and disk formats retain their contracts. The strict
|
||||
sub-5% performance target remains unconfirmed in the recorded comparison.
|
||||
- **Multipart listing (`4733e1423`):** withdraw #243's default persistent scan.
|
||||
Public listing uses node-local cache or exact-key lookup; restart omissions,
|
||||
prefix and cache-pagination limits remain. The retired strict switch is ignored
|
||||
with a startup warning. **Former strict users lose durable listing and strict
|
||||
cancellation.** Keep identity metadata, path/wrong-target safety, default
|
||||
best-effort Abort and peer notification. Diagnostic `multipart-preflight`
|
||||
reports `mode=legacy`; its budgets do not gate public operations. #79 remains
|
||||
open, including delayed creation after cancellation.
|
||||
- **Multipart cleanup (#236, `0f9199e0c`):** parse native timestamps before the
|
||||
Base64 fallback and remove empty hash parents without a second expiry wait.
|
||||
Keep active/unreadable uploads; default expiry/check intervals stay 24h/6h.
|
||||
- **Commit errors (#232):** missing staged `xl.meta` at final commit becomes
|
||||
HTTP 503 `SlowDownWrite`; preserve this classification across storage RPC and
|
||||
healing. Real incomplete bodies retain their existing errors.
|
||||
- **Reused metadata (#233):** clear absent legacy `EcM`/`Signature` and locally
|
||||
reset mutually exclusive scanner response fields without changing disk/RPC formats.
|
||||
- **Checksum and Console errors (#225, #229, #238):** align Content-MD5 and
|
||||
algorithm-specific messages, retaining the generic unknown-algorithm message;
|
||||
embed Console's wrapped-error and `InvalidBucketState` handling.
|
||||
- **Lambda and erasure documentation (#220, `2fde3cf53`):** fix inverted
|
||||
`TargetIDSet.IsEmpty`; document parity/quorum per object and erasure set.
|
||||
- **Coordinated dependencies:** Console v2.4.2 (`ef2dd2c41d21`), mcli 20261018
|
||||
(`fa3b8bf72458`), silo-pkg v3.14.3, upstream minio-go v7.4.1 and Go 1.27.2.
|
||||
Standalone Console/mcli still declare pkg v3.14.2. The Server package update
|
||||
speeds admin-action classification without changing policy behavior. Refresh
|
||||
Go x/*, go-jose v4.1.6, LDAP v3.4.15, compress v1.20.3, msgp v1.6.5, etcd v3.7.2,
|
||||
fwd v1.2.1 and cpuid v2.4.1. OIDC RSA JWKs above 4096 bits are rejected.
|
||||
Keep the NetBSD go-systemd v22.6.0 replacement; parallel SDK GET/Compose remain opt-in.
|
||||
- **Build and delivery:** gate releases on Object Lock regressions; use
|
||||
Git for rebrand scanning without a ripgrep dependency (`7007c164a`). Refresh UBI 9 pins,
|
||||
GoReleaser v2.18.3, nFPM v2.48.0, golangci-lint v2.15.0 and govulncheck v1.9.0.
|
||||
Update bundled client hashes/helper and the Helm client image to mcli 20261018.
|
||||
The chart's Server default stays at the public 20260916 image until publication.
|
||||
|
||||
- Evaluate ordinary multi-pool conditional PUT against the logical current
|
||||
object across all pools, including draining pools, under the existing object
|
||||
lock (#207). A stale destination copy no longer accepts a stale ETag or rejects
|
||||
the current one; a current delete marker is treated as absence.
|
||||
**Availability change:** if any pool's object metadata cannot be verified,
|
||||
the condition fails even when GET can use another pool; read-quorum failures
|
||||
return 503. Restore readability or heal before retrying. Unconditional PUT,
|
||||
single-pool conditions and internal replication retain their existing behavior.
|
||||
A public condition with a destination `versionId` compares the current object
|
||||
while preserving the requested write version. This change does not retire
|
||||
stale copies in other pools, undo historical accepted overwrites or provide
|
||||
a new global clock-ordering guarantee. The multipart-completion repair in #190
|
||||
neither introduced nor repaired this separate PUT defect.
|
||||
Upgrade every writer and replication site; follow the
|
||||
[IAM recovery guide](https://silo.pgsty.com/operations/replication/iam-upgrade/).
|
||||
Old strict multipart users must review the capability loss before upgrading.
|
||||
#217's multi-site delete-marker late creation and crash residue remain unresolved.
|
||||
Source tests, draft artifacts, public images and production rollout are separate
|
||||
acceptance boundaries; historical 20261015 test counts do not certify this source.
|
||||
|
||||
- Reconcile ordinary single-object version DELETE across all pools, including
|
||||
null versions, delete markers and unqualified directory-marker DELETE. This
|
||||
applies the deletion to every resolved pool copy under existing quorum
|
||||
rules. Pending outbound delete replication retains versions until the
|
||||
existing replication worker completes their purge; a successful response
|
||||
does not imply immediate physical removal from every drive. Unreadable
|
||||
pools now consistently return 503 instead of depending on pool traversal
|
||||
order; insufficient read quorum returns `SlowDownRead`. This extends the
|
||||
existing failure surface. Retry after recovery.
|
||||
Cleanup failures also return an error. Batch deletion already fans out across
|
||||
pools; replication and scanner cleanup keep their existing contracts. See
|
||||
[scope and limitations](docs/bucket/lifecycle/access-tiering-removal.md#version-deletion-scope).
|
||||
## RELEASE.2026-09-16T00-00-00Z
|
||||
|
||||
- Remove the opt-in GET-frequency pool-tiering feature from PR #60, including
|
||||
its tracker, mover, scanner hooks, configuration, XML actions and metrics.
|
||||
Accept and ignore retired configuration/XML and preserve ordinary statistics
|
||||
when reading v9 caches. See [migration notes](docs/bucket/lifecycle/access-tiering-removal.md).
|
||||
The [decision record](https://silo.pgsty.com/compatibility/access-tiering-removal/) preserves
|
||||
the feature's introduction, subsequent fixes, rollback scope and review history.
|
||||
- Preserve the independent multi-pool write, metadata, healing and conditional
|
||||
deletion fixes from PR #178, including shared remote-tier reference protection.
|
||||
- Enforce `If-Match` on DELETE, preserve retention and independently ordered
|
||||
Object Lock/tag updates, and correctly retransmit encrypted replicas.
|
||||
- Preserve plaintext part sizes and raw SSE-C replicas; prevent SSE-C
|
||||
compression, honor key-rotation checksums, and complete attributes pagination.
|
||||
- Repair federated CopyObject checksums, destination timestamps, reserved
|
||||
metadata, encrypted-object forwarding, legal hold and KMS context.
|
||||
- Make resync counters, target selection, cancellation and worker lifetimes
|
||||
reflect actual work, and report bounded MRF drops.
|
||||
- Converge bucket metadata with deterministic source state, deletion tombstones,
|
||||
creation time recovery and diagnostics. The mixed-version export gate requires
|
||||
coordinated upgrades before tombstones are exported. See [the #77 record](https://silo.pgsty.com/blog/design/bucket-metadata-convergence/).
|
||||
- Include per-bucket CORS in metadata export/import, close metadata publication
|
||||
and logger races, and report effective bucket quotas in metrics.
|
||||
Package version: `20260916000000.0.0`; published source:
|
||||
`2a4d51406b7ed87af5fe6fe0f801f3290f96eb3c`.
|
||||
|
||||
### 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
|
||||
all four WebSocket connection limits. Preserve Go TLS defaults across transports.
|
||||
- Directly require `github.com/pgsty/silo-pkg/v3` v3.14.1; select released Console
|
||||
v2.4.1 (`v0.0.0-20260916075814-1360e26d976d`) and mcli 20260916
|
||||
(`v0.0.0-20260916070421-e952aa78f10a`) with explicit PGSTY replacements.
|
||||
The embedded frontend identifies itself as Console v2.4.1.
|
||||
- Pin upstream minio-go `v7.3.1-0.20260915093545-32e1f32cb176` to handle
|
||||
CopyObject errors embedded in HTTP 200 responses. Update JWX to v3.3.0 for
|
||||
JSON field-name escaping, strfmt to v0.27.2 for Go 1.27 hostname validation,
|
||||
and LZ4 to v4.1.30 for frame-reader, partial-read and concurrency fixes.
|
||||
Retain the earlier Go x/* and bounded AMQP frame updates, Go 1.27.1, and
|
||||
go-systemd v22.6.0's NetBSD compatibility replacement.
|
||||
- Refresh container base digests and build static curl 8.22.0 from verified
|
||||
source for both Linux architectures. Pin the published mcli 20260916 archives
|
||||
and hashes in the container and update the client installer default.
|
||||
- Prepare Helm chart 7.0.3 with Server and client defaults for the September 16
|
||||
batch. Publish the chart only after the corresponding Server image exists.
|
||||
- Pin GoReleaser v2.18.1 and its action commit identically in snapshot and release
|
||||
workflows. Dependency-only PRs now run the Test Release Pipeline too.
|
||||
|
||||
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.
|
||||
The complete release, contributor credits, historical artifact acceptance and
|
||||
upgrade requirements are preserved in the canonical
|
||||
[English](https://silo.pgsty.com/blog/release/silo-20260916/) and
|
||||
[Chinese](https://silo.pgsty.com/zh/blog/release/silo-20260916/) release records.
|
||||
|
||||
## RELEASE.2026-09-03T13-18-01Z
|
||||
|
||||
|
||||
+58
-30
File diff suppressed because one or more lines are too long
+19
-7
@@ -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
|
||||
COPY dockerscripts/build-static-curl.sh /build/build-static-curl
|
||||
RUN /bin/sh /build/build-static-curl
|
||||
@@ -9,7 +9,7 @@ COPY --from=curl-build /go/bin/curl /curl
|
||||
COPY --from=curl-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
ENTRYPOINT ["/curl"]
|
||||
|
||||
FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
|
||||
FROM golang:1.27.3-alpine@sha256:d530cf60723abf1ffae0cd16bb546b379ec0550d2f3815e5b16ed69dbc00fba4 AS build
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -17,9 +17,9 @@ ENV GOPATH=/go
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
ARG MC_REPO=pgsty/mc
|
||||
ARG MC_VERSION=RELEASE.2026-09-16T00-00-00Z
|
||||
ARG MC_AMD64_SHA256=4ba2814fd5507fbe6b4d237c359750b9119d28d7217495fa5b48002fcbd397ef
|
||||
ARG MC_ARM64_SHA256=b7008ca2a1bc5735b6585981c59a3640a0daa152dc789df3d1a0d0438787de82
|
||||
ARG MC_VERSION=RELEASE.2026-10-18T00-00-00Z
|
||||
ARG MC_AMD64_SHA256=b5392a642dbc9685b517ee901f032d1d5e88f41704436f0f08dde6060b577db3
|
||||
ARG MC_ARM64_SHA256=17f3eb694ed34e155b331b1215832140749656d8d4b60e2e3a4d0573b8f320d7
|
||||
|
||||
RUN apk add -U --no-cache \
|
||||
ca-certificates \
|
||||
@@ -67,14 +67,26 @@ RUN apk add -U --no-cache \
|
||||
chmod +x /go/bin/mcli && \
|
||||
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 && \
|
||||
if [ -d /etc/pki/ca-trust/source/blocklist ]; then \
|
||||
blocklist=/etc/pki/ca-trust/source/blocklist; \
|
||||
else \
|
||||
blocklist=/etc/pki/ca-trust/source/blacklist; \
|
||||
fi && \
|
||||
trust extract --overwrite --format=pem-bundle \
|
||||
--filter='pkcs11:object=Comodo%20AAA%20Services%20root;type=cert' \
|
||||
"$blocklist/Comodo_AAA_Services_root.pem" && \
|
||||
trust extract --overwrite --format=pem-bundle \
|
||||
--filter='pkcs11:object=emSign%20ECC%20Root%20CA%20-%20G3;type=cert' \
|
||||
"$blocklist/emSign_ECC_Root_CA_-_G3.pem" && \
|
||||
update-ca-trust && \
|
||||
cp /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem /tmp/ca-certificates.crt && \
|
||||
dnf clean all && \
|
||||
rm -rf /var/cache/dnf
|
||||
|
||||
FROM registry.access.redhat.com/ubi9/ubi-micro:latest@sha256:f332c99eb8f798a8486821c91937f10ad64ee83d7e739303be2df051040918f6
|
||||
FROM registry.access.redhat.com/ubi9/ubi-micro:latest@sha256:5be877a2a280ae36eb9ab80ca4efb28bbf77c57abbba38137cdc37a03b8465e6
|
||||
|
||||
LABEL org.opencontainers.image.title="Silo" \
|
||||
org.opencontainers.image.description="S3-Interface Libre Object Storage" \
|
||||
|
||||
@@ -4,7 +4,7 @@ LDFLAGS := $(shell go run buildscripts/gen-ldflags.go)
|
||||
|
||||
GOOS ?= $(shell go env GOOS)
|
||||
GOARCH ?= $(shell go env GOARCH)
|
||||
GOLANGCI_VERSION ?= v2.14.0
|
||||
GOLANGCI_VERSION ?= v2.15.0
|
||||
|
||||
VERSION ?= $(shell git describe --tags)
|
||||
REPO ?= docker.io/pgsty
|
||||
|
||||
@@ -35,13 +35,19 @@
|
||||
> [!NOTE]
|
||||
> Renamed from `pgsty/minio` to `pgsty/silo`, default branch `master` → `main`, on 2026-08-06. Artifacts under the original MinIO identity stay published on the archived [`minio`](https://github.com/pgsty/silo/tree/minio) branch and in releases up to [`RELEASE.2026-08-04T00-00-00Z`](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-08-04T00-00-00Z).
|
||||
|
||||
## Current release and main branch
|
||||
## SILO 20261018000000
|
||||
|
||||
The latest published Server is [20260903](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-09-03T13-18-01Z).
|
||||
As of 2026-09-16, the main branch has newer security, storage, Console and
|
||||
shared-package changes that have not shipped in a Server release. See
|
||||
[CHANGELOG.md](CHANGELOG.md) and the [component version matrix](https://silo.pgsty.com/compatibility/versions/)
|
||||
for the exact release/source boundary, including SN-2026-011 and password-policy migration.
|
||||
[SILO 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`.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -62,7 +68,7 @@ docker run -d --name silo -p 9000:9000 -p 9001:9001 \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=change-me-long-password \
|
||||
-v "$PWD/data:/data" \
|
||||
docker.io/pgsty/silo:latest server /data --console-address ":9001"
|
||||
docker.io/pgsty/silo:RELEASE.2026-10-18T00-00-00Z server /data --console-address ":9001"
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
@@ -101,8 +107,8 @@ Every divergence from upstream is listed in the code-verified [compatibility aud
|
||||
|
||||
### TLS and Go upgrades
|
||||
|
||||
The following TLS repair is on main and is not included in Server 20260903.
|
||||
With that repair, TLS key exchange follows Go's defaults across the S3 listener, node links,
|
||||
Server 20260916 restored Go's default key exchange; Server 20261018 retains it with Go 1.27.2.
|
||||
TLS key exchange follows Go's defaults across the S3 listener, node links,
|
||||
replication, identity providers, etcd, and external HTTP services. If an endpoint
|
||||
cannot accept ML-KEM, `GODEBUG=tlsmlkem=0` disables the default hybrid exchanges
|
||||
for the process; certificate verification remains enabled. This option does not
|
||||
@@ -137,7 +143,7 @@ Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md); e
|
||||
|
||||
<!-- Generated by silo.pgsty.com/bin/contributors.py. -->
|
||||
|
||||
Every human issue or pull-request author is part of the SILO community, including open and unmerged work. Merged fixes, adopted proposals, and actionable reports receive priority, with first participation guiding the remaining order. Gold rings highlight reviewed significant contributions.
|
||||
Every human issue or pull-request author is part of the SILO community, including open and unmerged work. We also recognize security reports, including reports sent by email, with the reporter's permission. Merged fixes, adopted proposals, and actionable reports receive priority, with first participation guiding the remaining order. Gold rings highlight reviewed significant contributions.
|
||||
|
||||
<a href="CONTRIBUTORS.md">
|
||||
<picture>
|
||||
|
||||
+14
-8
@@ -35,12 +35,18 @@
|
||||
> [!NOTE]
|
||||
> 2026-08-06,本仓库由 `pgsty/minio` 更名为 `pgsty/silo`,默认分支由 `master` 更名为 `main`。以原 MinIO 形态维持的归档构件仍位于归档的 [`minio`](https://github.com/pgsty/silo/tree/minio) 分支,以及截止 [`RELEASE.2026-08-04T00-00-00Z`](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-08-04T00-00-00Z) 的历次发布中。
|
||||
|
||||
## 当前发行版与主分支
|
||||
## SILO 20261018000000
|
||||
|
||||
最新已发布的 Server 仍为 [20260903](https://github.com/pgsty/silo/releases/tag/RELEASE.2026-09-03T13-18-01Z)。
|
||||
截至 2026-09-16,主分支已合入更新的安全、存储、Console 与共享包改动,但尚未发布新 Server。
|
||||
准确的已发布/源码边界见 [CHANGELOG.md](CHANGELOG.md) 与[组件版本矩阵](https://silo.pgsty.com/zh/compatibility/versions/),
|
||||
其中包括 SN-2026-011 修复状态与密码权限迁移要求。
|
||||
[SILO 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`。
|
||||
|
||||
本版加强 Object Lock 与 IAM 鉴权,实现跨进程和站点的持久 STS 撤销,保护多池迁移中的并发更新,并改进 LIST 恢复、分片清理与 S3 错误处理。内置 Console v2.4.2、silo-pkg v3.14.3,使用 Go 1.27.2 构建,配套管理客户端为 mcli 20261018。
|
||||
|
||||
升级前请阅读[发行说明](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_PASSWORD=change-me-long-password \
|
||||
-v "$PWD/data:/data" \
|
||||
docker.io/pgsty/silo:latest server /data --console-address ":9001"
|
||||
docker.io/pgsty/silo:RELEASE.2026-10-18T00-00-00Z server /data --console-address ":9001"
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
@@ -100,7 +106,7 @@ Silo 保留 S3 与存储格式兼容性,包括既有 `MINIO_*` 环境变量、
|
||||
|
||||
### TLS 与 Go 升级
|
||||
|
||||
Server 恢复 Go 默认密钥交换策略的修复已在 main,尚未包含在 Server 20260903。
|
||||
Server 20260916 已恢复 Go 默认密钥交换策略,20261018 在 Go 1.27.2 下保留该行为。
|
||||
`GODEBUG=tlsmlkem=0`、`tlssecpmlkem=0` 的适用范围、macOS 根证书来源变化,
|
||||
以及 OIDC discovery 的诊断方法见 [Go 1.27 TLS 与 OIDC 指南](https://silo.pgsty.com/zh/blog/design/go127-tls-oidc-discovery/)。
|
||||
|
||||
@@ -119,7 +125,7 @@ Server 恢复 Go 默认密钥交换策略的修复已在 main,尚未包含在
|
||||
|
||||
<!-- Generated by silo.pgsty.com/bin/contributors.py. -->
|
||||
|
||||
每位 issue 或 PR 的作者都是 SILO 社区的一员,包括尚未合并的工作。已合并的修复、被采纳的方案和有效报告优先展示,其余参考首次参与时间;金色圆环突出经过审核的显著贡献。
|
||||
每位 issue 或 PR 的作者,以及同意公开署名的安全报告者,都是 SILO 社区的一员,包括尚未合并的工作。已合并的修复、被采纳的方案和有效报告优先展示,其余参考首次参与时间;金色圆环突出经过审核的显著贡献。
|
||||
|
||||
<a href="CONTRIBUTORS.md">
|
||||
<picture>
|
||||
|
||||
@@ -7,7 +7,7 @@ _init() {
|
||||
|
||||
## Minimum required versions for build dependencies
|
||||
GIT_VERSION="1.0"
|
||||
GO_VERSION="1.27.2"
|
||||
GO_VERSION="1.27.3"
|
||||
OSX_VERSION="10.8"
|
||||
KNAME=$(uname -s)
|
||||
ARCH=$(uname -m)
|
||||
|
||||
@@ -40,7 +40,7 @@ if [ -n "${MCLI_BIN:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release=${MCLI_RELEASE:-RELEASE.2026-09-16T00-00-00Z}
|
||||
release=${MCLI_RELEASE:-RELEASE.2026-10-18T00-00-00Z}
|
||||
version_hyphen=${release#RELEASE.}
|
||||
package_version=$(printf '%s\n' "${version_hyphen}" | sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2})-([0-9]{2})-([0-9]{2})Z$/\1\2\3\4\5\6.0.0/')
|
||||
if [ "${package_version}" = "${version_hyphen}" ]; then
|
||||
|
||||
@@ -11,6 +11,33 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Git is already a build prerequisite. --no-index also scans untracked files
|
||||
# and exported source trees. As before, honor .gitignore only in a worktree.
|
||||
search_ignore=--no-exclude-standard
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
search_ignore=--exclude-standard
|
||||
fi
|
||||
search_errors="$(mktemp)"
|
||||
trap 'rm -f "${search_errors}"' EXIT
|
||||
|
||||
search_text() {
|
||||
local status=0
|
||||
git --no-pager grep --no-index "${search_ignore}" --no-color -nIHE "$@" 2>"${search_errors}" || status=$?
|
||||
# Git can return 1 for both no matches and a file read error. Check stderr
|
||||
# as well, before callers filter matches through their allowlists.
|
||||
if [ "${status}" -gt 1 ] || [ -s "${search_errors}" ]; then
|
||||
cat "${search_errors}" >&2
|
||||
fail "could not scan source files"
|
||||
fi
|
||||
}
|
||||
|
||||
# Keep hidden files and build output out of the repository-wide scans.
|
||||
source_excludes=(
|
||||
':(exclude,glob)**/.*'
|
||||
':(exclude,glob)**/.*/**'
|
||||
':(exclude)dist/**'
|
||||
)
|
||||
|
||||
require_file() {
|
||||
[ -f "$1" ] || fail "missing required file: $1"
|
||||
}
|
||||
@@ -152,7 +179,11 @@ if grep -Ev '^[[:space:]]*(#|$)' silo.env | grep -q '='; then
|
||||
fail "silo.env must not contain active assignments that shadow /etc/default/minio"
|
||||
fi
|
||||
|
||||
if rg -n 'pgsty/minio:' .github/workflows Dockerfile.goreleaser helm/silo; then
|
||||
delivery_hits="$(search_text 'pgsty/minio:' -- .github/workflows Dockerfile.goreleaser helm/silo \
|
||||
':(exclude,glob).github/workflows/**/.*' ':(exclude,glob).github/workflows/**/.*/**' \
|
||||
':(exclude,glob)helm/silo/**/.*' ':(exclude,glob)helm/silo/**/.*/**')"
|
||||
if [ -n "${delivery_hits}" ]; then
|
||||
printf '%s\n' "${delivery_hits}" >&2
|
||||
fail "an active delivery surface still publishes the frozen pgsty/minio image"
|
||||
fi
|
||||
|
||||
@@ -165,10 +196,10 @@ fi
|
||||
# So two rules. First, no live URL may resolve to the old repository anywhere,
|
||||
# READMEs and CONTRIBUTORS.md included.
|
||||
old_repo_pattern='pgsty/minio(\.git)?([^[:alnum:]_.-]|$)'
|
||||
stale_repo_url="$(rg -n -e "github\.com/${old_repo_pattern}" -e "hub\.docker\.com/r/${old_repo_pattern}" \
|
||||
--glob '!.git/**' --glob '!dist/**' \
|
||||
--glob '!SILO_REBRANDING_MIGRATION.md' \
|
||||
--glob '!buildscripts/rebrand-guard/compat-baseline.json' . |
|
||||
stale_repo_url="$(search_text -e "github\.com/${old_repo_pattern}" -e "hub\.docker\.com/r/${old_repo_pattern}" -- . \
|
||||
"${source_excludes[@]}" ':(exclude)SILO_REBRANDING_MIGRATION.md' \
|
||||
':(exclude)buildscripts/rebrand-guard/compat-baseline.json')"
|
||||
stale_repo_url="$(printf '%s\n' "${stale_repo_url}" |
|
||||
sed 's#^\./##' | grep -v '^buildscripts/verify-rebrand\.sh:' || true)"
|
||||
if [ -n "${stale_repo_url}" ]; then
|
||||
printf '%s\n' "${stale_repo_url}" >&2
|
||||
@@ -180,32 +211,32 @@ fi
|
||||
# legacy image, the two READMEs that document the rename and the archived
|
||||
# minio branch, and historical issue titles in CONTRIBUTORS.md.
|
||||
repo_guard_allowlist='^(buildscripts/minio-upgrade\.sh|buildscripts/verify-rebrand\.sh|buildscripts/helm-migration-guard/main\.go|README\.md|README_ZH\.md|CONTRIBUTORS\.md):'
|
||||
stale_repo="$(rg -n "${old_repo_pattern}" --glob '!.git/**' --glob '!dist/**' \
|
||||
--glob '!SILO_REBRANDING_MIGRATION.md' \
|
||||
--glob '!buildscripts/rebrand-guard/compat-baseline.json' . |
|
||||
stale_repo="$(search_text "${old_repo_pattern}" -- . "${source_excludes[@]}" \
|
||||
':(exclude)SILO_REBRANDING_MIGRATION.md' ':(exclude)buildscripts/rebrand-guard/compat-baseline.json')"
|
||||
stale_repo="$(printf '%s\n' "${stale_repo}" |
|
||||
sed 's#^\./##' | grep -Ev "${repo_guard_allowlist}" || true)"
|
||||
if [ -n "${stale_repo}" ]; then
|
||||
printf '%s\n' "${stale_repo}" >&2
|
||||
fail "a source reference still names the pre-rename pgsty/minio repository"
|
||||
fi
|
||||
|
||||
stale_branch="$(rg -n 'pgsty/silo/(blob/|tree/|raw/)?master' \
|
||||
--glob '!.git/**' --glob '!dist/**' . || true)"
|
||||
stale_branch="$(search_text 'pgsty/silo/(blob/|tree/|raw/)?master' -- . "${source_excludes[@]}")"
|
||||
if [ -n "${stale_branch}" ]; then
|
||||
printf '%s\n' "${stale_branch}" >&2
|
||||
fail "a link still targets the retired master branch; raw and Actions URLs do not follow a branch rename"
|
||||
fi
|
||||
|
||||
for workflow in .github/workflows/go.yml .github/workflows/vulncheck.yml; do
|
||||
if rg -q '^\s+- master$' "${workflow}"; then
|
||||
stale_filter="$(search_text '^[[:space:]]+- master$' -- "${workflow}")"
|
||||
if [ -n "${stale_filter}" ]; then
|
||||
fail "${workflow} still filters on master and would go silently dormant on main"
|
||||
fi
|
||||
require_text "${workflow}" " - main"
|
||||
done
|
||||
|
||||
network_hits="$(rg -n --glob '*.go' --glob '!**/*_test.go' \
|
||||
'https?://[^"`[:space:]]*(dl\.min\.io|subnet\.min\.io|api\.min\.io|slack\.min\.io|play\.min\.io)' \
|
||||
cmd internal || true)"
|
||||
network_hits="$(search_text \
|
||||
'https?://[^"`[:space:]]*(dl\.min\.io|subnet\.min\.io|api\.min\.io|slack\.min\.io|play\.min\.io)' -- \
|
||||
':(glob)cmd/**/*.go' ':(glob)internal/**/*.go' ':(exclude,glob)**/*_test.go' "${source_excludes[@]}")"
|
||||
network_hits="$(printf '%s\n' "${network_hits}" | grep -Ev '^[^:]+:[0-9]+:[[:space:]]*//' || true)"
|
||||
if [ -n "${network_hits}" ]; then
|
||||
printf '%s\n' "${network_hits}" >&2
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -248,8 +249,20 @@ func TestIAMAdminPolicylessSelf(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
caller := iamAdminUser(ctx, t, sys, "review-policyless", "")
|
||||
svc := iamAdminServiceAccount(ctx, t, sys, caller.AccessKey, "rv-policyless-child", "")
|
||||
other := iamAdminUser(ctx, t, sys, "bulk-other", "readwrite")
|
||||
_ = iamAdminServiceAccount(ctx, t, sys, other.AccessKey, "bulk-other-child", "")
|
||||
api := adminAPIHandlers{}
|
||||
assertIAMAdminStatus(t, "list", iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-service-accounts", nil, nil, api.ListServiceAccounts), 200)
|
||||
rec := iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-access-keys-bulk?listType=all", nil, nil, api.ListAccessKeysBulk)
|
||||
assertIAMAdminStatus(t, "list bulk self", rec, 200)
|
||||
data, err := madmin.DecryptData(caller.SecretKey, rec.Body)
|
||||
mustIAM(t, err)
|
||||
var listed map[string]madmin.ListAccessKeysResp
|
||||
mustIAM(t, json.Unmarshal(data, &listed))
|
||||
entry, ok := listed[caller.AccessKey]
|
||||
if len(listed) != 1 || !ok || len(entry.ServiceAccounts) != 1 || entry.ServiceAccounts[0].AccessKey != svc.AccessKey || len(entry.STSKeys) != 0 {
|
||||
t.Fatalf("policyless bulk self returned unexpected identities: %+v", listed)
|
||||
}
|
||||
assertIAMAdminStatus(t, "info service", iamAdminCall(t, caller, "GET", "/minio/admin/v3/info-service-account?accessKey="+svc.AccessKey, nil, map[string]string{"accessKey": svc.AccessKey}, api.InfoServiceAccount), 200)
|
||||
assertIAMAdminStatus(t, "info access key", iamAdminCall(t, caller, "GET", "/minio/admin/v3/info-access-key?accessKey="+svc.AccessKey, nil, map[string]string{"accessKey": svc.AccessKey}, api.InfoAccessKey), 200)
|
||||
assertIAMAdminStatus(t, "delete", iamAdminCall(t, caller, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+svc.AccessKey, nil, map[string]string{"accessKey": svc.AccessKey}, api.DeleteServiceAccount), 204)
|
||||
@@ -384,6 +397,7 @@ func TestIAMAdminSelfDenials(t *testing.T) {
|
||||
}
|
||||
api := adminAPIHandlers{}
|
||||
assertIAMAdminStatus(t, "list", iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-service-accounts", nil, nil, api.ListServiceAccounts), 403)
|
||||
assertIAMAdminStatus(t, "list bulk self", iamAdminCall(t, caller, "GET", "/minio/admin/v3/list-access-keys-bulk?listType=all", nil, nil, api.ListAccessKeysBulk), 403)
|
||||
assertIAMAdminStatus(t, "info service", iamAdminCall(t, caller, "GET", "/minio/admin/v3/info-service-account?accessKey="+target.AccessKey, nil, map[string]string{"accessKey": target.AccessKey}, api.InfoServiceAccount), 403)
|
||||
assertIAMAdminStatus(t, "info access key", iamAdminCall(t, caller, "GET", "/minio/admin/v3/info-access-key?accessKey="+target.AccessKey, nil, map[string]string{"accessKey": target.AccessKey}, api.InfoAccessKey), 403)
|
||||
assertIAMAdminStatus(t, "delete", iamAdminCall(t, caller, "DELETE", "/minio/admin/v3/delete-service-account?accessKey="+target.AccessKey, nil, map[string]string{"accessKey": target.AccessKey}, api.DeleteServiceAccount), 403)
|
||||
@@ -477,7 +491,10 @@ func TestIAMAdminListSelfAliases(t *testing.T) {
|
||||
ctx, sys := iamAdminFixture(t)
|
||||
parent := iamAdminUser(ctx, t, sys, "alias-parent", "readwrite")
|
||||
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" {
|
||||
var err error
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
Groups: cred.Groups,
|
||||
Action: policy.ListServiceAccountsAdminAction,
|
||||
@@ -1250,12 +1250,12 @@ func (a adminAPIHandlers) ListAccessKeysBulk(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if selfOnly && len(users) == 0 {
|
||||
if selfOnly {
|
||||
selfUser := cred.AccessKey
|
||||
if cred.ParentUser != "" {
|
||||
selfUser = cred.ParentUser
|
||||
}
|
||||
users = append(users, selfUser)
|
||||
users = []string{selfUser}
|
||||
}
|
||||
|
||||
var checkedUserList []string
|
||||
@@ -1447,7 +1447,10 @@ func (a adminAPIHandlers) AccountInfoHandler(w http.ResponseWriter, r *http.Requ
|
||||
return buckets[i].Name < buckets[j].Name
|
||||
})
|
||||
} else {
|
||||
buckets, err = objectAPI.ListBuckets(ctx, BucketOptions{Cached: true})
|
||||
// Console refreshes this inventory immediately after bucket mutations.
|
||||
// A stale cached list can otherwise hide a creation or retain a deletion
|
||||
// indefinitely in the client's snapshot.
|
||||
buckets, err = objectAPI.ListBuckets(ctx, BucketOptions{})
|
||||
if err != nil {
|
||||
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
||||
return
|
||||
|
||||
+19
-7
@@ -632,13 +632,25 @@ func expireTransitionedObject(ctx context.Context, objectAPI ObjectLayer, oi *Ob
|
||||
}
|
||||
|
||||
// Delete remote object from warm-tier
|
||||
err := deleteObjectFromRemoteTier(ctx, oi.TransitionedObject.Name, oi.TransitionedObject.VersionID, oi.TransitionedObject.Tier)
|
||||
if err == nil {
|
||||
// Skip adding free version since we successfully deleted the
|
||||
// remote object
|
||||
opts.SkipFreeVersion = true
|
||||
} else {
|
||||
transitionLogIf(ctx, err)
|
||||
//
|
||||
// Unless the expiry only puts a delete marker on top of the version, as
|
||||
// for an object that is not transitioned: with versioning enabled, or
|
||||
// suspended and the version is not the null version, the version stays
|
||||
// as a noncurrent version and must remain readable. Its remote object is
|
||||
// removed later, through the free version added when the version itself
|
||||
// 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
|
||||
|
||||
@@ -19,11 +19,17 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
objectlock "github.com/minio/minio/internal/bucket/object/lock"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
@@ -245,3 +251,180 @@ func TestValidateTransitionTier(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tierExpiryTestBackend is a remote tier held in memory.
|
||||
type tierExpiryTestBackend struct {
|
||||
mu sync.Mutex
|
||||
objects map[string][]byte
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (b *tierExpiryTestBackend) Put(ctx context.Context, object string, r io.Reader, length int64) (remoteVersionID, error) {
|
||||
return b.PutWithMeta(ctx, object, r, length, nil)
|
||||
}
|
||||
|
||||
func (b *tierExpiryTestBackend) PutWithMeta(_ context.Context, object string, r io.Reader, _ int64, _ map[string]string) (remoteVersionID, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.objects[object] = data
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (b *tierExpiryTestBackend) Get(_ context.Context, object string, _ remoteVersionID, opts WarmBackendGetOpts) (io.ReadCloser, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
data, ok := b.objects[object]
|
||||
if !ok {
|
||||
return nil, ObjectNotFound{Bucket: "tier", Object: object}
|
||||
}
|
||||
if opts.length > 0 {
|
||||
data = data[opts.startOffset : opts.startOffset+opts.length]
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(data)), nil
|
||||
}
|
||||
|
||||
func (b *tierExpiryTestBackend) Remove(_ context.Context, object string, _ remoteVersionID) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
delete(b.objects, object)
|
||||
b.removed = append(b.removed, object)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *tierExpiryTestBackend) InUse(context.Context) (bool, error) { return false, nil }
|
||||
|
||||
// The expiry of a transitioned latest version that only puts a delete marker
|
||||
// on top of it must keep its remote object, so the version stays readable as
|
||||
// a noncurrent version. The remote object goes when the version is deleted.
|
||||
func TestExpireTransitionedObjectKeepsNoncurrentData(t *testing.T) {
|
||||
z, _ := migrationTestPools(t)
|
||||
ctx := t.Context()
|
||||
tier := &tierExpiryTestBackend{objects: map[string][]byte{}}
|
||||
prevTier := globalTierConfigMgr
|
||||
globalTierConfigMgr = NewTierConfigMgr()
|
||||
globalTierConfigMgr.drivercache["WARM"] = tier
|
||||
t.Cleanup(func() { globalTierConfigMgr = prevTier })
|
||||
|
||||
const expire = `<LifecycleConfiguration><Rule><ID>exp</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter>` +
|
||||
`<Expiration><Date>2020-01-01T00:00:00Z</Date></Expiration></Rule></LifecycleConfiguration>`
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
versioning string // "", Enabled or Suspended
|
||||
nullLatest bool // the latest version is the null version
|
||||
keep bool // the latest version survives as noncurrent
|
||||
want []string // versions after the expiry, newest first
|
||||
}{
|
||||
{name: "versioned", versioning: "Enabled", keep: true, want: []string{"DM", "v2", "v1"}},
|
||||
{name: "suspended", versioning: "Suspended", keep: true, want: []string{"DM", "v2", "v1"}},
|
||||
{name: "suspended-null", versioning: "Suspended", nullLatest: true, want: []string{"DM", "v1"}},
|
||||
{name: "unversioned", nullLatest: true, want: nil},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
bucket := "tier-expiry-" + tc.name
|
||||
if err := z.MakeBucket(ctx, bucket, MakeBucketOptions{VersioningEnabled: tc.versioning != ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.versioning == "Suspended" {
|
||||
meta.VersioningConfigXML = []byte("<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>")
|
||||
}
|
||||
meta.LifecycleConfigXML = []byte(expire)
|
||||
if err := meta.Save(ctx, z); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalBucketMetadataSys.Set(bucket, meta)
|
||||
|
||||
// tiered writes a version whose data is in the remote tier.
|
||||
labels := map[string]string{}
|
||||
tiered := func(label string, opts ObjectOptions) ObjectInfo {
|
||||
t.Helper()
|
||||
oi := putConsistencyObject(t, z, bucket, "obj", 0, "obj "+label, opts)
|
||||
vid := migrationTestVersion(oi)
|
||||
infos, errs := readAllFileInfo(ctx, z.serverPools[0].getHashedSet("obj").getDisks(), "", bucket, "obj", vid, false, false)
|
||||
if errs[0] != nil {
|
||||
t.Fatal(errs[0])
|
||||
}
|
||||
fi := infos[0]
|
||||
fi.TransitionStatus, fi.TransitionTier, fi.TransitionedObjName = lifecycle.TransitionComplete, "WARM", bucket+"/"+label
|
||||
if _, err := tier.Put(ctx, fi.TransitionedObjName, strings.NewReader("obj "+label), -1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := z.serverPools[0].DecomTieredObject(ctx, bucket, "obj", fi, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels[vid] = label
|
||||
return oi
|
||||
}
|
||||
var latest ObjectInfo
|
||||
if tc.versioning != "" {
|
||||
tiered("v1", ObjectOptions{Versioned: true})
|
||||
}
|
||||
if tc.nullLatest {
|
||||
latest = tiered("v2", ObjectOptions{})
|
||||
} else {
|
||||
latest = tiered("v2", ObjectOptions{Versioned: true})
|
||||
}
|
||||
|
||||
// Expire the latest version as the expiry worker does for the scanner.
|
||||
getOpts := ObjectOptions{Versioned: tc.versioning == "Enabled", VersionSuspended: tc.versioning == "Suspended"}
|
||||
oi, err := z.GetObjectInfo(ctx, bucket, "obj", getOpts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lc, err := globalLifecycleSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evt := evalActionFromLifecycle(ctx, *lc, objectlock.Retention{}, nil, oi)
|
||||
if evt.Action != lifecycle.DeleteAction || oi.TransitionedObject.Status != lifecycle.TransitionComplete {
|
||||
t.Fatalf("fixture: action %v, transition status %q", evt.Action, oi.TransitionedObject.Status)
|
||||
}
|
||||
if err := expireTransitionedObject(ctx, z, &oi, evt, lcEventSrc_Scanner); err != nil {
|
||||
t.Errorf("expiry: %v", err)
|
||||
}
|
||||
|
||||
var got []string
|
||||
for _, set := range z.serverPools[0].sets {
|
||||
err := set.listObjectsToDecommission(ctx, decomBucketInfo{Name: bucket}, func(entry metaCacheEntry) {
|
||||
fivs, err := entry.fileInfoVersions(bucket)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
for _, fi := range fivs.Versions {
|
||||
if fi.Deleted {
|
||||
got = append(got, "DM")
|
||||
} else {
|
||||
got = append(got, labels[migrationTestVersion(fi.ToObjectInfo(bucket, "obj", true))])
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Errorf("versions after expiry: got %v, want %v", got, tc.want)
|
||||
}
|
||||
remote := bucket + "/v2"
|
||||
if removed := slices.Contains(tier.removed, remote); removed == tc.keep {
|
||||
t.Errorf("remote object of the expired version removed: %v, want %v", removed, !tc.keep)
|
||||
}
|
||||
if tc.keep {
|
||||
body := migrationTestBody(t, z.GetObjectNInfo, bucket, "obj", migrationTestVersion(latest))
|
||||
if body != "obj v2" {
|
||||
t.Errorf("v2 reads %q after the expiry, want %q", body, "obj v2")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4250,6 +4250,11 @@ func getCRCMeta(oi ObjectInfo, partNum int, h http.Header) (cs map[string]string
|
||||
if cktype.IsSet() {
|
||||
meta[cktype.Key()] = v
|
||||
meta[xhttp.AmzChecksumAlgo] = cktype.String()
|
||||
// Without the type the target creates a COMPOSITE multipart
|
||||
// upload and rejects the full object CRC on completion.
|
||||
if isMP && cktype.CanMerge() && cs[xhttp.AmzChecksumType] == xhttp.AmzChecksumTypeFullObject {
|
||||
meta[xhttp.AmzChecksumType] = xhttp.AmzChecksumTypeFullObject
|
||||
}
|
||||
}
|
||||
}
|
||||
return meta, isMP
|
||||
|
||||
+1
-1
@@ -883,7 +883,7 @@ func serverHandleEnvVars() {
|
||||
}
|
||||
globalInplaceUpdateDisabled = true
|
||||
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,
|
||||
|
||||
@@ -91,7 +91,7 @@ func readConfigWithoutMigrate(ctx context.Context, objAPI ObjectLayer) (config.C
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err = decryptData(data, configFile)
|
||||
data, err = decryptData(ctx, data, configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+3
-3
@@ -68,7 +68,7 @@ func listServerConfigHistory(ctx context.Context, objAPI ObjectLayer, withData b
|
||||
continue
|
||||
}
|
||||
|
||||
data, err = decryptData(data, obj.Name)
|
||||
data, err = decryptData(ctx, data, obj.Name)
|
||||
if err != nil {
|
||||
// ignore history file that cannot be loaded.
|
||||
continue
|
||||
@@ -110,7 +110,7 @@ func readServerConfigHistory(ctx context.Context, objAPI ObjectLayer, uuidKV str
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decryptData(data, historyFile)
|
||||
return decryptData(ctx, data, historyFile)
|
||||
}
|
||||
|
||||
func saveServerConfigHistory(ctx context.Context, objAPI ObjectLayer, kv []byte) error {
|
||||
@@ -162,7 +162,7 @@ func readServerConfig(ctx context.Context, objAPI ObjectLayer, data []byte) (con
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err = decryptData(data, configFile)
|
||||
data, err = decryptData(ctx, data, configFile)
|
||||
if err != nil {
|
||||
lookupConfigs(srvCfg, objAPI)
|
||||
return nil, err
|
||||
|
||||
@@ -126,8 +126,8 @@ func (d *multipartCleanupDisk) ReadVersion(context.Context, string, string, stri
|
||||
return FileInfo{ModTime: d.modTime}, d.readErr
|
||||
}
|
||||
|
||||
func (d *multipartCleanupDisk) StatVol(context.Context, string) (VolInfo, error) {
|
||||
return VolInfo{Created: time.Now()}, nil
|
||||
func (d *multipartCleanupDisk) StatVol(ctx context.Context, volume string) (VolInfo, error) {
|
||||
return (&xlStorage{drivePath: d.root}).StatVol(ctx, volume)
|
||||
}
|
||||
|
||||
func TestCleanupStaleUploadsOnDisk(t *testing.T) {
|
||||
@@ -157,7 +157,13 @@ func TestCleanupStaleUploadsOnDisk(t *testing.T) {
|
||||
}
|
||||
disk := &multipartCleanupDisk{root: t.TempDir(), modTime: modTime, readErr: tc.readErr}
|
||||
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)
|
||||
}
|
||||
erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk)
|
||||
@@ -169,9 +175,60 @@ func TestCleanupStaleUploadsOnDisk(t *testing.T) {
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("expired upload must be removed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(uploadDir)); !os.IsNotExist(err) {
|
||||
t.Errorf("empty hash directory must be removed in the same pass: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("upload must be preserved: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleUploadsEmptyHashDirectory(t *testing.T) {
|
||||
for _, age := range []time.Duration{0, 2 * globalAPIConfig.getStaleUploadsExpiry()} {
|
||||
t.Run(age.String(), func(t *testing.T) {
|
||||
disk := &multipartCleanupDisk{root: t.TempDir()}
|
||||
shaDir := filepath.Join(disk.root, minioMetaMultipartBucket, "sha")
|
||||
for _, dir := range []string{shaDir, filepath.Join(disk.root, minioMetaTmpDeletedBucket)} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
modTime := time.Now().Add(-age)
|
||||
if err := os.Chtimes(shaDir, modTime, modTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk)
|
||||
if _, err := os.Stat(shaDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("empty hash directory must be removed regardless of age: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStaleUploadsSharedHashDirectory(t *testing.T) {
|
||||
disk := &multipartCleanupDisk{root: t.TempDir(), readErr: errFileNotFound}
|
||||
shaDir := filepath.Join(disk.root, minioMetaMultipartBucket, "sha")
|
||||
now := time.Now()
|
||||
expiredID := fmt.Sprintf("d16ad42a-6fe5-4a3b-8092-69983142238fx%d", now.Add(-2*globalAPIConfig.getStaleUploadsExpiry()).UnixNano())
|
||||
recentID := fmt.Sprintf("4eacd477-e733-4851-b821-6fd42785ff87x%d", now.UnixNano())
|
||||
for _, id := range []string{expiredID, recentID} {
|
||||
if err := os.MkdirAll(filepath.Join(shaDir, id), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
partPath := filepath.Join(shaDir, recentID, "part.1")
|
||||
const part = "recent upload part"
|
||||
if err := os.WriteFile(partPath, []byte(part), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
erasureObjects{}.cleanupStaleUploadsOnDisk(t.Context(), disk)
|
||||
if _, err := os.Stat(filepath.Join(shaDir, expiredID)); !os.IsNotExist(err) {
|
||||
t.Errorf("expired upload must be removed: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(partPath); err != nil || string(got) != part {
|
||||
t.Errorf("recent upload must remain intact: got %q, err %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// Run explicitly with -benchtime=1x. Fixture creation is excluded from timing;
|
||||
// each request still discovers real on-disk metadata. The 16-drive/10k case
|
||||
// deliberately exceeds the existing aggregate directory-entry budget.
|
||||
func BenchmarkMultipartListingCapacity(b *testing.B) {
|
||||
func BenchmarkMultipartDiagnosticScanCapacity(b *testing.B) {
|
||||
for _, tc := range []struct {
|
||||
drives, uploads int
|
||||
mixed, limited bool
|
||||
@@ -67,7 +67,7 @@ func BenchmarkMultipartListingCapacity(b *testing.B) {
|
||||
}
|
||||
b.ResetTimer()
|
||||
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 {
|
||||
var limited SlowDown
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ func (z *erasureServerPools) multipartPreflight(ctx context.Context) (multipartP
|
||||
return multipartPreflightReport{}, err
|
||||
}
|
||||
defer s.close()
|
||||
report := multipartPreflightReport{Complete: true, Mode: "persistent"}
|
||||
report := multipartPreflightReport{Complete: true, Mode: "legacy"}
|
||||
for p, pool := range z.serverPools {
|
||||
for i, set := range pool.sets {
|
||||
_, _ = set.scanMultipartUploads(s, "", p, i)
|
||||
|
||||
@@ -22,10 +22,53 @@ import (
|
||||
"github.com/minio/minio/internal/config/storageclass"
|
||||
)
|
||||
|
||||
// Check the real handler and storage path, including a successful abort between
|
||||
// pages. Choose IDs increasing in both initiation time and lexical order so the
|
||||
// failure does not depend on differing interpretations of S3 marker ordering.
|
||||
func TestMultipartListingAbortBetweenHTTPPages(t *testing.T) {
|
||||
// The persistent scanner is retained for diagnostics. This test-only wrapper
|
||||
// preserves its pagination coverage without exposing it through public listing.
|
||||
func (z *erasureServerPools) scanMultipartUploadsForTest(ctx context.Context, bucket, prefix, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (ListMultipartsInfo, error) {
|
||||
if err := checkListMultipartArgs(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter); err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
if _, err := z.GetBucketInfo(ctx, bucket, BucketOptions{}); err != nil {
|
||||
return ListMultipartsInfo{}, toObjectErr(err, bucket)
|
||||
}
|
||||
scan, err := startMultipartScan(ctx, false)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
defer scan.close()
|
||||
|
||||
var uploads []MultipartInfo
|
||||
for idx, pool := range z.serverPools {
|
||||
if z.IsSuspended(idx) {
|
||||
continue
|
||||
}
|
||||
poolUploads, err := pool.scanMultipartUploadsForTest(scan, bucket, idx)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
uploads = append(uploads, poolUploads...)
|
||||
}
|
||||
|
||||
// Confirmed historical records lack a recoverable bucket/key and are
|
||||
// omitted by the scan. They do not block listing identifiable uploads.
|
||||
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
|
||||
}
|
||||
|
||||
func (s *erasureSets) scanMultipartUploadsForTest(scan *multipartScan, bucket string, poolIdx int) ([]MultipartInfo, error) {
|
||||
var uploads []MultipartInfo
|
||||
for i, set := range s.sets {
|
||||
setUploads, err := set.scanMultipartUploads(scan, bucket, poolIdx, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uploads = append(uploads, setUploads...)
|
||||
}
|
||||
return uploads, nil
|
||||
}
|
||||
|
||||
// Check that a successful HTTP abort removes the cached upload. The restored
|
||||
// cache path still ignores page limits; this is a documented legacy limitation.
|
||||
func TestMultipartListingAbortHTTPUpdatesCache(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads", "AbortMultipart"}, MakeBucketOptions{})
|
||||
if err != nil {
|
||||
@@ -81,8 +124,8 @@ func TestMultipartListingAbortBetweenHTTPPages(t *testing.T) {
|
||||
return result
|
||||
}
|
||||
first := list("", "", 1)
|
||||
if len(first.Uploads) != 1 || first.Uploads[0].UploadID != firstID || !first.IsTruncated {
|
||||
t.Fatalf("unexpected first page: %+v", first)
|
||||
if len(first.Uploads) != 3 || first.Uploads[0].UploadID != firstID || first.IsTruncated {
|
||||
t.Fatalf("unexpected legacy cache view: %+v", first)
|
||||
}
|
||||
rec := request(http.MethodDelete, getAbortMultipartUploadURL("", bucket, "a", firstID))
|
||||
if rec.Code != http.StatusNoContent {
|
||||
@@ -93,12 +136,12 @@ func TestMultipartListingAbortBetweenHTTPPages(t *testing.T) {
|
||||
for _, upload := range rest.Uploads {
|
||||
keys = append(keys, upload.Key)
|
||||
}
|
||||
t.Logf("GET first page=200; DELETE marker=204; GET next page=200 keys=%v truncated=%v", keys, rest.IsTruncated)
|
||||
t.Logf("GET cache=200; DELETE upload=204; GET cache=200 keys=%v truncated=%v", keys, rest.IsTruncated)
|
||||
if _, err := z.GetMultipartInfo(t.Context(), bucket, "a", secondID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("remaining upload is not valid: %v", err)
|
||||
}
|
||||
if len(rest.Uploads) != 2 || rest.Uploads[0].UploadID != secondID {
|
||||
t.Fatalf("valid remaining upload for key a is missing from continuation: %+v", rest.Uploads)
|
||||
t.Fatalf("valid remaining upload for key a is missing from cache: %+v", rest.Uploads)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +192,7 @@ func TestMultipartListingLegacyPreflight(t *testing.T) {
|
||||
}
|
||||
z.mpCache.Clear()
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -159,14 +202,14 @@ func TestMultipartListingLegacyPreflight(t *testing.T) {
|
||||
requireMultipartUploadKeys(t, got)
|
||||
}
|
||||
}
|
||||
// Exact-looking prefixes now have the same durable semantics.
|
||||
exact, err := z.ListMultipartUploads(t.Context(), other, "old", "", "", "", 10)
|
||||
// Diagnostic discovery cannot recover an unidentified historical key.
|
||||
exact, err := z.scanMultipartUploadsForTest(t.Context(), other, "old", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, exact)
|
||||
report, err := z.multipartPreflight(t.Context())
|
||||
if err != nil || report.Ready || !report.Complete || report.LegacyUploads != 1 || report.Mode != "persistent" {
|
||||
if err != nil || report.Ready || !report.Complete || report.LegacyUploads != 1 || report.Mode != "legacy" {
|
||||
t.Fatalf("legacy preflight: %+v %v", report, err)
|
||||
}
|
||||
if err = z.AbortMultipartUpload(t.Context(), other, "old", old.UploadID, ObjectOptions{}); err != nil {
|
||||
@@ -176,14 +219,14 @@ func TestMultipartListingLegacyPreflight(t *testing.T) {
|
||||
if err != nil || !report.Ready || report.LegacyUploads != 0 {
|
||||
t.Fatalf("drained preflight: %+v %v", report, err)
|
||||
}
|
||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, got, "a")
|
||||
}
|
||||
|
||||
func TestMultipartListingIdentityFallback(t *testing.T) {
|
||||
func TestMultipartDiagnosticIdentityFallback(t *testing.T) {
|
||||
for _, kind := range []string{"old", "partial", "corrupt", "read-failure", "wrong-bucket"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
@@ -220,7 +263,7 @@ func TestMultipartListingIdentityFallback(t *testing.T) {
|
||||
}
|
||||
return disks
|
||||
}
|
||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -469,7 +512,7 @@ func (d multipartListingCountingDisk) ReadVersion(ctx context.Context, original,
|
||||
}
|
||||
|
||||
// Counts storage API calls; this is not a deployment throughput benchmark.
|
||||
func TestMultipartListingScanCosts(t *testing.T) {
|
||||
func TestMultipartDiagnosticScanCosts(t *testing.T) {
|
||||
obj, dirs, err := prepareErasure(t.Context(), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -507,7 +550,7 @@ func TestMultipartListingScanCosts(t *testing.T) {
|
||||
t.Helper()
|
||||
directoryCalls.Store(0)
|
||||
metadataCalls.Store(0)
|
||||
result, err := z.ListMultipartUploads(t.Context(), bucket, "dir/", "", "", "", 1)
|
||||
result, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "dir/", "", "", "", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -589,7 +632,7 @@ func TestMultipartListingMissingMarkers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingCoverage(t *testing.T) {
|
||||
func TestMultipartDiagnosticCoverage(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
@@ -605,12 +648,12 @@ func TestMultipartListingCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return []StorageAPI{disks[0], disks[1], nil, nil} }
|
||||
_, err = z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
_, err = z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
||||
t.Fatalf("incomplete discovery returned success: %v", err)
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return []StorageAPI{nil, disks[1], disks[2], disks[3]} }
|
||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -621,7 +664,7 @@ func TestMultipartListingCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingBudgetAndAdmission(t *testing.T) {
|
||||
func TestMultipartDiagnosticBudgetAndAdmission(t *testing.T) {
|
||||
_, set, bucket := multipartListingFixture(t)
|
||||
if _, err := set.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -651,7 +694,7 @@ func TestMultipartListingBudgetAndAdmission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingAdmissionHTTP(t *testing.T) {
|
||||
func TestMultipartPublicListingAdmissionHTTP(t *testing.T) {
|
||||
z, _, _ := multipartListingFixture(t)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads"}, MakeBucketOptions{})
|
||||
if err != nil {
|
||||
@@ -673,12 +716,12 @@ func TestMultipartListingAdmissionHTTP(t *testing.T) {
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var response APIErrorResponse
|
||||
var response ListMultipartUploadsResponse
|
||||
if err := xml.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusServiceUnavailable || response.Code != "SlowDown" {
|
||||
t.Fatalf("admission returned %d %s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusOK || len(response.Uploads) != 0 {
|
||||
t.Fatalf("diagnostic admission blocked public listing: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,12 +748,12 @@ func TestMultipartListingPreflightMinorityLegacy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingCancellationRetainsAdmission(t *testing.T) {
|
||||
func TestMultipartDiagnosticCancellationRetainsAdmission(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
for i := range 40 {
|
||||
if _, err := z.NewMultipartUpload(t.Context(), bucket, fmt.Sprintf("key-%02d", i), ObjectOptions{}); err != nil {
|
||||
@@ -749,7 +792,7 @@ func TestMultipartListingCancellationRetainsAdmission(t *testing.T) {
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := z.ListMultipartUploads(ctx, bucket, "", "", "", "", 10)
|
||||
_, err := z.scanMultipartUploadsForTest(ctx, bucket, "", "", "", "", 10)
|
||||
done <- err
|
||||
}()
|
||||
for range 16 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,10 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
@@ -33,14 +32,10 @@ func removeMultipartIdentity(metadata map[string]string) {
|
||||
delete(metadata, multipartMetaObject)
|
||||
}
|
||||
|
||||
// The actual S3 handler must use the same semantics for every prefix and page,
|
||||
// even with old records and a stale, misleading node-local cache.
|
||||
func TestMultipartListingMixedHTTP(t *testing.T) {
|
||||
z, set, _ := multipartListingFixture(t)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads"}, MakeBucketOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The retained diagnostic scanner handles mixed records independently of a
|
||||
// stale cache. Public HTTP behavior is tested separately.
|
||||
func TestMultipartDiagnosticMixed(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
for _, key := range []string{"dir/old", "dir/a", "dir/sub/b", "outside"} {
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, key, ObjectOptions{})
|
||||
if err != nil {
|
||||
@@ -54,22 +49,11 @@ func TestMultipartListingMixedHTTP(t *testing.T) {
|
||||
z.mpCache.Store("stale", MultipartInfo{Bucket: bucket, Object: "ghost", UploadID: "stale"})
|
||||
list := func(prefix, keyMarker, uploadMarker, delimiter string) ListMultipartUploadsResponse {
|
||||
t.Helper()
|
||||
req, err := newTestSignedRequestV4(http.MethodGet,
|
||||
getListMultipartUploadsURLWithParams("", bucket, prefix, keyMarker, uploadMarker, delimiter, "1"),
|
||||
0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, prefix, keyMarker, uploadMarker, delimiter, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
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
|
||||
return generateListMultipartUploadsResponse(bucket, got, "")
|
||||
}
|
||||
var keys []string
|
||||
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"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
@@ -142,7 +126,7 @@ func TestMultipartListingInvalidIdentity(t *testing.T) {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -193,9 +184,18 @@ func TestMultipartListingRestart(t *testing.T) {
|
||||
if got.Uploads[0].UploadID != mp.UploadID {
|
||||
t.Fatal("restart changed upload identity")
|
||||
}
|
||||
part, err := obj.PutObjectPart(t.Context(), bucket, key, mp.UploadID, 1,
|
||||
mustGetPutObjReader(t, bytes.NewBufferString("part"), 4, "", ""), ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("continue known upload after restart: %v", err)
|
||||
}
|
||||
if _, err := obj.CompleteMultipartUpload(t.Context(), bucket, key, mp.UploadID,
|
||||
[]CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("complete known upload after restart: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingConflictingIdentityCopies(t *testing.T) {
|
||||
func TestMultipartDiagnosticConflictingIdentityCopies(t *testing.T) {
|
||||
for _, oldCopies := range []int{1, 3} {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
@@ -213,14 +213,14 @@ func TestMultipartListingConflictingIdentityCopies(t *testing.T) {
|
||||
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 {
|
||||
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} {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
@@ -244,7 +244,7 @@ func TestMultipartListingUnsupportedStoredID(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
got, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 10)
|
||||
if historical {
|
||||
if err != nil || len(got.Uploads) != 0 {
|
||||
t.Fatalf("unidentified historical ID: %+v %v", got, err)
|
||||
|
||||
@@ -197,6 +197,7 @@ func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk Sto
|
||||
drivePath := disk.Endpoint().Path
|
||||
|
||||
readDirFn(pathJoin(drivePath, minioMetaMultipartBucket), func(shaDir string, typ os.FileMode) error {
|
||||
hasSkippedUploads := false
|
||||
readDirFn(pathJoin(drivePath, minioMetaMultipartBucket, shaDir), func(uploadIDDir string, typ os.FileMode) error {
|
||||
uploadIDPath := pathJoin(shaDir, uploadIDDir)
|
||||
modTime := extractModTimeFromUploadIDDir(uploadIDDir)
|
||||
@@ -205,12 +206,14 @@ func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk Sto
|
||||
wait := deleteMultipartCleanupSleeper.Timer(ctx)
|
||||
fi, err := disk.ReadVersion(ctx, "", minioMetaMultipartBucket, uploadIDPath, "", ReadOptions{})
|
||||
if err != nil {
|
||||
hasSkippedUploads = true
|
||||
return nil
|
||||
}
|
||||
modTime = fi.ModTime
|
||||
wait()
|
||||
}
|
||||
if time.Since(modTime) < globalAPIConfig.getStaleUploadsExpiry() {
|
||||
hasSkippedUploads = true
|
||||
return nil
|
||||
}
|
||||
w := xioutil.NewDeadlineWorker(globalDriveConfig.GetMaxTimeout())
|
||||
@@ -223,24 +226,15 @@ func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk Sto
|
||||
return nil
|
||||
})
|
||||
})
|
||||
// Get the modtime of the shaDir.
|
||||
vi, err := disk.StatVol(ctx, pathJoin(minioMetaMultipartBucket, shaDir))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Modtime is returned in the Created field. See (*xlStorage).StatVol
|
||||
if time.Since(vi.Created) < globalAPIConfig.getStaleUploadsExpiry() {
|
||||
if hasSkippedUploads {
|
||||
return nil
|
||||
}
|
||||
w := xioutil.NewDeadlineWorker(globalDriveConfig.GetMaxTimeout())
|
||||
return w.Run(func() error {
|
||||
wait := deleteMultipartCleanupSleeper.Timer(ctx)
|
||||
pathUUID := mustGetUUID()
|
||||
targetPath := pathJoin(drivePath, minioMetaTmpDeletedBucket, pathUUID)
|
||||
|
||||
// We are not deleting shaDir recursively here, if shaDir is empty
|
||||
// and its older then we can happily delete it.
|
||||
Rename(pathJoin(drivePath, minioMetaMultipartBucket, shaDir), targetPath)
|
||||
// Removing an upload updates the parent's mtime. Remove the parent
|
||||
// immediately if it is empty; other uploads keep it in place.
|
||||
_ = Remove(pathJoin(drivePath, minioMetaMultipartBucket, shaDir))
|
||||
wait()
|
||||
return nil
|
||||
})
|
||||
@@ -414,7 +408,7 @@ func paginateMultipartUploads(uploads []MultipartInfo, prefix, keyMarker, upload
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
scan, err := startMultipartScan(ctx, false)
|
||||
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
|
||||
return er.listMultipartUploadsExact(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
||||
}
|
||||
|
||||
// newMultipartUpload - wrapper for initializing a new multipart
|
||||
|
||||
@@ -1891,6 +1891,23 @@ func (er erasureObjects) DeleteObjects(ctx context.Context, bucket string, objec
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if err == errFileNotFound || err == errFileVersionNotFound {
|
||||
// A retry after partial physical deletion may mix removals
|
||||
// with already-absent replies. Both prove the desired outcome.
|
||||
// Marker creation and replication-state updates still require
|
||||
// successful writes; absence cannot acknowledge those operations.
|
||||
physical := true
|
||||
for _, v := range dedupVersions[i].Versions {
|
||||
if v.Deleted || v.MarkDeleted || v.ReplicationState.ReplicationStatusInternal != "" ||
|
||||
v.ReplicationState.VersionPurgeStatusInternal != "" || v.ReplicationState.ReplicaStatus != "" {
|
||||
physical = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if physical {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, v := range dedupVersions[i].Versions {
|
||||
delObjErrs[index][v.Idx] = err
|
||||
}
|
||||
@@ -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()
|
||||
if purge {
|
||||
// A delete marker describes the version being removed; it is not an
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
madmin "github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/pgsty/silo-pkg/v3/sync/errgroup"
|
||||
)
|
||||
|
||||
// objectPoolInfos reads the addressed version in every pool, including pools
|
||||
@@ -62,6 +63,125 @@ func (z *erasureServerPools) objectPoolInfos(ctx context.Context, bucket, object
|
||||
return copies, nil
|
||||
}
|
||||
|
||||
// checkDataMovementCopies rejects moving a version when another pool holds a
|
||||
// newer copy of it, which reads return instead. A cross-pool overwrite can
|
||||
// leave such a copy, and a client write can add one while the move copies.
|
||||
// Equal timestamps normally identify an interrupted move. Conflicting ties
|
||||
// retain the same pool-order winner as reads. While the caller holds the
|
||||
// pools-layer object lock, the returned resolver serves the set layer's
|
||||
// reconciliation of this version from the same reads. Without opts.NoLock
|
||||
// it takes the read lock itself.
|
||||
func (z *erasureServerPools) checkDataMovementCopies(ctx context.Context, bucket, object string, opts ObjectOptions) (GetObjectInfoFn, error) {
|
||||
if opts.MTime.IsZero() {
|
||||
// The set layer would stamp the moved copy as the newest one.
|
||||
return nil, errInvalidArgument
|
||||
}
|
||||
if !opts.NoLock {
|
||||
lk := z.NewNSLock(bucket, object)
|
||||
lkctx, err := lk.GetRLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx = lkctx.Context()
|
||||
defer lk.RUnlock(lkctx)
|
||||
}
|
||||
versionID := opts.VersionID
|
||||
if versionID == "" {
|
||||
versionID = nullVersionID
|
||||
}
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, object, ObjectOptions{VersionID: versionID, NoAuditLog: true})
|
||||
if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
// The snapshot may have been purged or replaced after the reader opened.
|
||||
// In that case the walker must leave the current source state alone.
|
||||
sourcePresent := false
|
||||
for _, copy := range copies {
|
||||
if copy.Index == opts.SrcPoolIdx && copy.ObjInfo.ModTime.Equal(opts.MTime) && copy.ObjInfo.DeleteMarker == opts.DeleteMarker {
|
||||
sourcePresent = true
|
||||
}
|
||||
}
|
||||
if !sourcePresent {
|
||||
return nil, VersionNotFound{Bucket: bucket, Object: decodeDirObject(object), VersionID: versionID}
|
||||
}
|
||||
for _, copy := range copies {
|
||||
if copy.Index != opts.SrcPoolIdx && copy.ObjInfo.ModTime.After(opts.MTime) {
|
||||
return nil, DataMovementOverwriteErr{Bucket: bucket, Object: object, VersionID: opts.VersionID, Err: errDataMovementSuperseded}
|
||||
}
|
||||
}
|
||||
if winner := copies[0]; winner.Index != opts.SrcPoolIdx && winner.ObjInfo.ModTime.Equal(opts.MTime) {
|
||||
for _, copy := range copies {
|
||||
if copy.Index == opts.SrcPoolIdx && (copy.ObjInfo.ETag != winner.ObjInfo.ETag || copy.ObjInfo.DeleteMarker != winner.ObjInfo.DeleteMarker || copy.ObjInfo.Size != winner.ObjInfo.Size) {
|
||||
return nil, DataMovementOverwriteErr{Bucket: bucket, Object: object, VersionID: opts.VersionID, Err: errDataMovementSuperseded}
|
||||
}
|
||||
}
|
||||
}
|
||||
return func(ctx context.Context, b, o string, ropts ObjectOptions) (ObjectInfo, error) {
|
||||
if ropts.VersionID == "" {
|
||||
ropts.VersionID = nullVersionID
|
||||
}
|
||||
if b != bucket || o != object || ropts.VersionID != versionID {
|
||||
return z.replicaObjectInfo(ctx, b, o, ropts)
|
||||
}
|
||||
return replicaCopiesInfo(copies, err, bucket, object, versionID)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// removeMovedVersions retires only the source versions in the walker's
|
||||
// snapshot. A concurrent delete can create a new marker in the source pool;
|
||||
// deleting the whole prefix would remove that marker and expose moved data.
|
||||
func (z *erasureServerPools) removeMovedVersions(ctx context.Context, bucket, object string, source int, versions []FileInfo) error {
|
||||
object = encodeDirObject(object)
|
||||
lk := z.NewNSLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = lkctx.Context()
|
||||
defer lk.Unlock(lkctx)
|
||||
for _, version := range versions {
|
||||
vid := version.VersionID
|
||||
if vid == "" {
|
||||
vid = nullVersionID
|
||||
}
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, object, ObjectOptions{VersionID: vid, NoAuditLog: true})
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var src *ObjectInfo
|
||||
var retained []PoolObjInfo
|
||||
for _, copy := range copies {
|
||||
if copy.Index == source {
|
||||
src = ©.ObjInfo
|
||||
} else {
|
||||
retained = append(retained, copy)
|
||||
}
|
||||
}
|
||||
if src == nil {
|
||||
continue
|
||||
}
|
||||
if !src.ModTime.Equal(version.ModTime) || src.DeleteMarker != version.Deleted {
|
||||
return PreConditionFailed{}
|
||||
}
|
||||
// Successful movement is not enough if the destination changed again.
|
||||
// Keep the source unless another copy still preserves its read result.
|
||||
if len(retained) == 0 || retained[0].ObjInfo.ModTime.Before(src.ModTime) {
|
||||
return PreConditionFailed{}
|
||||
}
|
||||
_, err = z.serverPools[source].DeleteObject(ctx, bucket, object, ObjectOptions{
|
||||
VersionID: vid, NoLock: true, NoAuditLog: true,
|
||||
SkipFreeVersion: true, // preserve the old prefix cleanup's tier-GC behavior
|
||||
})
|
||||
if err != nil && !isErrObjectNotFound(err) && !isErrVersionNotFound(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Healing also commits object metadata. Both queued healing and drive healing
|
||||
// must use the same namespace as pooled writes, rather than racing them under
|
||||
// a destination set's independent namespace.
|
||||
@@ -137,11 +257,15 @@ func (z *erasureServerPools) replicaObjectInfo(ctx context.Context, bucket, obje
|
||||
opts.VersionID = nullVersionID
|
||||
}
|
||||
copies, err := z.objectPoolInfos(ctx, bucket, object, opts)
|
||||
return replicaCopiesInfo(copies, err, bucket, object, opts.VersionID)
|
||||
}
|
||||
|
||||
func replicaCopiesInfo(copies []PoolObjInfo, err error, bucket, object, versionID string) (ObjectInfo, error) {
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
if copies[0].ObjInfo.DeleteMarker {
|
||||
return copies[0].ObjInfo, MethodNotAllowed{Bucket: bucket, Object: decodeDirObject(object), VersionID: opts.VersionID}
|
||||
return copies[0].ObjInfo, MethodNotAllowed{Bucket: bucket, Object: decodeDirObject(object), VersionID: versionID}
|
||||
}
|
||||
return mergedPoolObjectInfo(copies), nil
|
||||
}
|
||||
@@ -410,3 +534,110 @@ func (z *erasureServerPools) deleteObjectReconciled(ctx context.Context, bucket,
|
||||
oi.replicationDecision = opts.DeleteReplication.ReplicateDecisionStr
|
||||
return oi, err
|
||||
}
|
||||
|
||||
// deleteObjectsReconciled removes data while retaining each item's authoritative
|
||||
// copy until every other pool has completed its cleanup. The caller holds the
|
||||
// pools-layer write locks and separates marker creation from this batch.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1071,6 +1071,8 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
restored bool
|
||||
unconditional bool
|
||||
skipFreeVersion bool
|
||||
bulk bool
|
||||
unversioned bool
|
||||
}{
|
||||
{name: "metadata-copy"},
|
||||
{name: "restored-metadata-copy", restored: true},
|
||||
@@ -1081,6 +1083,14 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
{name: "ordinary-failed-primary-delete", deleting: true, unconditional: true, failPrimaryDelete: true},
|
||||
{name: "ordinary-successful-delete", deleting: true, unconditional: true},
|
||||
{name: "ordinary-skip-free-version", deleting: true, unconditional: true, skipFreeVersion: true},
|
||||
{name: "bulk-failed-primary-delete", deleting: true, bulk: true, failPrimaryDelete: true},
|
||||
{name: "bulk-restored-failed-primary-delete", deleting: true, bulk: true, restored: true, failPrimaryDelete: true},
|
||||
{name: "bulk-successful-delete", deleting: true, bulk: true},
|
||||
{name: "bulk-distinct-reference", deleting: true, bulk: true, differentRemote: true},
|
||||
{name: "bulk-skip-free-version", deleting: true, bulk: true, skipFreeVersion: true},
|
||||
{name: "unversioned-failed-primary-delete", deleting: true, unconditional: true, unversioned: true, failPrimaryDelete: true},
|
||||
{name: "unversioned-successful-delete", deleting: true, unconditional: true, unversioned: true},
|
||||
{name: "bulk-unversioned-failed-primary-delete", deleting: true, bulk: true, unversioned: true, failPrimaryDelete: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
@@ -1094,13 +1104,13 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
if test.restored {
|
||||
metadata[xhttp.AmzRestore] = completedRestoreObj(time.Now().Add(time.Hour)).String()
|
||||
}
|
||||
oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{Versioned: true, UserDefined: metadata})
|
||||
oi := putConsistencyObject(t, z, bucket, object, 0, "data", ObjectOptions{Versioned: !test.unversioned, UserDefined: metadata})
|
||||
secondaryMetadata := maps.Clone(metadata)
|
||||
if test.differentRemote {
|
||||
secondaryMetadata[ReservedMetadataPrefixLower+TransitionedObjectName] = "other-remote-object"
|
||||
}
|
||||
putConsistencyObject(t, z, bucket, object, 1, "data", ObjectOptions{
|
||||
Versioned: true, VersionID: oi.VersionID, MTime: oi.ModTime, UserDefined: secondaryMetadata,
|
||||
Versioned: !test.unversioned, VersionID: oi.VersionID, MTime: oi.ModTime, UserDefined: secondaryMetadata,
|
||||
})
|
||||
current, err := z.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
|
||||
if err != nil || current.TransitionedObject.Status != "complete" || current.IsRemote() == test.restored {
|
||||
@@ -1113,7 +1123,11 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
set := z.serverPools[0].getHashedSet(object)
|
||||
getDisks := set.getDisks
|
||||
faulty := append([]StorageAPI(nil), getDisks()...)
|
||||
var failed, attempted atomic.Int32
|
||||
for i := range faulty {
|
||||
if test.bulk {
|
||||
faulty[i] = bulkDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, mode: "eio", fail: true, failed: &failed, attempted: &attempted}
|
||||
}
|
||||
faulty[i] = consistencyDeleteFaultDisk{StorageAPI: faulty[i], bucket: bucket, object: object, version: oi.VersionID}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return faulty }
|
||||
@@ -1121,13 +1135,20 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
}
|
||||
if test.deleting {
|
||||
opts := ObjectOptions{
|
||||
Versioned: true, VersionID: oi.VersionID, SkipFreeVersion: test.skipFreeVersion,
|
||||
Versioned: !test.unversioned, VersionID: oi.VersionID, SkipFreeVersion: test.skipFreeVersion,
|
||||
CheckPrecondFn: func(info ObjectInfo) bool { return info.ETag != current.ETag },
|
||||
}
|
||||
if test.unconditional {
|
||||
opts.CheckPrecondFn = nil
|
||||
}
|
||||
_, err := z.DeleteObject(t.Context(), bucket, object, opts)
|
||||
var err error
|
||||
if test.bulk {
|
||||
opts.CheckPrecondFn = nil
|
||||
_, errs := z.DeleteObjects(t.Context(), bucket, []ObjectToDelete{{ObjectV: ObjectV{ObjectName: object, VersionID: oi.VersionID}}}, opts)
|
||||
err = errs[0]
|
||||
} else {
|
||||
_, err = z.DeleteObject(t.Context(), bucket, object, opts)
|
||||
}
|
||||
if (err != nil) != test.failPrimaryDelete {
|
||||
t.Fatalf("unexpected authoritative delete result: %v", err)
|
||||
}
|
||||
@@ -1145,7 +1166,7 @@ func TestPoolsRetiringCopyPreservesSharedTierObject(t *testing.T) {
|
||||
_, err = z.serverPools[0].GetObjectInfo(t.Context(), bucket, object, ObjectOptions{VersionID: oi.VersionID})
|
||||
primaryDeleted := test.deleting && !test.failPrimaryDelete
|
||||
if primaryDeleted {
|
||||
if !isErrVersionNotFound(err) {
|
||||
if !isErrVersionNotFound(err) && (!test.unversioned || !isErrObjectNotFound(err)) {
|
||||
t.Fatalf("authoritative copy survived successful delete: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
@@ -1306,3 +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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +616,12 @@ func (z *erasureServerPools) decommissionObject(ctx context.Context, idx int, bu
|
||||
}
|
||||
|
||||
if objInfo.isMultipart() {
|
||||
// Completion repeats this check under the object write lock.
|
||||
if _, err := z.checkDataMovementCopies(ctx, bucket, encodeDirObject(objInfo.Name), ObjectOptions{
|
||||
VersionID: objInfo.VersionID, MTime: objInfo.ModTime, SrcPoolIdx: idx,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := z.NewMultipartUpload(ctx, bucket, objInfo.Name, ObjectOptions{
|
||||
VersionID: objInfo.VersionID,
|
||||
UserDefined: migrationObjectMetadata(objInfo),
|
||||
@@ -656,6 +662,7 @@ func (z *erasureServerPools) decommissionObject(ctx context.Context, idx int, bu
|
||||
}
|
||||
}
|
||||
_, err = z.CompleteMultipartUpload(ctx, bucket, objInfo.Name, res.UploadID, parts, ObjectOptions{
|
||||
VersionID: objInfo.VersionID,
|
||||
SrcPoolIdx: idx,
|
||||
DataMovement: true,
|
||||
MTime: objInfo.ModTime,
|
||||
@@ -825,27 +832,17 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
// to create the appropriate stack.
|
||||
versionsSorter(fivs.Versions).reverse()
|
||||
|
||||
var decommissioned, expired int
|
||||
var decommissioned int
|
||||
for _, version := range fivs.Versions {
|
||||
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionObject, idx, bi.Name, version.Name, version.VersionID)
|
||||
// Apply lifecycle rules on the objects that are expired.
|
||||
if filterLifecycle(bi.Name, version.Name, version) {
|
||||
expired++
|
||||
decommissioned++
|
||||
stopFn(version.Size, errors.New("ILM expired object/version will be skipped"))
|
||||
continue
|
||||
}
|
||||
|
||||
// any object with only single DEL marker we don't need
|
||||
// to decommission, just skip it, this also includes
|
||||
// any other versions that have already expired.
|
||||
remainingVersions := len(fivs.Versions) - expired
|
||||
if version.Deleted && remainingVersions == 1 && rcfg == nil {
|
||||
decommissioned++
|
||||
stopFn(version.Size, errors.New("DELETE marked object with no other non-current versions will be skipped"))
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve every marker: older versions may live in another pool.
|
||||
versionID := version.VersionID
|
||||
if versionID == "" {
|
||||
versionID = nullVersionID
|
||||
@@ -870,6 +867,9 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
SkipDecommissioned: true, // make sure we skip the decommissioned pool
|
||||
NoAuditLog: true,
|
||||
})
|
||||
if isDataMovementSuperseded(err) {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
// This can happen when rebalance stop races with ongoing rebalance workers.
|
||||
// These rebalance failures can be ignored.
|
||||
@@ -898,17 +898,19 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
// gr.Close() is ensured by decommissionObject().
|
||||
for range 3 {
|
||||
if version.IsRemote() {
|
||||
if err := z.DecomTieredObject(ctx, bi.Name, version.Name, version, ObjectOptions{
|
||||
err := z.DecomTieredObject(ctx, bi.Name, version.Name, version, ObjectOptions{
|
||||
VersionID: versionID,
|
||||
MTime: version.ModTime,
|
||||
UserDefined: version.Metadata,
|
||||
SrcPoolIdx: idx,
|
||||
DataMovement: true,
|
||||
}); err != nil {
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
||||
ignore = true
|
||||
stopFn(0, nil)
|
||||
}
|
||||
})
|
||||
if isDataMovementSuperseded(err) {
|
||||
err = nil
|
||||
}
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
||||
ignore = true
|
||||
stopFn(0, nil)
|
||||
}
|
||||
if !ignore {
|
||||
stopFn(version.Size, err)
|
||||
@@ -949,7 +951,7 @@ func (z *erasureServerPools) decommissionPool(ctx context.Context, idx int, pool
|
||||
stopFn(version.Size, err)
|
||||
continue
|
||||
}
|
||||
if err = z.decommissionObject(ctx, idx, bi.Name, gr); err != nil {
|
||||
if err = z.decommissionObject(ctx, idx, bi.Name, gr); err != nil && !isDataMovementSuperseded(err) {
|
||||
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isDataMovementOverWriteErr(err) {
|
||||
ignore = true
|
||||
stopFn(0, nil)
|
||||
@@ -979,15 +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 decommissioned == len(fivs.Versions) {
|
||||
stopFn := globalDecommissionMetrics.log(decomMetricDecommissionRemoveObject, idx, bi.Name, entry.name)
|
||||
_, err := set.DeleteObject(ctx,
|
||||
bi.Name,
|
||||
encodeDirObject(entry.name),
|
||||
ObjectOptions{
|
||||
DeletePrefix: true, // use prefix delete to delete all versions at once.
|
||||
DeletePrefixObject: true, // use prefix delete on exact object (this is an optimization to avoid fan-out calls)
|
||||
NoAuditLog: true,
|
||||
},
|
||||
)
|
||||
err := z.removeMovedVersions(ctx, bi.Name, entry.name, idx, fivs.Versions)
|
||||
stopFn(0, err)
|
||||
auditLogDecom(ctx, "DecomDeleteObject", bi.Name, entry.name, "", err)
|
||||
if err != nil {
|
||||
@@ -1196,10 +1190,6 @@ func (z *erasureServerPools) checkAfterDecom(ctx context.Context, idx int) error
|
||||
ignored++
|
||||
continue
|
||||
}
|
||||
if version.Deleted {
|
||||
ignored++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
versionsFound += len(fivs.Versions) - ignored
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
@@ -114,15 +116,18 @@ func migrationTestPools(t *testing.T) (*erasureServerPools, string) {
|
||||
t.Helper()
|
||||
z, bucket := consistencyPools(t)
|
||||
for _, pool := range z.serverPools {
|
||||
for _, set := range pool.sets {
|
||||
original := set.getDisks
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i := range disks {
|
||||
disks[i] = tagTestCapacityDisk{StorageAPI: disks[i]}
|
||||
pool.erasureDisksMu.Lock()
|
||||
previous := pool.erasureDisks
|
||||
wrapped := make([][]StorageAPI, len(previous))
|
||||
for i, disks := range previous {
|
||||
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
|
||||
}
|
||||
@@ -131,7 +136,10 @@ func migrationTestMover(t *testing.T, z *erasureServerPools, source int, kind st
|
||||
t.Helper()
|
||||
if kind == "rebalance" {
|
||||
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.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
|
||||
want = addressed
|
||||
case "movement":
|
||||
opts.DataMovement, opts.SrcPoolIdx = true, 1
|
||||
opts.DataMovement, opts.SrcPoolIdx, opts.MTime = true, 1, addressed.ModTime
|
||||
want = addressed
|
||||
case "tie":
|
||||
want = putConsistencyObject(t, z, bucket, object, 0, "tie-winner", ObjectOptions{Versioned: true, MTime: current.ModTime})
|
||||
|
||||
@@ -623,7 +623,7 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
||||
// to create the appropriate stack.
|
||||
versionsSorter(fivs.Versions).reverse()
|
||||
|
||||
var rebalanced, expired int
|
||||
var rebalanced int
|
||||
for _, version := range fivs.Versions {
|
||||
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.
|
||||
if filterLifecycle(bucket, version.Name, version) {
|
||||
expired++
|
||||
stopFn(version.Size, errors.New("ILM expired object/version will be skipped"))
|
||||
continue
|
||||
}
|
||||
|
||||
// any object with only single DEL marker we don't need
|
||||
// to 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
|
||||
}
|
||||
|
||||
// Preserve every marker: older versions may live in another pool.
|
||||
versionID := version.VersionID
|
||||
if versionID == "" {
|
||||
versionID = nullVersionID
|
||||
@@ -671,6 +661,9 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
||||
SkipRebalancing: true, // make sure we skip the decommissioned pool
|
||||
NoAuditLog: true,
|
||||
})
|
||||
if isDataMovementSuperseded(err) {
|
||||
err = nil
|
||||
}
|
||||
// This can happen when rebalance stop races with ongoing rebalance workers.
|
||||
// These rebalance failures can be ignored.
|
||||
if err != nil {
|
||||
@@ -719,7 +712,7 @@ func (z *erasureServerPools) rebalanceBucket(ctx context.Context, bucket string,
|
||||
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.
|
||||
// These rebalance failures can be ignored.
|
||||
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 rebalanced == len(fivs.Versions) {
|
||||
stopFn := globalRebalanceMetrics.log(rebalanceMetricRebalanceRemoveObject, poolIdx, bucket, entry.name)
|
||||
_, err := set.DeleteObject(ctx,
|
||||
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,
|
||||
},
|
||||
)
|
||||
err := z.removeMovedVersions(ctx, bucket, entry.name, poolIdx, fivs.Versions)
|
||||
stopFn(0, err)
|
||||
auditLogRebalance(ctx, "Rebalance:DeleteObject", bucket, entry.name, "", err)
|
||||
if err != nil {
|
||||
@@ -863,6 +848,12 @@ func (z *erasureServerPools) rebalanceObject(ctx context.Context, poolIdx int, b
|
||||
}
|
||||
|
||||
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{
|
||||
VersionID: oi.VersionID,
|
||||
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{
|
||||
VersionID: oi.VersionID,
|
||||
SrcPoolIdx: poolIdx,
|
||||
DataMovement: true,
|
||||
MTime: oi.ModTime,
|
||||
NoAuditLog: true,
|
||||
@@ -1010,7 +1003,10 @@ const (
|
||||
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 {
|
||||
var errStr string
|
||||
|
||||
+216
-70
@@ -39,6 +39,7 @@ import (
|
||||
"github.com/minio/minio-go/v7/pkg/set"
|
||||
"github.com/minio/minio-go/v7/pkg/tags"
|
||||
"github.com/minio/minio/internal/bpool"
|
||||
"github.com/minio/minio/internal/bucket/lifecycle"
|
||||
"github.com/minio/minio/internal/cachevalue"
|
||||
"github.com/minio/minio/internal/config/storageclass"
|
||||
xioutil "github.com/minio/minio/internal/ioutil"
|
||||
@@ -1183,6 +1184,14 @@ func (z *erasureServerPools) PutObject(ctx context.Context, bucket string, objec
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
// receiver: latest-key routing can select a different pool or miss copies.
|
||||
// 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
|
||||
}
|
||||
|
||||
// Datamovement must never be allowed on the same pool.
|
||||
if opts.DataMovement && opts.SrcPoolIdx == pinfo.Index {
|
||||
return pinfo.ObjInfo, DataMovementOverwriteErr{
|
||||
Bucket: bucket,
|
||||
Object: object,
|
||||
VersionID: opts.VersionID,
|
||||
Err: errDataMovementSrcDstPoolSame,
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DataMovement {
|
||||
objInfo, err = z.serverPools[pinfo.Index].DeleteObject(ctx, bucket, object, opts)
|
||||
objInfo.Name = decodeDirObject(object)
|
||||
return objInfo, err
|
||||
}
|
||||
|
||||
// Delete concurrently in all server pools with read quorum error for unversioned objects.
|
||||
// Retire older copies before the newest copy for unversioned objects.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1356,6 +1373,21 @@ func (z *erasureServerPools) DeleteObject(ctx context.Context, bucket string, ob
|
||||
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 {
|
||||
objInfo, err := pool.DeleteObject(ctx, bucket, object, opts)
|
||||
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) {
|
||||
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)
|
||||
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) {
|
||||
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) {
|
||||
@@ -1414,6 +1486,11 @@ func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, o
|
||||
objSets := set.NewStringSet()
|
||||
for i := range derrs {
|
||||
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)
|
||||
objSets.Add(objects[i].ObjectName)
|
||||
@@ -1431,25 +1508,64 @@ func (z *erasureServerPools) DeleteObjects(ctx context.Context, bucket string, o
|
||||
ctx = lkctx.Context()
|
||||
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))
|
||||
dErrsByPool := make([][]error, len(z.serverPools))
|
||||
|
||||
eg := errgroup.WithNErrs(len(z.serverPools)).WithConcurrency(len(z.serverPools))
|
||||
for i, pool := range z.serverPools {
|
||||
pool := pool
|
||||
eg.Go(func() error {
|
||||
dObjectsByPool[i], dErrsByPool[i] = pool.DeleteObjects(ctx, bucket, objects, opts)
|
||||
return nil
|
||||
}, i)
|
||||
if len(batch) > 0 {
|
||||
eg := errgroup.WithNErrs(len(z.serverPools)).WithConcurrency(len(z.serverPools))
|
||||
for i, pool := range z.serverPools {
|
||||
pool := pool
|
||||
eg.Go(func() error {
|
||||
dObjectsByPool[i], dErrsByPool[i] = pool.DeleteObjects(ctx, bucket, batch, opts)
|
||||
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
|
||||
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
|
||||
dobjects[i] = dObjectsByPool[pool][i]
|
||||
dobjects[i] = dObjectsByPool[pool][j]
|
||||
derrs[i] = nil
|
||||
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
|
||||
// whether it is nil because there is no object found or because of
|
||||
// some other errors such erasure quorum errors.
|
||||
dobjects[i] = dObjectsByPool[pool][i]
|
||||
derrs[i] = dErrsByPool[pool][i]
|
||||
dobjects[i] = dObjectsByPool[pool][j]
|
||||
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 {
|
||||
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 {
|
||||
if z.IsSuspended(idx) {
|
||||
continue
|
||||
}
|
||||
poolUploads, err := pool.scanMultipartUploads(scan, bucket, idx)
|
||||
result, err := pool.getHashedSet(prefix).listMultipartUploadsExact(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
||||
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
|
||||
// omitted by the scan. They do not block listing identifiable uploads.
|
||||
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
|
||||
return poolResult, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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
|
||||
@@ -2249,7 +2390,7 @@ func (z *erasureServerPools) CompleteMultipartUpload(ctx context.Context, bucket
|
||||
if opts.ReplicaLockReconcile || opts.DataMovement {
|
||||
opts.ReplicaLockReconcile = true
|
||||
}
|
||||
if opts.ReplicaLockReconcile || !opts.ReplicationRequest {
|
||||
if opts.replicaObjectInfo == nil && (opts.ReplicaLockReconcile || !opts.ReplicationRequest) {
|
||||
opts.replicaObjectInfo = z.replicaObjectInfo
|
||||
}
|
||||
|
||||
@@ -3240,6 +3381,11 @@ func (z *erasureServerPools) DecomTieredObject(ctx context.Context, bucket, obje
|
||||
defer ns.Unlock(lkctx)
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+1
-25
@@ -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) {
|
||||
if err := checkListMultipartArgs(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter); err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
scan, err := startMultipartScan(ctx, false)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
defer scan.close()
|
||||
uploads, 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
|
||||
return s.getHashedSet(prefix).ListMultipartUploads(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
||||
}
|
||||
|
||||
func (s *erasureSets) listMultipartUploadsExact(ctx context.Context, bucket, object string) (ListMultipartsInfo, error) {
|
||||
|
||||
@@ -85,15 +85,21 @@ func TestIAMPolicyDeletionRemainsExplicit(t *testing.T) {
|
||||
}
|
||||
p, err := sys.store.GetPolicy("readwrite")
|
||||
mustIAM(t, err)
|
||||
native, err := sys.InfoPolicy("readwrite")
|
||||
mustIAM(t, err)
|
||||
if err := sys.DeletePolicy(ctx, "readwrite", true); err == nil {
|
||||
t.Fatal("local pristine builtin policy became deletable")
|
||||
}
|
||||
_, err = sys.SetPolicy(ctx, "readwrite", p)
|
||||
mustIAM(t, err)
|
||||
mustIAM(t, sys.DeletePolicy(ctx, "readwrite", true))
|
||||
assertIAMPolicyResetInfo(t, sys, "readwrite", native)
|
||||
mustIAM(t, sys.store.LoadIAMCache(ctx, false))
|
||||
if _, err := sys.store.GetPolicy("readwrite"); !errors.Is(err, errNoSuchPolicy) {
|
||||
t.Fatalf("reload restored an explicitly deleted override: %v", err)
|
||||
assertIAMPolicyResetInfo(t, sys, "readwrite", native)
|
||||
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)
|
||||
mustIAM(t, err)
|
||||
|
||||
+15
-11
@@ -123,8 +123,8 @@ func (ies *IAMEtcdStore) saveIAMConfig(ctx context.Context, item any, itemPath s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ies *IAMEtcdStore) decodeIAMConfig(item any, data []byte, path string) error {
|
||||
data, err := decryptData(data, path)
|
||||
func (ies *IAMEtcdStore) decodeIAMConfig(ctx context.Context, item any, data []byte, path string) error {
|
||||
data, err := decryptData(ctx, data, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (ies *IAMEtcdStore) loadIAMConfigBytes(ctx context.Context, path string) ([
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err = decryptData(data, path)
|
||||
data, err = decryptData(ctx, data, path)
|
||||
if err == nil {
|
||||
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 {
|
||||
data, err := decryptData(kvs.Value, string(kvs.Key))
|
||||
data, err := decryptData(ctx, kvs.Value, string(kvs.Key))
|
||||
if err != nil {
|
||||
if err == errConfigNotFound {
|
||||
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 {
|
||||
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()
|
||||
// Retrieve all keys and values to avoid too many calls to etcd in case of
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse all values to construct the policies data model.
|
||||
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) {
|
||||
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 {
|
||||
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 == errConfigNotFound {
|
||||
return errNoSuchUser
|
||||
@@ -392,9 +396,9 @@ func (ies *IAMEtcdStore) loadMappedPolicy(ctx context.Context, name string, user
|
||||
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
|
||||
err := ies.decodeIAMConfig(&p, kv.Value, string(kv.Key))
|
||||
err := ies.decodeIAMConfig(ctx, &p, kv.Value, string(kv.Key))
|
||||
if err != nil {
|
||||
if err == errConfigNotFound {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+40
-5
@@ -108,7 +108,10 @@ func (iamOS *IAMObjectStore) saveIAMConfig(ctx context.Context, item any, objPat
|
||||
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) {
|
||||
return data, nil
|
||||
}
|
||||
@@ -118,13 +121,17 @@ func decryptData(data []byte, objPath string) ([]byte, error) {
|
||||
return pdata, 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),
|
||||
})
|
||||
if err == 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,
|
||||
})
|
||||
if err == nil {
|
||||
@@ -134,12 +141,37 @@ func decryptData(data []byte, objPath string) ([]byte, error) {
|
||||
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) {
|
||||
data, meta, err := readConfigWithMetadata(ctx, iamOS.objAPI, objPath, ObjectOptions{})
|
||||
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
|
||||
}
|
||||
data, err = decryptData(data, objPath)
|
||||
data, err = decryptData(ctx, data, objPath)
|
||||
if err != nil {
|
||||
return nil, meta, err
|
||||
}
|
||||
@@ -251,12 +283,15 @@ func (iamOS *IAMObjectStore) loadPolicyDocs(ctx context.Context, m map[string]Po
|
||||
return item.Err
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
policyName := path.Dir(item.Item)
|
||||
if err := iamOS.loadPolicyDoc(ctx, policyName, m); err != nil && !errors.Is(err, errNoSuchPolicy) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (iamOS *IAMObjectStore) loadSecretKey(ctx context.Context, user string, userType IAMUserType) (string, error) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
iamRevisionPeerPath = "/v3/site-replication/peer/iam-revisions"
|
||||
iamUserBoundaryType = "silo-user-revocation"
|
||||
iamGroupBoundaryType = "silo-group-revocation"
|
||||
iamSTSDeleteType = "silo-sts-revocation"
|
||||
maxIAMRevisionBatch = 128
|
||||
)
|
||||
|
||||
@@ -49,6 +50,7 @@ type iamGroupBoundary struct {
|
||||
// before applying any change that would lose revocation or member metadata.
|
||||
type iamReplicationItem struct {
|
||||
madmin.SRIAMItem
|
||||
STSExpiresAt time.Time `json:"stsExpiresAt,omitempty"`
|
||||
GroupGrants map[string]time.Time `json:"groupGrants,omitempty"`
|
||||
GroupSnapshot bool `json:"groupSnapshot,omitempty"`
|
||||
UserRevocation *iamUserBoundary `json:"userRevocation,omitempty"`
|
||||
@@ -155,6 +157,13 @@ type (
|
||||
|
||||
func (c *SiteReplicationSys) replicationItem(ctx context.Context, item madmin.SRIAMItem) (iamReplicationItem, error) {
|
||||
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 {
|
||||
var key string
|
||||
if item.SvcAccChange.Create != nil {
|
||||
@@ -242,6 +251,15 @@ func applyIAMReplicationItem(ctx context.Context, item iamReplicationItem) error
|
||||
ctx = context.WithValue(ctx, iamRecordBoundaryKey{}, item.RevokedBefore)
|
||||
}
|
||||
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:
|
||||
if item.UserRevocation == nil || item.UserRevocation.User == "" || item.UserRevocation.Before.IsZero() {
|
||||
return errSRInvalidRequest(errInvalidArgument)
|
||||
|
||||
+32
-8
@@ -348,6 +348,13 @@ func saveIAMRevision(ctx context.Context, store IAMStorageAPI, path string, item
|
||||
// STS access keys identify immutable tokens, not reusable user names.
|
||||
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 {
|
||||
*at = origin
|
||||
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.
|
||||
v.Credentials = auth.Credentials{ParentUser: previous.Credentials.ParentUser}
|
||||
v.RevokedBefore = *at
|
||||
if strings.HasPrefix(path, iamConfigSTSPrefix) && !previous.Credentials.Expiration.IsZero() && !previous.Credentials.Expiration.Equal(timeSentinel) {
|
||||
// The signed STS token cannot authorize beyond this time, even
|
||||
// if an offline site replays it with a newer event timestamp.
|
||||
v.ExpiresAt = previous.Credentials.Expiration.Add(globalMaxSkewTime)
|
||||
opts = []options{{ttl: max(1, int64(time.Until(v.ExpiresAt).Seconds())+1)}}
|
||||
if strings.HasPrefix(path, iamConfigSTSPrefix) {
|
||||
// Carry expiry when a revocation arrives before the token itself.
|
||||
// It is safe to forget this key only after its signed lifetime.
|
||||
if expiration := previous.Credentials.Expiration; !expiration.IsZero() && !expiration.Equal(timeSentinel) && expiration.Add(globalMaxSkewTime).After(v.ExpiresAt) {
|
||||
v.ExpiresAt = expiration.Add(globalMaxSkewTime)
|
||||
}
|
||||
if previous.ExpiresAt.After(v.ExpiresAt) {
|
||||
v.ExpiresAt = previous.ExpiresAt
|
||||
}
|
||||
if !v.ExpiresAt.IsZero() {
|
||||
opts = []options{{ttl: max(1, int64(time.Until(v.ExpiresAt).Seconds())+1)}}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
item.Type = madmin.SRIAMItemIAMUser
|
||||
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):
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(path, iamConfigServiceAccountsPrefix), "/"+iamIdentityFile)
|
||||
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.PolicyMapping = &madmin.SRPolicyMapping{UserOrGroup: strings.TrimSuffix(name, ".json"), UserType: int(typ), IsGroup: prefix == "groups"}
|
||||
default:
|
||||
// Expired STS credentials are not replayed. Parent revocations and
|
||||
// their retained timestamp reject delayed copies of derived tokens.
|
||||
// Other config paths do not represent replicated IAM deletions.
|
||||
return item, false
|
||||
}
|
||||
return item, true
|
||||
@@ -527,6 +546,8 @@ func iamDeletionItem(path string, r iamRevision) (item madmin.SRIAMItem, ok bool
|
||||
|
||||
func iamDeletionPath(item madmin.SRIAMItem) string {
|
||||
switch item.Type {
|
||||
case iamSTSDeleteType:
|
||||
return getUserIdentityPath(item.Name, stsUser)
|
||||
case madmin.SRIAMItemIAMUser:
|
||||
if item.IAMUser != nil && item.IAMUser.IsDeleteReq {
|
||||
return getUserIdentityPath(item.IAMUser.AccessKey, regUser)
|
||||
@@ -582,6 +603,9 @@ func (c *SiteReplicationSys) healIAMDeletions(ctx context.Context) (err error) {
|
||||
continue
|
||||
}
|
||||
out := iamReplicationItem{SRIAMItem: item}
|
||||
if item.Type == iamSTSDeleteType {
|
||||
out.STSExpiresAt = r.ExpiresAt
|
||||
}
|
||||
if item.Type == madmin.SRIAMItemIAMUser && !r.Deleted {
|
||||
out.Type, out.IAMUser = iamUserBoundaryType, nil
|
||||
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)
|
||||
}
|
||||
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]...)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
|
||||
@@ -7,14 +7,19 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/grid"
|
||||
"github.com/pgsty/silo-pkg/v3/ldap"
|
||||
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.
|
||||
// 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.
|
||||
|
||||
+153
-76
@@ -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
|
||||
// replaces the in-memory cache object.
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
|
||||
for name := range newCache.iamPolicyDocsMap {
|
||||
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()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1363,9 +1390,9 @@ func (store *IAMStoreSys) PolicyDBSet(ctx context.Context, name, policy string,
|
||||
}
|
||||
|
||||
// PolicyNotificationHandler - loads given policy from storage. If not present,
|
||||
// deletes from cache. This notification only reads from storage, and updates
|
||||
// cache. When the notification is for a policy deletion, it updates the
|
||||
// user-policy and group-policy maps as well.
|
||||
// removes its override from cache. This notification only reads from storage,
|
||||
// and updates cache. A deleted built-in override restores its native policy;
|
||||
// a deleted custom policy is removed from user-policy and group-policy maps.
|
||||
func (store *IAMStoreSys) PolicyNotificationHandler(ctx context.Context, policy string) error {
|
||||
if policy == "" {
|
||||
return errInvalidArgument
|
||||
@@ -1376,8 +1403,10 @@ func (store *IAMStoreSys) PolicyNotificationHandler(ctx context.Context, policy
|
||||
|
||||
err := store.loadPolicyDoc(ctx, policy, cache.iamPolicyDocsMap)
|
||||
if errors.Is(err, errNoSuchPolicy) {
|
||||
// policy was deleted, update cache.
|
||||
delete(cache.iamPolicyDocsMap, policy)
|
||||
if removePolicyFromCache(policy, cache.iamPolicyDocsMap) {
|
||||
cache.updatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// update user policy map
|
||||
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()
|
||||
return nil
|
||||
}
|
||||
if err == nil {
|
||||
cache.updatedAt = time.Now()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeletePolicy - deletes policy from storage and cache. When this called in
|
||||
// response to a notification (i.e. isFromNotification = true), it skips the
|
||||
// validation of policy usage and the attempt to delete in the backend as well
|
||||
// (as this is already done by the notifying node).
|
||||
// DeletePolicy - deletes a stored policy and restores the native definition
|
||||
// when it was a built-in override. Notifications reload committed storage, so
|
||||
// a delayed deletion event cannot remove a subsequently recreated policy.
|
||||
func (store *IAMStoreSys) DeletePolicy(ctx context.Context, policy string, isFromNotification bool) error {
|
||||
if policy == "" {
|
||||
return errInvalidArgument
|
||||
}
|
||||
if isFromNotification {
|
||||
return store.PolicyNotificationHandler(ctx, policy)
|
||||
}
|
||||
|
||||
cache := store.lock()
|
||||
defer store.unlock()
|
||||
|
||||
if !isFromNotification {
|
||||
if err := store.checkIAMRevision(ctx, getPolicyDocPath(policy), true); err != nil {
|
||||
return err
|
||||
if err := store.checkIAMRevision(ctx, getPolicyDocPath(policy), true); err != nil {
|
||||
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 {
|
||||
if _, exists := cache.iamPolicyDocsMap[policy]; !exists {
|
||||
// Preserve idempotent local deletion without creating a durable
|
||||
// revocation for a misspelled/nonexistent policy name.
|
||||
return nil
|
||||
// Check if policy is mapped to any existing user or group. If so, we do not
|
||||
// allow deletion of the policy. If the policy is mapped to an STS account,
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// Check if policy is mapped to any existing user or group. If so, we do not
|
||||
// allow deletion of the policy. If the policy is mapped to an STS account,
|
||||
// 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
|
||||
if pset.Contains(policy) {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
|
||||
err := store.deletePolicyDoc(ctx, policy)
|
||||
if errors.Is(err, errNoSuchPolicy) {
|
||||
// Ignore error if policy is already deleted.
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
return nil
|
||||
@@ -1568,10 +1600,22 @@ func (store *IAMStoreSys) ListPolicies(ctx context.Context, bucketName string) (
|
||||
defer store.unlock()
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sets default canned policies
|
||||
setDefaultCannedPolicies(m)
|
||||
@@ -1596,10 +1640,20 @@ func (store *IAMStoreSys) ListPolicyDocs(ctx context.Context, bucketName string)
|
||||
defer store.unlock()
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sets default canned policies
|
||||
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,
|
||||
// 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 == "" {
|
||||
return errInvalidArgument
|
||||
return nil, errInvalidArgument
|
||||
}
|
||||
|
||||
before := UTCNow()
|
||||
cache := store.lock()
|
||||
defer store.unlock()
|
||||
|
||||
secret, err := getTokenSigningKey()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var revoked bool
|
||||
for _, ui := range cache.iamSTSAccountsMap {
|
||||
if ui.Credentials.ParentUser != parentUser {
|
||||
// Another node may have issued a token whose notification was lost.
|
||||
// Enumerate durable identities, retaining cached entries for retrying a
|
||||
// partially propagated revocation. Do not hold up cached authentication.
|
||||
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
|
||||
}
|
||||
if tokenRevokeType != "" {
|
||||
claims, err := getClaimsFromTokenWithSecret(ui.Credentials.SessionToken, secret)
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := store.deleteUserIdentity(ctx, ui.Credentials.AccessKey, stsUser); err != nil {
|
||||
return err
|
||||
if err := store.deleteUserIdentity(ctx, key, stsUser); err != nil {
|
||||
failures = append(failures, err)
|
||||
continue
|
||||
}
|
||||
delete(cache.iamSTSAccountsMap, ui.Credentials.AccessKey)
|
||||
revoked = true
|
||||
delete(cache.iamSTSAccountsMap, key)
|
||||
revoked = append(revoked, key)
|
||||
}
|
||||
|
||||
if revoked {
|
||||
if len(revoked) != 0 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/grid"
|
||||
xnet "github.com/pgsty/silo-pkg/v3/net"
|
||||
)
|
||||
|
||||
func TestIAMSTSRevocationDurableScope(t *testing.T) {
|
||||
for _, backend := range []string{"object", "etcd"} {
|
||||
for _, notify := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/notify=%t", backend, notify), func(t *testing.T) {
|
||||
ctx, sys, obj := prepareIAMRevisionFixture(t, backend)
|
||||
for _, parent := range []string{"sts-parent", "other-parent"} {
|
||||
_, err := sys.CreateUser(ctx, parent, madmin.AddOrUpdateUserReq{SecretKey: "synthetic-parent-password", Status: madmin.AccountEnabled})
|
||||
mustIAM(t, err)
|
||||
}
|
||||
sibling := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(obj, MinIOUsersSysType)}
|
||||
if backend == "etcd" {
|
||||
sibling.IAMStorageAPI = newIAMEtcdStore(sys.store.IAMStorageAPI.(*IAMEtcdStore).client, MinIOUsersSysType)
|
||||
}
|
||||
mustIAM(t, sibling.LoadIAMCache(ctx, false))
|
||||
secret, err := getTokenSigningKey()
|
||||
mustIAM(t, err)
|
||||
issue := func(store *IAMStoreSys, parent, typ string) auth.Credentials {
|
||||
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: parent, tokenRevokeTypeClaim: typ}, secret)
|
||||
mustIAM(t, err)
|
||||
cred.ParentUser = parent
|
||||
_, err = store.SetTempUser(ctx, cred.AccessKey, cred, "")
|
||||
mustIAM(t, err)
|
||||
return cred
|
||||
}
|
||||
first := issue(sys.store, "sts-parent", "selected")
|
||||
uncached := issue(sibling, "sts-parent", "selected")
|
||||
otherType := issue(sys.store, "sts-parent", "other-type")
|
||||
otherUser := issue(sys.store, "other-parent", "selected")
|
||||
if _, ok := sys.store.GetUser(uncached.AccessKey); ok {
|
||||
t.Fatal("fixture cached the remote token")
|
||||
}
|
||||
for _, c := range []auth.Credentials{first, otherType, otherUser} {
|
||||
mustIAM(t, sibling.UserNotificationHandler(ctx, c.AccessKey, stsUser))
|
||||
}
|
||||
prior := globalNotificationSys
|
||||
globalNotificationSys = &NotificationSys{}
|
||||
t.Cleanup(func() { globalNotificationSys = prior })
|
||||
if notify {
|
||||
tg, err := grid.SetupTestGrid(2)
|
||||
mustIAM(t, err)
|
||||
t.Cleanup(tg.Cleanup)
|
||||
mustIAM(t, loadUserRPC.Register(tg.Managers[1], func(r *grid.MSS) (grid.NoPayload, *grid.RemoteErr) {
|
||||
if err := sibling.UserNotificationHandler(ctx, r.Get(peerRESTUser), stsUser); err != nil {
|
||||
return grid.NoPayload{}, grid.NewRemoteErr(err)
|
||||
}
|
||||
return grid.NoPayload{}, nil
|
||||
}))
|
||||
host, err := xnet.ParseHost(strings.TrimPrefix(tg.Hosts[1], "http://"))
|
||||
mustIAM(t, err)
|
||||
globalNotificationSys = &NotificationSys{peerClients: []*peerRESTClient{{host: host, gridConn: func() *grid.Connection { return tg.Managers[0].Connection(tg.Hosts[1]) }}}}
|
||||
}
|
||||
mustIAM(t, sys.RevokeTokens(ctx, "sts-parent", "selected"))
|
||||
for _, c := range []auth.Credentials{first, uncached} {
|
||||
r, err := loadIAMRevision(ctx, sys.store, getUserIdentityPath(c.AccessKey, stsUser))
|
||||
mustIAM(t, err)
|
||||
if !r.Deleted {
|
||||
t.Errorf("durable token was omitted: %s", c.AccessKey)
|
||||
}
|
||||
if _, ok := iamDeletionItem(getUserIdentityPath(c.AccessKey, stsUser), r); !ok {
|
||||
t.Error("STS revocation cannot be replayed")
|
||||
}
|
||||
if notify && backend == "object" {
|
||||
if _, ok := sibling.GetUser(c.AccessKey); ok {
|
||||
t.Error("notified sibling accepted revoked token")
|
||||
}
|
||||
}
|
||||
}
|
||||
fresh := issue(sibling, "sts-parent", "selected")
|
||||
// A missed notification must converge through the normal cache refresh.
|
||||
mustIAM(t, sibling.LoadIAMCache(ctx, false))
|
||||
for _, c := range []auth.Credentials{first, uncached} {
|
||||
if _, ok := sibling.GetUser(c.AccessKey); ok {
|
||||
t.Error("refresh retained revoked token")
|
||||
}
|
||||
}
|
||||
for _, c := range []auth.Credentials{otherType, otherUser, fresh} {
|
||||
mustIAM(t, sibling.UserNotificationHandler(ctx, c.AccessKey, stsUser))
|
||||
if _, ok := sibling.GetUser(c.AccessKey); !ok {
|
||||
t.Error("revocation removed an out-of-scope or new token")
|
||||
}
|
||||
}
|
||||
if _, ok := sibling.GetUser("sts-parent"); !ok {
|
||||
t.Error("revocation removed parent")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deletion is keyed by the immutable STS access key, so delayed delivery cannot
|
||||
// affect a newly issued token and a delayed creation cannot undo revocation.
|
||||
func TestIAMSTSRevocationReorderedReplay(t *testing.T) {
|
||||
for _, backend := range []string{"object", "etcd"} {
|
||||
for _, missing := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/missing=%t", backend, missing), func(t *testing.T) {
|
||||
ctx, sys, _ := prepareIAMRevisionFixture(t, backend)
|
||||
const parent = "sts-replay-parent"
|
||||
_, err := sys.CreateUser(ctx, parent, madmin.AddOrUpdateUserReq{SecretKey: "synthetic-parent-password", Status: madmin.AccountEnabled})
|
||||
mustIAM(t, err)
|
||||
secret, err := getTokenSigningKey()
|
||||
mustIAM(t, err)
|
||||
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: parent}, secret)
|
||||
mustIAM(t, err)
|
||||
cred.ParentUser = parent
|
||||
created := UTCNow().Add(-time.Minute)
|
||||
peer := madmin.SRSTSCredential{AccessKey: cred.AccessKey, SecretKey: cred.SecretKey, SessionToken: cred.SessionToken, ParentUser: parent}
|
||||
if !missing {
|
||||
mustIAM(t, globalSiteReplicationSys.PeerSTSAccHandler(ctx, &peer, created))
|
||||
}
|
||||
fresh, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: parent}, secret)
|
||||
mustIAM(t, err)
|
||||
fresh.ParentUser = parent
|
||||
_, err = sys.SetTempUser(ctx, fresh.AccessKey, fresh, "")
|
||||
mustIAM(t, err)
|
||||
// Older than the live record on this peer: deletion must still win.
|
||||
item := iamReplicationItem{SRIAMItem: madmin.SRIAMItem{Type: iamSTSDeleteType, Name: cred.AccessKey, UpdatedAt: created.Add(-time.Second)}, STSExpiresAt: cred.Expiration.Add(globalMaxSkewTime)}
|
||||
for range 2 {
|
||||
mustIAM(t, applyIAMReplicationItem(ctx, item))
|
||||
mustIAM(t, globalSiteReplicationSys.PeerSTSAccHandler(ctx, &peer, UTCNow()))
|
||||
mustIAM(t, sys.store.LoadIAMCache(ctx, false))
|
||||
if _, ok := sys.GetUser(ctx, cred.AccessKey); ok {
|
||||
t.Fatal("delayed STS creation undid revocation")
|
||||
}
|
||||
if _, ok := sys.GetUser(ctx, fresh.AccessKey); !ok {
|
||||
t.Fatal("delayed revocation removed new token")
|
||||
}
|
||||
}
|
||||
r, err := loadIAMRevision(ctx, sys.store, getUserIdentityPath(cred.AccessKey, stsUser))
|
||||
mustIAM(t, err)
|
||||
if !r.Deleted || !r.ExpiresAt.Equal(item.STSExpiresAt) || r.Credentials.SessionToken != "" || r.Credentials.SecretKey != "" {
|
||||
t.Fatal("revocation lost expiry or retained secret material")
|
||||
}
|
||||
for _, status := range []madmin.AccountStatus{madmin.AccountDisabled, madmin.AccountEnabled} {
|
||||
_, err = sys.SetUserStatus(ctx, parent, status)
|
||||
mustIAM(t, err)
|
||||
}
|
||||
if _, ok := sys.GetUser(ctx, cred.AccessKey); ok {
|
||||
t.Fatal("parent re-enable revived revoked token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pause the durable scan while another node persists a new credential. The
|
||||
// revocation's original cutoff must survive the scan and leave that token live.
|
||||
type iamSTSScanGateStore struct {
|
||||
IAMStorageAPI
|
||||
entered chan struct{}
|
||||
resume chan struct{}
|
||||
}
|
||||
|
||||
func (s *iamSTSScanGateStore) loadUsers(ctx context.Context, typ IAMUserType, users map[string]UserIdentity) error {
|
||||
close(s.entered)
|
||||
select {
|
||||
case <-s.resume:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return s.IAMStorageAPI.loadUsers(ctx, typ, users)
|
||||
}
|
||||
|
||||
func TestIAMSTSRevocationConcurrentIssuance(t *testing.T) {
|
||||
ctx, sys, obj := prepareIAMRevisionFixture(t)
|
||||
_, err := sys.CreateUser(ctx, "sts-race-parent", madmin.AddOrUpdateUserReq{SecretKey: "synthetic-parent-password", Status: madmin.AccountEnabled})
|
||||
mustIAM(t, err)
|
||||
sibling := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(obj, MinIOUsersSysType)}
|
||||
secret, err := getTokenSigningKey()
|
||||
mustIAM(t, err)
|
||||
issue := func() auth.Credentials {
|
||||
c, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: "sts-race-parent"}, secret)
|
||||
mustIAM(t, err)
|
||||
c.ParentUser = "sts-race-parent"
|
||||
_, err = sibling.SetTempUser(ctx, c.AccessKey, c, "")
|
||||
mustIAM(t, err)
|
||||
return c
|
||||
}
|
||||
old := issue()
|
||||
gate := &iamSTSScanGateStore{IAMStorageAPI: sys.store.IAMStorageAPI, entered: make(chan struct{}), resume: make(chan struct{})}
|
||||
sys.store.IAMStorageAPI = gate
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- sys.RevokeTokens(ctx, "sts-race-parent", "") }()
|
||||
<-gate.entered
|
||||
fresh := issue()
|
||||
close(gate.resume)
|
||||
mustIAM(t, <-done)
|
||||
mustIAM(t, sibling.UserNotificationHandler(ctx, old.AccessKey, stsUser))
|
||||
if _, ok := sibling.GetUser(old.AccessKey); ok {
|
||||
t.Fatal("old uncached token survived")
|
||||
}
|
||||
mustIAM(t, sibling.UserNotificationHandler(ctx, fresh.AccessKey, stsUser))
|
||||
if _, ok := sibling.GetUser(fresh.AccessKey); !ok {
|
||||
t.Fatal("new token was included in earlier revocation")
|
||||
}
|
||||
}
|
||||
|
||||
type iamSTSFailureStore struct {
|
||||
IAMStorageAPI
|
||||
failedPath string
|
||||
}
|
||||
|
||||
func (s *iamSTSFailureStore) saveIAMConfig(ctx context.Context, item any, path string, opts ...options) error {
|
||||
if path == s.failedPath {
|
||||
return errIAMInjectedWrite
|
||||
}
|
||||
return s.IAMStorageAPI.saveIAMConfig(ctx, item, path, opts...)
|
||||
}
|
||||
|
||||
func TestIAMSTSRevocationPartialFailure(t *testing.T) {
|
||||
ctx, sys, _ := prepareIAMRevisionFixture(t)
|
||||
_, err := sys.CreateUser(ctx, "sts-partial-parent", madmin.AddOrUpdateUserReq{SecretKey: "synthetic-parent-password", Status: madmin.AccountEnabled})
|
||||
mustIAM(t, err)
|
||||
secret, err := getTokenSigningKey()
|
||||
mustIAM(t, err)
|
||||
var keys []string
|
||||
for range 2 {
|
||||
c, err := auth.GetNewCredentialsWithMetadata(map[string]any{"exp": UTCNow().Add(time.Hour).Unix(), parentClaim: "sts-partial-parent"}, secret)
|
||||
mustIAM(t, err)
|
||||
c.ParentUser = "sts-partial-parent"
|
||||
_, err = sys.SetTempUser(ctx, c.AccessKey, c, "")
|
||||
mustIAM(t, err)
|
||||
keys = append(keys, c.AccessKey)
|
||||
}
|
||||
sys.store.IAMStorageAPI = &iamSTSFailureStore{IAMStorageAPI: sys.store.IAMStorageAPI, failedPath: getUserIdentityPath(keys[0], stsUser)}
|
||||
revoked, err := sys.store.RevokeTokens(ctx, "sts-partial-parent", "")
|
||||
if !errors.Is(err, errIAMInjectedWrite) || len(revoked) != 1 || revoked[0] != keys[1] {
|
||||
t.Fatalf("committed keys or failure lost: %v %v", revoked, err)
|
||||
}
|
||||
if _, ok := sys.store.GetUser(keys[1]); ok {
|
||||
t.Fatal("committed revocation retained cache entry")
|
||||
}
|
||||
if _, ok := sys.store.GetUser(keys[0]); !ok {
|
||||
t.Fatal("failed revocation claimed cache success")
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -717,7 +717,14 @@ func (sys *IAMSys) RevokeTokens(ctx context.Context, accessKey, tokenRevokeType
|
||||
return errServerNotInitialized
|
||||
}
|
||||
|
||||
return 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).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -81,14 +81,14 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
||||
uploadIDs[object] = mp.UploadID
|
||||
}
|
||||
|
||||
// Durable multipart metadata, rather than this node-local cache, must be
|
||||
// authoritative after a restart or when another node handles the request.
|
||||
// The diagnostic scanner must discover metadata independently of the cache.
|
||||
// These checks do not claim that public listing offers durable pagination.
|
||||
z.mpCache.Range(func(uploadID string, _ MultipartInfo) bool {
|
||||
z.mpCache.Delete(uploadID)
|
||||
return true
|
||||
})
|
||||
|
||||
all, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 100)
|
||||
all, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -108,31 +108,31 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -141,7 +141,7 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
||||
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 {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy, err := z.ListMultipartUploads(t.Context(), bucket, legacyObject, "", "", "", 100)
|
||||
legacy, err := z.scanMultipartUploadsForTest(t.Context(), bucket, legacyObject, "", "", "", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, legacy)
|
||||
remaining, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 100)
|
||||
remaining, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -280,7 +280,7 @@ func TestListMultipartUploadsGlobalPageAcrossPools(t *testing.T) {
|
||||
return true
|
||||
})
|
||||
|
||||
page, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 2)
|
||||
page, err := z.scanMultipartUploadsForTest(t.Context(), bucket, "", "", "", "", 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -289,7 +289,7 @@ func TestListMultipartUploadsGlobalPageAcrossPools(t *testing.T) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -781,6 +781,15 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
|
||||
}
|
||||
last := ""
|
||||
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.
|
||||
for {
|
||||
@@ -790,6 +799,7 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
|
||||
best := top[0]
|
||||
bestIdx := 0
|
||||
toMerge = toMerge[:0]
|
||||
directoryCollisions = directoryCollisions[:0]
|
||||
for i, other := range top[1:] {
|
||||
otherIdx := i + 1
|
||||
if other == nil {
|
||||
@@ -819,10 +829,9 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
|
||||
if serverDebugLog {
|
||||
console.Debugln("mergeEntryChannels: discarding directory", other.name, "for object", best.name)
|
||||
}
|
||||
// Discard the directory.
|
||||
if err := selectFrom(otherIdx); err != nil {
|
||||
return err
|
||||
}
|
||||
// A pending object can resolve to absence. Keep the
|
||||
// directory until the whole object group is confirmed.
|
||||
directoryCollisions = append(directoryCollisions, otherIdx)
|
||||
continue
|
||||
}
|
||||
// Replace directory with object.
|
||||
@@ -838,6 +847,7 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
|
||||
}
|
||||
if best.name > other.name {
|
||||
toMerge = toMerge[:0]
|
||||
directoryCollisions = directoryCollisions[:0]
|
||||
best = other
|
||||
bestIdx = otherIdx
|
||||
}
|
||||
@@ -866,6 +876,11 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved != nil {
|
||||
if err := discardDirectories(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if resolved != nil && resolved.name > last {
|
||||
select {
|
||||
case <-ctxDone:
|
||||
@@ -876,6 +891,9 @@ func mergeEntryChannelsWithResolver(ctx context.Context, in []chan metaCacheEntr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := discardDirectories(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Merge any unmerged
|
||||
if len(toMerge) > 0 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,33 +221,50 @@ func TestListingRecoveryNormalPathDoesNotReadOrLock(t *testing.T) {
|
||||
|
||||
func TestListingPendingDirectoryCollision(t *testing.T) {
|
||||
for _, objectFirst := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(objectFirst), func(t *testing.T) {
|
||||
in := []chan metaCacheEntry{make(chan metaCacheEntry, 1), make(chan metaCacheEntry, 1)}
|
||||
objectIndex := 1
|
||||
if objectFirst {
|
||||
objectIndex = 0
|
||||
}
|
||||
in[objectIndex] <- metaCacheEntry{name: "dir/", listingSet: &erasureObjects{}}
|
||||
in[1-objectIndex] <- metaCacheEntry{name: "dir/"}
|
||||
for _, c := range in {
|
||||
close(c)
|
||||
}
|
||||
out := make(chan metaCacheEntry, 2)
|
||||
calls := 0
|
||||
err := mergeEntryChannelsWithResolver(t.Context(), in, out, 1, nil, func(group []*metaCacheEntry) (*metaCacheEntry, error) {
|
||||
calls++
|
||||
if len(group) != 1 || group[0].listingSet == nil {
|
||||
t.Fatalf("wrong pending group: %+v", group)
|
||||
for _, present := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("objectFirst=%v/present=%v", objectFirst, present), func(t *testing.T) {
|
||||
in := []chan metaCacheEntry{make(chan metaCacheEntry, 2), make(chan metaCacheEntry, 2), make(chan metaCacheEntry, 2)}
|
||||
objectIndex := 1
|
||||
if objectFirst {
|
||||
objectIndex = 0
|
||||
}
|
||||
in[objectIndex] <- metaCacheEntry{name: "dir/", listingSet: &erasureObjects{}}
|
||||
in[1-objectIndex] <- metaCacheEntry{name: "dir/"}
|
||||
in[2] <- metaCacheEntry{name: "a", metadata: []byte{1}}
|
||||
for _, c := range in {
|
||||
close(c)
|
||||
}
|
||||
out := make(chan metaCacheEntry, 4)
|
||||
calls := 0
|
||||
err := mergeEntryChannelsWithResolver(t.Context(), in, out, 1, nil, func(group []*metaCacheEntry) (*metaCacheEntry, error) {
|
||||
calls++
|
||||
if len(group) != 1 || group[0].listingSet == nil {
|
||||
t.Fatalf("wrong pending group: %+v", group)
|
||||
}
|
||||
if !present {
|
||||
return nil, nil
|
||||
}
|
||||
return &metaCacheEntry{name: "dir/", metadata: []byte{1}}, nil
|
||||
})
|
||||
if err != nil || calls != 1 || len(out) != 2 {
|
||||
t.Fatalf("err=%v recoveries=%d outputs=%d", err, calls, len(out))
|
||||
}
|
||||
first := <-out
|
||||
if first.name != "a" {
|
||||
t.Fatalf("expected smaller key first, got %+v", first)
|
||||
}
|
||||
entry := <-out
|
||||
if entry.name != "dir/" || entry.listingSet != nil {
|
||||
t.Fatalf("unexpected recovered entry: %+v", entry)
|
||||
}
|
||||
if present && (entry.isDir() || !entry.isObjectDir()) {
|
||||
t.Fatalf("object became directory: %+v", entry)
|
||||
}
|
||||
if !present && (!entry.isDir() || entry.name != "dir/") {
|
||||
t.Fatalf("absent pending object dropped live directory prefix: %+v", entry)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -834,6 +834,9 @@ func (de DataMovementOverwriteErr) Error() string {
|
||||
if 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)
|
||||
}
|
||||
|
||||
@@ -841,3 +844,11 @@ func isDataMovementOverWriteErr(err error) bool {
|
||||
var de DataMovementOverwriteErr
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
goldap "github.com/go-ldap/ldap/v3"
|
||||
"github.com/klauspost/compress/zip"
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio-go/v7"
|
||||
@@ -1095,6 +1096,7 @@ func TestIAMWithLDAPServerSuite(t *testing.T) {
|
||||
suite.TestLDAPSTSServiceAccountsWithGroups(c)
|
||||
suite.TestLDAPAttributesLookup(c)
|
||||
suite.TestLDAPCyrillicUser(c)
|
||||
suite.TestLDAPLongNonASCIIDN(c)
|
||||
suite.TestLDAPSlashDN(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) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -3,8 +3,8 @@ set -eu
|
||||
|
||||
# Build both release architectures from the same verified upstream source.
|
||||
# The previous binary supplier stopped publishing current ARM64 builds.
|
||||
CURL_VERSION=8.22.0
|
||||
CURL_SHA256=f7ef3ae8a22e521f289803fe93543eb64c329b58aa73a9e224dfd915a2a5f4f7
|
||||
CURL_VERSION=8.23.0
|
||||
CURL_SHA256=8dadd7d2536a314f982ad05a651b713f426bf7d02cc2c76cffb9796f2218e6c5
|
||||
|
||||
case ${TARGETARCH:?TARGETARCH is required} in
|
||||
amd64) expected_arch=x86_64 ;;
|
||||
@@ -16,9 +16,12 @@ esac
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Keep security fixes in statically linked libraries, including distro backports.
|
||||
apk add --no-cache --upgrade build-base curl ca-certificates \
|
||||
openssl-libs-static nghttp2-static libpsl-static libidn2-static \
|
||||
libunistring-static zlib-static brotli-static zstd-static libssh2-static
|
||||
'musl-dev>=1.2.6-r3' 'openssl-libs-static>=3.5.9-r0' \
|
||||
'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)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
module github.com/minio/minio/docs/debugging/inspect
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.8
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.17.11
|
||||
github.com/klauspost/compress v1.20.3
|
||||
github.com/klauspost/filepathx v1.1.1
|
||||
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/tinylib/msgp v1.2.5
|
||||
github.com/tinylib/msgp v1.6.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/pkg/v3 v3.0.28 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
golang.org/x/crypto v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.16 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/minio/pkg/v3 v3.11.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.1 // indirect
|
||||
golang.org/x/crypto v0.58.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
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/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
|
||||
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/go.mod h1:XWxdp8rEw4gupPBrxrV5Q57dL/71xj0OgV1gKt2zTfU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/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.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
|
||||
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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/minio/colorjson v1.0.8 h1:AS6gEQ1dTRYHmC4xuoodPDRILHP/9Wz5wYUGDQfPLpg=
|
||||
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.88/go.mod h1:pMLdj9OtN0CANNs5tdm6opvOlDFfj0WhbztboZAjRWE=
|
||||
github.com/minio/pkg/v3 v3.0.28 h1:8tSuZnJbjc3C3DM2DEh4ZnSWjMZdccd679stk8sPD60=
|
||||
github.com/minio/pkg/v3 v3.0.28/go.mod h1:mIaN552nu0D2jiSk5BQC8LB25f44ytbOBJCuLtksX7Q=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
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/pkg/v3 v3.11.0 h1:WjzWGP7lyuupJgUSRim1RDtNujHtESyMQF7qSR4r5Dg=
|
||||
github.com/minio/pkg/v3 v3.11.0/go.mod h1:gBNHbob7QUCu/5nOtkAEEpN6diu1o1lwFpEnzU35s9U=
|
||||
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/secure-io/sio-go v0.3.1 h1:dNvY9awjabXTYGsTF1PiCySl9Ltofk9GA3VdWlo7rRc=
|
||||
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.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
|
||||
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-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
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.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-20190412213103-97732733099d/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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
module github.com/minio/minio/docs/debugging/pprofgoparser
|
||||
|
||||
go 1.21
|
||||
|
||||
toolchain go1.24.8
|
||||
go 1.27.3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
github.com/pgsty/silo-pkg/v3 v3.13.2 h1:Clw11c/J54Tx6pijNCWtXiC7e0fwP/f5Tgeb6fsXg2w=
|
||||
github.com/pgsty/silo-pkg/v3 v3.13.2/go.mod h1:0GmaDA0ArQ8bkAI/obiSNTBQzdgBu6W0e0olDCbyXXo=
|
||||
github.com/pgsty/silo-pkg/v3 v3.14.3 h1:hMDxOo36F1whGRkYScrUGGwn98FvsmJ8hVM6pE4i8Bs=
|
||||
github.com/pgsty/silo-pkg/v3 v3.14.3/go.mod h1:zqIm3FMETmwPHRKuUExCDGfYkCNXt+vpfBnPM821RdI=
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
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.0.83
|
||||
require github.com/minio/minio-go/v7 v7.4.1
|
||||
|
||||
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/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/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/klauspost/compress v1.20.3 // 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/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.5 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.58.0 // 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
|
||||
)
|
||||
|
||||
@@ -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/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/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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
|
||||
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.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U=
|
||||
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/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.0.83/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/minio/minio-go/v7 v7.4.1 h1:PtU/zIOfQRWZ+QydXAZysc+xZYHdG+BBR4Z6TUvy73w=
|
||||
github.com/minio/minio-go/v7 v7.4.1/go.mod h1:nQnQi7zTfXR4lREtYXZQWqo5aw0Rqv+ZqCGrTORygRk=
|
||||
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/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
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/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
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/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/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=
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
module github.com/minio/minio/docs/debugging/xattr
|
||||
|
||||
go 1.21
|
||||
|
||||
toolchain go1.24.8
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
github.com/olekukonko/tablewriter v0.0.5
|
||||
github.com/pkg/xattr v0.4.9
|
||||
github.com/pkg/xattr v0.4.12
|
||||
)
|
||||
|
||||
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/rivo/uniseg v0.4.4 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
)
|
||||
|
||||
@@ -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/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
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.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||
github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
|
||||
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.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
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.
|
||||
@@ -1,12 +1,12 @@
|
||||
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
|
||||
// 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
|
||||
// CLOCK_MONOTONIC, which is unavailable there. Keep the last portable release
|
||||
@@ -20,15 +20,15 @@ tool (
|
||||
)
|
||||
|
||||
require (
|
||||
aead.dev/mtls v0.3.0
|
||||
cloud.google.com/go/storage v1.61.3
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4
|
||||
github.com/IBM/sarama v1.45.1
|
||||
aead.dev/mtls v0.4.0
|
||||
cloud.google.com/go/storage v1.70.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.15.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.2
|
||||
github.com/IBM/sarama v1.61.1
|
||||
github.com/alecthomas/participle v0.7.1
|
||||
github.com/beevik/ntp v1.5.0
|
||||
github.com/buger/jsonparser v1.1.2
|
||||
github.com/beevik/ntp v1.6.0
|
||||
github.com/buger/jsonparser v1.6.1
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/cheggaaa/pb v1.0.30
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
@@ -41,23 +41,23 @@ require (
|
||||
github.com/fatih/color v1.19.0
|
||||
github.com/felixge/fgprof v0.9.5
|
||||
github.com/fraugster/parquet-go v0.12.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.14
|
||||
github.com/go-openapi/loads v0.25.2
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/go-ldap/ldap/v3 v3.4.15
|
||||
github.com/go-openapi/loads v0.25.3
|
||||
github.com/go-sql-driver/mysql v1.10.1
|
||||
github.com/gobwas/ws v1.4.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/gomodule/redigo v1.9.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/inconshreveable/mousetrap v1.1.0
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/klauspost/compress v1.20.0
|
||||
github.com/klauspost/cpuid/v2 v2.4.0
|
||||
github.com/klauspost/compress v1.20.3
|
||||
github.com/klauspost/cpuid/v2 v2.4.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/reedsolomon v1.13.3
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0
|
||||
github.com/klauspost/reedsolomon v1.14.2
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/lithammer/shortuuid/v4 v4.3.0
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/minio/cli v1.24.2
|
||||
github.com/minio/console v1.7.6
|
||||
@@ -66,78 +66,79 @@ require (
|
||||
github.com/minio/dperf v0.7.1
|
||||
github.com/minio/highwayhash v1.0.4
|
||||
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/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/selfupdate v0.6.0
|
||||
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/zipindex v0.5.0
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/nats-io/nats-server/v2 v2.11.15
|
||||
github.com/nats-io/nats.go v1.49.0
|
||||
github.com/nats-io/nats-server/v2 v2.15.1
|
||||
github.com/nats-io/nats.go v1.55.0
|
||||
github.com/nats-io/stan.go v0.10.4
|
||||
github.com/ncw/directio v1.0.5
|
||||
github.com/nsqio/go-nsq v1.1.0
|
||||
github.com/pgsty/silo-pkg/v3 v3.14.1
|
||||
github.com/philhofer/fwd v1.2.0
|
||||
github.com/pierrec/lz4/v4 v4.1.30
|
||||
github.com/pgsty/silo-pkg/v3 v3.15.1
|
||||
github.com/philhofer/fwd v1.2.1
|
||||
github.com/pierrec/lz4/v4 v4.1.33
|
||||
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/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/common v0.71.0
|
||||
github.com/prometheus/procfs v0.22.0
|
||||
github.com/prometheus/common v0.72.1
|
||||
github.com/prometheus/procfs v0.23.0
|
||||
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/rs/cors v1.11.1
|
||||
github.com/secure-io/sio-go v0.3.1
|
||||
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/xdg/scram v1.0.5
|
||||
github.com/zeebo/xxh3 v1.1.0
|
||||
go.etcd.io/etcd/api/v3 v3.7.1
|
||||
go.etcd.io/etcd/client/v3 v3.7.1
|
||||
go.uber.org/atomic v1.11.0
|
||||
go.etcd.io/etcd/api/v3 v3.7.3
|
||||
go.etcd.io/etcd/client/v3 v3.7.3
|
||||
go.uber.org/atomic v1.12.0
|
||||
go.uber.org/zap v1.28.0
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
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/sync v0.23.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.org/x/sync v0.24.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/term v0.47.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
|
||||
)
|
||||
|
||||
require (
|
||||
aead.dev/mem v0.2.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/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
cloud.google.com/go/auth v0.24.1 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.3.0 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.10.0 // indirect
|
||||
cloud.google.com/go/iam v1.15.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.32.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/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 // indirect
|
||||
github.com/VividCortex/ewma v1.2.0 // 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/apache/thrift v0.24.0 // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // 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/beorn7/perks v1.0.1 // 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/harmonica v0.2.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/term v0.2.2 // 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/docker/go-units v0.5.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/eapache/queue v1.1.0 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.40.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fatih/structs 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-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/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-openapi/analysis v0.26.2 // indirect
|
||||
github.com/go-openapi/errors v0.22.8 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.1 // indirect
|
||||
github.com/go-openapi/runtime v0.33.1 // indirect
|
||||
github.com/go-openapi/runtime/server-middleware v0.33.1 // indirect
|
||||
github.com/go-openapi/spec v1.0.0 // indirect
|
||||
github.com/go-openapi/strfmt v0.27.2 // indirect
|
||||
github.com/go-openapi/swag v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/pools v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
|
||||
github.com/go-openapi/validate v0.26.5 // indirect
|
||||
github.com/go-openapi/analysis v1.0.1 // indirect
|
||||
github.com/go-openapi/errors v0.22.9 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.2 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.3 // indirect
|
||||
github.com/go-openapi/runtime v0.33.3 // indirect
|
||||
github.com/go-openapi/runtime/server-middleware v0.33.3 // indirect
|
||||
github.com/go-openapi/spec v1.0.1 // indirect
|
||||
github.com/go-openapi/strfmt v0.27.4 // indirect
|
||||
github.com/go-openapi/swag v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/conv v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/loading v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/pools v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
|
||||
github.com/go-openapi/validate v1.0.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // 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/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // 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/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 // indirect
|
||||
github.com/google/s2a-go v0.1.11 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.24 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.27.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // 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/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/dnsutils/v2 v2.0.0 // 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/httpcc v1.0.1 // 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/lucasb-eyer/go-colorful v1.4.1 // 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-isatty v0.0.24 // 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/minio/colorjson v1.0.8 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
@@ -242,7 +242,7 @@ require (
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
// Legacy minio/colorjson and minio/dperf code still imports pkg/console.
|
||||
// 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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/termenv v0.16.0 // 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/nats-streaming-server v0.24.6 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/nats-streaming-server v0.25.6 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.2 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // 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/prometheus v0.314.0 // indirect
|
||||
github.com/prometheus/prometheus v0.315.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rjeczalik/notify v0.9.3 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/safchain/ethtool v0.7.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.2.2 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect
|
||||
github.com/tidwall/gjson v1.19.0 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.2.4 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.9.0 // indirect
|
||||
github.com/tidwall/gjson v1.20.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/numcpus v0.12.0 // indirect
|
||||
github.com/unrolled/secure v1.17.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/vbauerster/cupwriter v0.0.4 // indirect
|
||||
github.com/vbauerster/mpb/v8 v8.16.1 // indirect
|
||||
github.com/vbauerster/cupwriter v0.0.5 // indirect
|
||||
github.com/vbauerster/mpb/v8 v8.16.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
|
||||
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/contrib/detectors/gcp v1.44.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
|
||||
go.opentelemetry.io/otel v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.47.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.72.0 // indirect
|
||||
go.opentelemetry.io/otel v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/log v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.47.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
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/net v0.60.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/tools v0.50.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect
|
||||
golang.org/x/mod v0.42.0 // indirect
|
||||
golang.org/x/net v0.61.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
golang.org/x/tools v0.52.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b // 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
|
||||
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
|
||||
|
||||
@@ -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.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
|
||||
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
|
||||
aead.dev/mtls v0.3.0 h1:a+C0t15Y9SRX6qP1EqmQFZ4ZSMm88TPvNDymasu4ahQ=
|
||||
aead.dev/mtls v0.3.0/go.mod h1:rZvRApIcPkCNu2AgpFoaMxKBee/XVkKs7wEuYgqLI3Q=
|
||||
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
|
||||
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
aead.dev/mtls v0.4.0 h1:s+nuIKH0ZZIZ1RsfOjYVx0q2OcfKFKS3UaJGngrTLFY=
|
||||
aead.dev/mtls v0.4.0/go.mod h1:ZuSTM2+kagQsjaWoHn7yTU36MAaEaH/5Aq5XR4uYMk4=
|
||||
cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
|
||||
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/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
|
||||
cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
|
||||
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg=
|
||||
cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
cloud.google.com/go/auth v0.24.1 h1:NTmd2iJb4DsGIav0KiqFMYI88nMshA2wnFMBatRmoCs=
|
||||
cloud.google.com/go/auth v0.24.1/go.mod h1:1dmBXa9xfBvBr39ZnDUDkaaA91ccq9CcqENDgJA4d7c=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88=
|
||||
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.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY=
|
||||
cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk=
|
||||
cloud.google.com/go/iam v1.15.0 h1:U/wqjfrM0ceBFdPlOI2+cu68yPAxh72M8fe9uLG0+ZE=
|
||||
cloud.google.com/go/iam v1.15.0/go.mod h1:HAVf7PNhTrNTrEc6fVB21l8ejkbzNTulJHYf7QtD424=
|
||||
cloud.google.com/go/logging v1.20.0 h1:l7yzCB8/JSY5PHT9RsaqdT1jaBGbM6KlSJDX210HlXM=
|
||||
cloud.google.com/go/logging v1.20.0/go.mod h1:ALUaJQZM691Eeujy9kFx0xREq3AMPuGd+pTRZuTYv+w=
|
||||
cloud.google.com/go/longrunning v1.3.0 h1:lZ6c8SJJBmILp/Wnr3vGnBLa0q+cYfSHRCcUfVI2hpI=
|
||||
cloud.google.com/go/longrunning v1.3.0/go.mod h1:wpgLxCn+UA3/TZ1bj/fW39mfDpwVlMPu1xKJyoMt7V0=
|
||||
cloud.google.com/go/monitoring v1.32.0 h1:0bXxoh3o751jZnKDHgUDajaG8ZtqtKPMxhVmohRHI0Q=
|
||||
cloud.google.com/go/monitoring v1.32.0/go.mod h1:FxmFKr7SUhb60LDuYvjUc6bk9i1+LyXaENR5qAEibrM=
|
||||
cloud.google.com/go/storage v1.70.0 h1:LQNHn0rRF94jVFfF3e416IzdQqIufa5GfF6HilQxb80=
|
||||
cloud.google.com/go/storage v1.70.0/go.mod h1:W84cNa070TZIegvTW67CBY+f5rxux8EJMb7YyiHdrls=
|
||||
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
|
||||
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/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.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM=
|
||||
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.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U=
|
||||
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.15.0 h1:w3gDUJlJ0BfBsHH02DCBDwbkmMSXJqBj6nBOCo9iocY=
|
||||
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/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.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
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.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/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.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
|
||||
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.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/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/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.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1 h1:0O6j18nQDoIff6/mtosT5LNU2WehEwp+LqfX6699oqY=
|
||||
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/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
|
||||
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/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||
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/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 h1:5y2LR2pripbccOte6BpZ+guv5O5W0OQIfKvUYZrGaAQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0/go.mod h1:b3f7EifFRqkVt142USoGIP62uUeZm7ALCPrs1ZjWXYo=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0 h1:tRsXhJnn0w0TNwJpVrB94wsrimzjWKFzdQlUhRdNUzc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0/go.mod h1:ifhe5teRNcbxg9p353DhOkOgczzL/IBLYFqT1cm1zpM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 h1:aPMa5qZU9dp4fU34o+jQgVIPFIOi20oxYg8TLigmZLg=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0/go.mod h1:sg6Wqbl0BKKZ5zKnQj2q0qic/3ziF894SCx1LpbX37Y=
|
||||
github.com/IBM/sarama v1.61.1 h1:I59MWPHQUWqJNdRpsDUcbeCriog8SxjQaPfHNWxidEg=
|
||||
github.com/IBM/sarama v1.61.1/go.mod h1:dITlGHIiCQL/maGtBfDHNMDvyWgC9Ww//8pmlsU3RUs=
|
||||
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
|
||||
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
|
||||
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/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/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
|
||||
github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
|
||||
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.24.0 h1:zy31L1a49QTNB2bG1BBfMXol3yJrTH975G3pPubQVLQ=
|
||||
github.com/apache/thrift v0.24.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs=
|
||||
github.com/apache/thrift v0.25.0 h1:6LLlvoPjy8Cc/0b5TYRcTR3RZa2W0at3WQRcOkDwVGs=
|
||||
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/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/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/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
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/beevik/ntp v1.5.0 h1:y+uj/JjNwlY2JahivxYvtmv4ehfi3h74fAuABB9ZSM4=
|
||||
github.com/beevik/ntp v1.5.0/go.mod h1:mJEhBrwT76w9D+IfOEGvuzyuudiW9E52U2BaTrMOYow=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beevik/ntp v1.6.0 h1:bW359Vvw6bicGhKsVi+ZDLMsMYzb2ohUpLaF/Juv0k8=
|
||||
github.com/beevik/ntp v1.6.0/go.mod h1:VTAbDFySkuc8cQiAYHhXslAHjJdenICebskhYqBWC4k=
|
||||
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/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/buger/jsonparser v1.6.1 h1:I0phFv0PlbLHnM7TZAVjZ2MJ2/eWRTDyuO7GLR98IEs=
|
||||
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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
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/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
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.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||
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/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
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/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/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/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/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
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/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA=
|
||||
github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho=
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws=
|
||||
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/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/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/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.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.40.0 h1:G4dcl8KE/HVPnZ25BBdvGZWi8JjyeBfQSvm95U5P1Qc=
|
||||
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/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/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/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.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
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/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
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/go.mod h1:dGzUxdNqXsAijatByVgbAWVPlFirnhknQbdazcUIjY0=
|
||||
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/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.4/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.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
|
||||
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
|
||||
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.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
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.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
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 v0.26.2/go.mod h1:KAxGydTYCbs6dK9zjCMQ1AoEVD2RzSWEQNgmb64dRpI=
|
||||
github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I=
|
||||
github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w=
|
||||
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
||||
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
|
||||
github.com/go-openapi/jsonreference v1.0.1 h1:4zJ7AmYDKNmD3aSpfPnFNCFA5E80/xMHUNKgydaLh38=
|
||||
github.com/go-openapi/jsonreference v1.0.1/go.mod h1:dYplQXa6p5lXprLcJ8LE2iU7vNpXsAHDQ5ZAgL+Qx3A=
|
||||
github.com/go-openapi/loads v0.25.2 h1:+uNsDlRQfYtZTrh+3pdwampcAqZVPuBJW0IA82aZHII=
|
||||
github.com/go-openapi/loads v0.25.2/go.mod h1:RXsfJEQGGNv4uw8u/KdmF8Vh8OR4fTfs9ZO4QrzNohA=
|
||||
github.com/go-openapi/runtime v0.33.1 h1:jCvhI+wAdsn29byy+RgcPcg+j39YT6E304QOE/WqIVk=
|
||||
github.com/go-openapi/runtime v0.33.1/go.mod h1:Dl5SMVRnJz+d8bX6Y1zxy0QKpqe/ysvVeUEh1nCpEZ4=
|
||||
github.com/go-openapi/runtime/server-middleware v0.33.1 h1:IAeKbwWnBnpsYTpuPVS8t73ZrPpKvRZnK2iJ2KJGUV0=
|
||||
github.com/go-openapi/runtime/server-middleware v0.33.1/go.mod h1:2Gej5fDxqeJxY+w38vxXYW0BgFASfgBsJ5rXwN1Fseg=
|
||||
github.com/go-openapi/spec v1.0.0 h1:JtB/GHOj+eetjse6YvxqLze88oEekl/4uPBethvzRrA=
|
||||
github.com/go-openapi/spec v1.0.0/go.mod h1:boj1PRhqS0x5jylgcNp9BRWxenphrh7vVNYZ90IRCoo=
|
||||
github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ=
|
||||
github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E=
|
||||
github.com/go-openapi/swag v0.29.1 h1:C6EeWzUwQtcWEhE9eqBdUubGXxhWY4PlzHMLD7kLaiQ=
|
||||
github.com/go-openapi/swag v0.29.1/go.mod h1:BzxEXKiPlSXRsRTv1KSBF/BpGKHxA/YciCnr4tv9bvA=
|
||||
github.com/go-openapi/swag/cmdutils v0.29.1 h1:3DorPGfUdE80BogKY22EzoHBcHMrkVomZMoV7kS4ANY=
|
||||
github.com/go-openapi/swag/cmdutils v0.29.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
|
||||
github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA=
|
||||
github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM=
|
||||
github.com/go-openapi/swag/fileutils v0.29.1 h1:ZcPzMceVhU1WPbK6N1G6sNQKdd1CWJlf3cA08UHuoM0=
|
||||
github.com/go-openapi/swag/fileutils v0.29.1/go.mod h1:/wofKYckbtRl2p3+EwQsosie5CT1B38+dQ+PS579BzI=
|
||||
github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c=
|
||||
github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg=
|
||||
github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc=
|
||||
github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw=
|
||||
github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I=
|
||||
github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s=
|
||||
github.com/go-openapi/swag/netutils v0.29.1 h1:IjIvdEP5duKcghFqJEPSUraRnkKYHoM65kTluTu+Jb4=
|
||||
github.com/go-openapi/swag/netutils v0.29.1/go.mod h1:DUde7x4Bx00k5jYl2AdRpNAO0m7atUvD2x6X+bWkbno=
|
||||
github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg=
|
||||
github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc=
|
||||
github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0=
|
||||
github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc=
|
||||
github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M=
|
||||
github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE=
|
||||
github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk=
|
||||
github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
|
||||
github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8=
|
||||
github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/go-openapi/validate v0.26.5 h1:Vm02dSmhevDx/4v4m8KAtMwffHGfq9wRLqICeebE/D4=
|
||||
github.com/go-openapi/validate v0.26.5/go.mod h1:2VVi2kpxSvInv/OOI6echOcYddydjXOxJvkHQ5VsoZw=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-openapi/analysis v1.0.1 h1:JTEyujgl4GyFj8pbMUY2yZx5ak1Ozt/DaqzQjQBnBWM=
|
||||
github.com/go-openapi/analysis v1.0.1/go.mod h1:f3gYbXmnXSQIZB02DqE4f8JX969PdOEnaY7gDBLeJjI=
|
||||
github.com/go-openapi/errors v0.22.9 h1:HI9+SyVYiRzyeBQGv0CbWbQa+u2S0nny7G7AhY+UMiM=
|
||||
github.com/go-openapi/errors v0.22.9/go.mod h1:R73q66sXP2smzAGgFOI4nqtusRK2xN5pYCjsij+KX58=
|
||||
github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
|
||||
github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8=
|
||||
github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ=
|
||||
github.com/go-openapi/jsonreference v1.0.3/go.mod h1:OJSlMXHysgH272D9z2tiy2MGe5A9vQLHaCPb6KbYdE4=
|
||||
github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34P0=
|
||||
github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo=
|
||||
github.com/go-openapi/runtime v0.33.3 h1:qVgTDaFH+pzO8UqlwYNVmx2DD+nwVK3rB65V29ednD0=
|
||||
github.com/go-openapi/runtime v0.33.3/go.mod h1:57crQFXXnCtT68LuAd+NOELX5tSF4ZE1GFW3Z+r/KCk=
|
||||
github.com/go-openapi/runtime/server-middleware v0.33.3 h1:Td5UBla/J/KCjlsD0tmZYWQWxnT4gHpfXbueLQv9Umw=
|
||||
github.com/go-openapi/runtime/server-middleware v0.33.3/go.mod h1:dopwrwqIqXe4xWtovDZkkXJzYqKw+Ybmy7G/hIDUWKY=
|
||||
github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk=
|
||||
github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8=
|
||||
github.com/go-openapi/strfmt v0.27.4 h1:E1xsvHb8R9vsuYru4TP2PVVTv9AIgSFAUACUZrFQfCI=
|
||||
github.com/go-openapi/strfmt v0.27.4/go.mod h1:SCOff81PPhU26acBiW96jHaFycEbT2nkQRvojM+V2z0=
|
||||
github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4=
|
||||
github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo=
|
||||
github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro=
|
||||
github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw=
|
||||
github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0=
|
||||
github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q=
|
||||
github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI=
|
||||
github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0=
|
||||
github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k=
|
||||
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.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM=
|
||||
github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY=
|
||||
github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI=
|
||||
github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog=
|
||||
github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8=
|
||||
github.com/go-openapi/swag/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8=
|
||||
github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE=
|
||||
github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c=
|
||||
github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc=
|
||||
github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk=
|
||||
github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI=
|
||||
github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I=
|
||||
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.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM=
|
||||
github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.8.0 h1:dvOQNZ2ovOdogn902NYq7i6jUI9Dma4qyjTXABY5YTY=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.8.0/go.mod h1:3VwZZUT6nWcAjhIr2RypXxlsxd3mPPtXh3T8z65aRlQ=
|
||||
github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho=
|
||||
github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak=
|
||||
github.com/go-openapi/validate v1.0.1 h1:Z337eNvBb5KhZiKtjBKfpk2ZHPI8otENKyp/I60XVos=
|
||||
github.com/go-openapi/validate v1.0.1/go.mod h1:2ZKOLz7jBYipFCn3IDGwppZwtbUHlQTMuKfSWTO7Nqk=
|
||||
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
|
||||
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
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.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
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.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
|
||||
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/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
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/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.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/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
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/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/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
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/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
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-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/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-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
|
||||
github.com/google/s2a-go v0.1.11 h1:7v1V1TraNGP63WptsAkNqr2w8jgm4vM6A6qx6Lsw9iM=
|
||||
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/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
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/googleapis/enterprise-certificate-proxy v0.3.18 h1:hvVi34VucdrV1IIsiWuqYM8kutw/92MxNEFxCJZEh0k=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
|
||||
github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE=
|
||||
github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.24 h1:G9iCJIY+AYVmUskKdjX8FVy7sG+nbzjOQ41cvlmhcvg=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.24/go.mod h1:hEjCmnoakQzv5ImLUp6alkY7XgLlNoAxwTXmhHHNIsI=
|
||||
github.com/googleapis/gax-go/v2 v2.27.0 h1:qe2iQOUzxb3fd6x6ewpIEixPQhJ+0Ihc/RRZhKA/2g0=
|
||||
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/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/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/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.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 h1:QMILzF8BA7xftE4zVORkRrlv08m12St3csK4RsyRgRs=
|
||||
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.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
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/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/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||
github.com/hashicorp/go-msgpack v1.1.5 h1:9byZdVjKTe5mce63pRVNP1L7UAmdHOTEMGehn6KvJWs=
|
||||
github.com/hashicorp/go-msgpack v1.1.5/go.mod h1:gWVc3sv/wbDmR3rQsj1CAktEZzoz1YNK9NfGLXJ69/4=
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.1 h1:xQEY9yB2wnHitoSzk/B9UjXWRQ67QKu5AOm8aFp8N3I=
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.1/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4=
|
||||
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/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.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
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/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
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.3.9/go.mod h1:4Ak7FSPnuvmb0GV6vgIAJ4vYT4bek9bb6Q+7HVbyzqM=
|
||||
github.com/hashicorp/raft v1.6.0 h1:tkIAORZy2GbJ2Trp5eUSggLXDPOJLXC+JJLNMMqtgtM=
|
||||
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/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
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/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/klauspost/compress v1.14.4/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
|
||||
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.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U=
|
||||
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/klauspost/filepathx v1.1.1 h1:201zvAsL1PhZvmXTP+QLer3AavWrO3U1NILWpniHK4w=
|
||||
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.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/klauspost/pgzip v1.3.0 h1:8t+KjjzD/0n/8vNlJGGZmTeK4njuwffOQvJm8tOSc8o=
|
||||
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/go.mod h1:7bolpMKhT5LKskLwYXGSDOyA2TYtMFgdgV0Y8gy7QhA=
|
||||
github.com/klauspost/reedsolomon v1.13.3 h1:01GwnO2xoCSaM0ShP4qwl+FsHg3csFShC6Tu/RS1ji0=
|
||||
github.com/klauspost/reedsolomon v1.13.3/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
|
||||
github.com/klauspost/reedsolomon v1.14.2 h1:SafJYwpBBQBI6amHUygcjxZjXeN2HpiENHQDwuPWCCQ=
|
||||
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/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
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/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/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4=
|
||||
github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8=
|
||||
github.com/lestrrat-go/jwx/v3 v3.4.0 h1:2YSO4sfAj+M90XUuEJZsMibRNpoh1KXP1iL+C/tFebg=
|
||||
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/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lithammer/shortuuid/v4 v4.3.0 h1:XRr80OYPOlqxgnCv2/iuElkl/ZtXtPtLKk2AvpXGdWA=
|
||||
github.com/lithammer/shortuuid/v4 v4.3.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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
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/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.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
|
||||
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/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.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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
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/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/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/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
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/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/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/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/go.mod h1:Q9Ct0KUAuN9dH0hSVa0eva45Jg99cahbZpPxeqR9rOQ=
|
||||
github.com/minio/kms-go/kms v0.6.0 h1:oGdGUyjfCZwRIi7em0aj4wk+oOm7+4a0lzSZny7ZIDU=
|
||||
github.com/minio/kms-go/kms v0.6.0/go.mod h1:me+0DQoVzfERHCBrGLLnhq1NCqj7a+BNWcnigSLPnpQ=
|
||||
github.com/minio/kms-go/kms v0.7.0 h1:vxkStC2gJUHvrooDn0Ep8mPP8bWkiZcAtSUWo6boQ5o=
|
||||
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/go.mod h1:WOe2kYmYl1OIlY2DSRHVQ8j1v4OItARQ6jGyQqcCud8=
|
||||
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/minio-go/v7 v7.3.1-0.20260915093545-32e1f32cb176 h1:TIrbAkJoMN/kbtV4LAitf/eidrbvvYcujXPbSe+4j/0=
|
||||
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 h1:BIn1BJi2zZ+z5BIMsr02USFB+Wmipc4SrIAA8nksNH0=
|
||||
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/go.mod h1:INYT4sMSTJy0QWUEA/E2DZNxJ5sAxIwbnyZjkzNFRfE=
|
||||
github.com/minio/pkg/v3 v3.6.1 h1:gaNT80BS/iuIany5ylTkVmfN4s6UYY30OtImFv4GQA8=
|
||||
github.com/minio/pkg/v3 v3.6.1/go.mod h1:fYlexVD0GMD0XNeBHeefFI6YBE0Oo8oDbDPWm3Jd68I=
|
||||
github.com/minio/pkg/v3 v3.11.0 h1:WjzWGP7lyuupJgUSRim1RDtNujHtESyMQF7qSR4r5Dg=
|
||||
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/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||
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/sio v0.4.3 h1:JqyID1XM86KwBZox5RAdLD4MLPIDoCY2cke2CXCJCkg=
|
||||
github.com/minio/sio v0.4.3/go.mod h1:4ANoe4CCXqnt1FCiLM0+vlBUhhWZzVOhYCz0069KtFc=
|
||||
github.com/minio/sio v0.5.1 h1:sqtImrnCSHbDqO/lVy3tfbsctHzfelDv3NbXWEVcWT8=
|
||||
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/go.mod h1:COH1CePZfHT9Ec1O7vZjTlX5uEPpyYnrifPNbu665DM=
|
||||
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/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/nats-io/jwt/v2 v2.2.1-0.20220330180145-442af02fd36a/go.mod h1:0tqz9Hlu6bCBFLWAASKhE5vUA4c24L9KPUUgvwumE/k=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.8.2/go.mod h1:vIdpKz3OG+DCg4q/xVPdXHoztEyKDWRtykQ4N7hd7C4=
|
||||
github.com/nats-io/nats-server/v2 v2.11.15 h1:StSf9TINInaZtr4oww2+kXmfwa9SkN//g/LwS19/UJ0=
|
||||
github.com/nats-io/nats-server/v2 v2.11.15/go.mod h1:zwhv8Y0PE3KHyKgznJc/9Xoai638SaJd83zzJ5GJn74=
|
||||
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/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/nats-server/v2 v2.15.1 h1:fK3AeYFIji1Smng60jB0hVoAPZezA/UzsaI3BqXFNEI=
|
||||
github.com/nats-io/nats-server/v2 v2.15.1/go.mod h1:yeuYRtKVKIFtmMz2/YPk+6hvmNBnk+WwTUzErBdH6IE=
|
||||
github.com/nats-io/nats-streaming-server v0.25.6 h1:8OBRaIl64u+DFvZYpF50RRzwG/yLcJZL0R7VMc7tp4Y=
|
||||
github.com/nats-io/nats-streaming-server v0.25.6/go.mod h1:LEcu6uGSDBB4O/IBUsDBHYk/O0K7XZza8nMjCIXicLk=
|
||||
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.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||
github.com/nats-io/nats.go v1.55.0 h1:Ng8QKHdvdHIWoaiq0+d/aH1BWkxvcJxWJ0Y/9BPKx3M=
|
||||
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.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
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/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/go.mod h1:3XJXH8GagrGqajoO/9+HgPyKV5MWsv7S5ccdda+pc6k=
|
||||
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/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
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/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-20260916070421-e952aa78f10a/go.mod h1:6z9lX8kOvaWz2vLlP7BjjF3JWHnIH86XDA2HwcmpZH8=
|
||||
github.com/pgsty/silo-console v0.0.0-20261015132217-e5b165e4c0ee h1:e51AnrGe0Zq+nHTYaGfv/GkehQMO9Mekuw/ew6zYwOM=
|
||||
github.com/pgsty/silo-console v0.0.0-20261015132217-e5b165e4c0ee/go.mod h1:jpt39bx4wjhXoFB+BTOa/bx/pmU3pXzp2cT7PnT21Lo=
|
||||
github.com/pgsty/silo-pkg/v3 v3.14.1 h1:8MONT3Hky9EOnsuPk29590pEBE6MILaOnJtaejPPi7M=
|
||||
github.com/pgsty/silo-pkg/v3 v3.14.1/go.mod h1:jxKWxi52jSsDaDhXXPqjOyPDYdxx2iqdBXyLfM/xSIw=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pierrec/lz4/v4 v4.1.30 h1:cchX8N2DVP668WkElI9QMwVyoNabLkq1LofDHFeIrdg=
|
||||
github.com/pierrec/lz4/v4 v4.1.30/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pgsty/mc v0.0.0-20261025104502-dcc78d506f94 h1:M5n0rCPOTyo3RiQBeSCQW/klx08efFpiVYmPq6eoM3w=
|
||||
github.com/pgsty/mc v0.0.0-20261025104502-dcc78d506f94/go.mod h1:H3Uwlm1Qj3kPsbmojhNnv3aj5a+QdjdTKwv4w4gMSF8=
|
||||
github.com/pgsty/silo-console v0.0.0-20261025140954-258717be077b h1:/RQ+D5TRP8/GGZaY5cbwIixd1RFE0eQjEVLBpIl3+8k=
|
||||
github.com/pgsty/silo-console v0.0.0-20261025140954-258717be077b/go.mod h1:fMxNcVxan3mu9mc7HCRiczSyQoFrbZEGumEmpatxtds=
|
||||
github.com/pgsty/silo-pkg/v3 v3.15.1 h1:T5rqY3HR1WGybcjnx/ghBvliEgo7yNCA7JAC3f1LpTk=
|
||||
github.com/pgsty/silo-pkg/v3 v3.15.1/go.mod h1:GmTF6fIEkTrzgM+ysWWfDZIlVRD2DYAJkKjA5bjiWpQ=
|
||||
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/pierrec/lz4/v4 v4.1.33 h1:GjG1TJ1V4IzKP8L96muuuDNpTwd7D+l2ccXrjAbe014=
|
||||
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/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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
|
||||
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
|
||||
github.com/pkg/sftp v1.13.12 h1:Myhzy/ZzLPnCtE2F2WtDHsgs8RfgMnH2gfbHuCJhkMU=
|
||||
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/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||
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/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
|
||||
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-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
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/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 h1:XA01Vk/wv9YikCi1V51yRzIHPMT5of9+cMpZoZvDn/M=
|
||||
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0=
|
||||
github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
|
||||
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
||||
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
|
||||
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
|
||||
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
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/common v0.72.1 h1:ZAiQed/nk8IIcsdzb2TlJu23NEUDyDLQ9TXTN2WRK6Y=
|
||||
github.com/prometheus/common v0.72.1/go.mod h1:1IoBY4IRaJnhs2ESjNzjilP1cJ/P8pimGoomq7zRXrA=
|
||||
github.com/prometheus/procfs v0.23.0 h1:ad+S6FTkzwaMgacKLM5h6bNKExS+DMH0YiUwXItH/o4=
|
||||
github.com/prometheus/procfs v0.23.0/go.mod h1:3AVXO0FqLafyYFU/V/IQRZlC8Rgqz8Dm/x6pKLK+RIk=
|
||||
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/prometheus v0.314.0 h1:YjsimqsIi6/mOtzZcrPEYUALO6zpfaht9O5sXqDz2vg=
|
||||
github.com/prometheus/prometheus v0.314.0/go.mod h1:zjg3pMTAkY0/JG8jy/h8/YgSQUVB+aCXMhUqN6l64jg=
|
||||
github.com/prometheus/prometheus v0.315.1 h1:ZXaNKyUO5szJhhw7gKiP6b2jpx1QkS0S3CI1R1bPqIA=
|
||||
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/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek=
|
||||
github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/rabbitmq/amqp091-go v1.16.0 h1:1O8oQqnj9hLACoo6CVMgn57z2pLWXvgemhh4GsquV+Y=
|
||||
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/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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
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/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/shoenig/go-m1cpu v0.2.2 h1:4nc55oVv7nygGnfI9bhLCLzUEs4794y0Bkqx4q2zy7Y=
|
||||
github.com/shoenig/go-m1cpu v0.2.2/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
|
||||
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||
github.com/shoenig/go-m1cpu v0.2.4 h1:F5esY3x6L2iIxmqHnMZ5afJwXBy573/fwkPgvatExgE=
|
||||
github.com/shoenig/go-m1cpu v0.2.4/go.mod h1:38ReYghj5Sqaa2nPBh2aJAHyFSi2vpTQJLM7TFJRxNM=
|
||||
github.com/shoenig/test v1.13.2 h1:SaGxHxg7xkRuKuNtuFmHf0LgNGaAgcBT7HN4WHCKfqU=
|
||||
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/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
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.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
|
||||
github.com/spiffe/go-spiffe/v2 v2.9.0 h1:g5qTg3qIP1HXV+GzuyEfSJFVxUCpEP5Ui1OINVJHPB4=
|
||||
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
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.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto=
|
||||
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/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tidwall/pretty v1.2.2 h1:dz1jrRuE7or/74V490B4/GP1pZm5WKlt2bgCP5A83w8=
|
||||
github.com/tidwall/pretty v1.2.2/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
|
||||
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/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
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/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
|
||||
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/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
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.4/go.mod h1:IFyzS6Xis5dnBH/rdAhrnuzg3c+KkUqEN6yE8lhJlDw=
|
||||
github.com/vbauerster/mpb/v8 v8.16.1 h1:gNYmwMip9xRWNGAiblZOgUNXWeU2P0NIGd5x0f8ffbc=
|
||||
github.com/vbauerster/mpb/v8 v8.16.1/go.mod h1:gnU8zNF/JWltFepqwko/ulMEUIDrydIq7T4UdMN26Nw=
|
||||
github.com/vbauerster/cupwriter v0.0.5 h1:pxlPgxPLIebXh8lJPOZmn2TmjPE8+PqJIFkKkr7hI9I=
|
||||
github.com/vbauerster/cupwriter v0.0.5/go.mod h1:ptJDgOtLtGZp1gLzycD0TFnZztDhrQdsikG6EAA4xYQ=
|
||||
github.com/vbauerster/mpb/v8 v8.16.3 h1:C9AShAmENWKGHRPFU47CGMhVGUkLh38kzK7dcTKcH8g=
|
||||
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/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/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
|
||||
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/yuin/goldmark v1.1.27/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/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
||||
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
||||
go.etcd.io/etcd/api/v3 v3.7.1 h1:KJG0/DcWGfe3Y1otDf/fsBf0TSSgpxZ5RO/L8SFt73E=
|
||||
go.etcd.io/etcd/api/v3 v3.7.1/go.mod h1:8bXIpCMeV7E3/XL0Ix123ATn3dB+0V7d9zklHbB0m78=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.7.1 h1:rKYsj3pRkR0eK3yjT3XOgrhqfmIfj9pzNgxjh7mfFv4=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.7.1/go.mod h1:cnzZGIUzSfjEwLC6UBVsSXlEK1eepS/JUD7wE6PLRT0=
|
||||
go.etcd.io/etcd/client/v3 v3.7.1 h1:0PEMMC0KuZmVIN+RAbdqfkZ45pYTgKVtmBEbRCvZFUg=
|
||||
go.etcd.io/etcd/client/v3 v3.7.1/go.mod h1:ffNqALa8tRCYhYo1F9oR489y23K39Gz+BSR3ApAGYq0=
|
||||
go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA=
|
||||
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||
go.etcd.io/etcd/api/v3 v3.7.3 h1:2tumepXe+wi7Wab64uUO71K7ISQyUWLGC1DJbbc59sY=
|
||||
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.3 h1:5zMwNu3ycrzCeA5NwOfQbxFRhqq974chq1DrYMua4QI=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.7.3/go.mod h1:/OhzqYrF0U7d1NOk55VMyUQGbYD1WoIe4bJwxb47wOk=
|
||||
go.etcd.io/etcd/client/v3 v3.7.3 h1:UrXcetgkuSEocaBlQlulQRJlXLEnUAHuBRbHh0lue1Q=
|
||||
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/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
|
||||
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
|
||||
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
|
||||
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
||||
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.47.0 h1:Ct6+aaiSO6Nk5SOb6PYcR7iWp+83YF/pL1OJcGqOmbg=
|
||||
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.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M=
|
||||
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.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw=
|
||||
go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
|
||||
go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI=
|
||||
go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
|
||||
go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
|
||||
go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
|
||||
go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
|
||||
go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM=
|
||||
go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k=
|
||||
go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I=
|
||||
go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw=
|
||||
go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
|
||||
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/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
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-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-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.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
|
||||
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
|
||||
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/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A=
|
||||
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.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.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
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/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
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-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.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.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
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/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||
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-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-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.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
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-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-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-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-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-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-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-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-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-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-20220722155257-8c9f86f7a55f/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.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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
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/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-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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
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.3/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.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.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
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=
|
||||
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/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-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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
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-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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
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.290.0/go.mod h1:weJZ3lldHFYI0DBFNKpJelUDNnusTt5YaOEgxvt8ci8=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
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/api v0.308.0 h1:iGb+4qT3nVb3uuIzwor3yP2nyu+rMZr31PvLFiRNJQk=
|
||||
google.golang.org/api v0.308.0/go.mod h1:4XMH+b/ZEfuTbK7FnrFRY9c4xfFHVflGTKuy/i5wUmE=
|
||||
google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b h1:+t2qGqt6R8CO622uiX1JpnRvRvSOQoVRV0DiPzUjKXg=
|
||||
google.golang.org/genproto v0.0.0-20261019180522-ce908d29299b/go.mod h1:XRIfc1yr5xsbF8xFw5+g7SEmLJX/eQ7MMazTRbnMMvI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:nB3spRIaSPtxJpxb0k41yljROvm2Jrwfs2pSNdKS5y8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
|
||||
google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
|
||||
google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -25,7 +25,7 @@ imagePullSecrets: []
|
||||
##
|
||||
mcImage:
|
||||
repository: pgsty/mc
|
||||
tag: RELEASE.2026-09-16T00-00-00Z
|
||||
tag: RELEASE.2026-10-18T00-00-00Z
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
## Silo mode, i.e. standalone or distributed.
|
||||
|
||||
@@ -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.
|
||||
// The same context that have been used during encryption must be
|
||||
// provided.
|
||||
func DecryptBytes(k *kms.KMS, ciphertext []byte, context kms.Context) ([]byte, error) {
|
||||
plaintext, err := Decrypt(k, bytes.NewReader(ciphertext), context)
|
||||
func DecryptBytes(ctx context.Context, k *kms.KMS, ciphertext []byte, associatedData kms.Context) ([]byte, error) {
|
||||
plaintext, err := Decrypt(ctx, k, bytes.NewReader(ciphertext), associatedData)
|
||||
if err != nil {
|
||||
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.
|
||||
// The same context that have been used during encryption must be
|
||||
// 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 (
|
||||
MaxMetadataSize = 1 << 20 // max. size of the metadata
|
||||
Version = 1
|
||||
@@ -145,7 +148,7 @@ func Decrypt(k *kms.KMS, ciphertext io.Reader, associatedData kms.Context) (io.R
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := k.Decrypt(context.TODO(), &kms.DecryptRequest{
|
||||
key, err := k.Decrypt(ctx, &kms.DecryptRequest{
|
||||
Name: metadata.KeyID,
|
||||
Ciphertext: metadata.KMSKey,
|
||||
AssociatedData: associatedData,
|
||||
|
||||
@@ -19,6 +19,7 @@ package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"testing"
|
||||
@@ -68,7 +69,7 @@ func TestEncryptDecrypt(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("Test %d: failed to decrypt stream: %v", i, err)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
|
||||
// DARECiphers returns a list of supported cipher suites
|
||||
// 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
|
||||
// cipher suite IDs.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,9 +130,12 @@ func (key *ObjectKey) Unseal(extKey []byte, sealedKey SealedKey, domain, bucket,
|
||||
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
|
||||
}
|
||||
// DARE v1 may allocate a new buffer instead of reusing key's backing array.
|
||||
copy(key[:], out)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,15 @@ func (sses3) IsEncrypted(metadata map[string]string) bool {
|
||||
// from the metadata using KMS and returns the decrypted object
|
||||
// key.
|
||||
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 {
|
||||
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 {
|
||||
return key, err
|
||||
}
|
||||
unsealKey, err := k.Decrypt(context.TODO(), &kms.DecryptRequest{
|
||||
unsealKey, err := k.Decrypt(ctx, &kms.DecryptRequest{
|
||||
Name: keyID,
|
||||
Ciphertext: kmsKey,
|
||||
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))
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -341,6 +341,8 @@ func (target *KafkaTarget) initKafka() error {
|
||||
args := target.args
|
||||
|
||||
config := sarama.NewConfig()
|
||||
// Preserve SILO's default broker protocol when no version is configured.
|
||||
config.Version = sarama.V2_1_0_0
|
||||
if args.Version != "" {
|
||||
kafkaVersion, err := sarama.ParseKafkaVersion(args.Version)
|
||||
if err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -235,8 +235,11 @@ func (c *kesConn) Decrypt(ctx context.Context, req *DecryptRequest) ([]byte, 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 ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if errors.Is(err, kes.ErrKeyNotFound) {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
|
||||
@@ -238,6 +238,8 @@ func (h *Target) init() error {
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
kafkaVersion, err := sarama.ParseKafkaVersion(h.kconfig.Version)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user