mirror of
https://github.com/pgsty/minio.git
synced 2026-10-01 15:25:58 +03:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82f0a9828e | |||
| a168576adb | |||
| 0e3c43778e | |||
| 9101fe78db | |||
| 82948d6306 | |||
| dfb4b2a1d2 | |||
| 143f6970d8 | |||
| ea5dac5a99 | |||
| 3c26a8b0b5 | |||
| 2fabd436c0 | |||
| 07c68d6054 | |||
| e791640dac | |||
| 9b4ae82a29 | |||
| 4620be394b | |||
| 5e7d603083 | |||
| fb7c406ddc | |||
| 416826f61f | |||
| a2e2f3ee82 | |||
| 70c7ec4a9f | |||
| 2b722b7e92 | |||
| 4cbb074ccd | |||
| 40220bd836 | |||
| df0dfa0a34 | |||
| 80684fed59 | |||
| 055030ea53 | |||
| aea3882c95 | |||
| 0c61128d23 | |||
| 680eac66e4 | |||
| 9f3037e941 | |||
| 5f00f6762f | |||
| af2b1794d3 | |||
| d371f77dcc | |||
| 022722a7a7 | |||
| 03027727d1 | |||
| 4fcdf37ce6 | |||
| 9ebe81c1b3 | |||
| e5f5c9e7f6 |
@@ -90,6 +90,12 @@ jobs:
|
||||
- name: Run S3 Select tests under race detector
|
||||
run: go test -race ./internal/s3select/... -count=1
|
||||
|
||||
- name: Run conditional PUT tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(PoolsConditionalPut|SinglePoolConditionalPutHTTP)' -count=1 -timeout=5m
|
||||
|
||||
- name: Run multipart listing and cancellation tests under race detector
|
||||
run: go test -race ./cmd -run '^Test(MultipartListing|MultipartAbort|PaginateMultipartUploads|ListMultipartUploads)' -count=1 -timeout=5m
|
||||
|
||||
crosscompile:
|
||||
name: Cross Compile
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
name: Repository Cards
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 00:00 UTC = 08:00 Asia/Shanghai. GitHub may queue scheduled runs.
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- .github/workflows/repository-cards.yml
|
||||
- .github/silo.svg
|
||||
- buildscripts/repository-cards/**
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- .github/workflows/repository-cards.yml
|
||||
- .github/silo.svg
|
||||
- buildscripts/repository-cards/**
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: repository-cards-${{ github.event.pull_request.number || 'publish' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Validate repository cards
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- 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
|
||||
- run: python -m unittest discover -s buildscripts/repository-cards -p 'test_*.py' -v
|
||||
|
||||
publish:
|
||||
name: Update README images
|
||||
needs: check
|
||||
if: github.repository == 'pgsty/silo' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read
|
||||
pull-requests: read
|
||||
env:
|
||||
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: Load the generated-assets branch
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifacts="$RUNNER_TEMP/repository-cards"
|
||||
if git ls-remote --exit-code --heads origin "$OUTPUT_BRANCH"; then
|
||||
git fetch --depth=1 origin "$OUTPUT_BRANCH"
|
||||
git worktree add --detach "$artifacts" FETCH_HEAD
|
||||
else
|
||||
status=$?
|
||||
# Exit 2 means no matching ref; transport/auth failures must stop.
|
||||
if [ "$status" -ne 2 ]; then exit "$status"; fi
|
||||
git worktree add --detach "$artifacts" HEAD
|
||||
git -C "$artifacts" checkout --orphan "$OUTPUT_BRANCH"
|
||||
git -C "$artifacts" rm -rf .
|
||||
fi
|
||||
|
||||
- name: Refresh contributor and star cards
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python buildscripts/repository-cards/update.py --output "$RUNNER_TEMP/repository-cards"
|
||||
|
||||
- name: Publish changed assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$RUNNER_TEMP/repository-cards"
|
||||
git add README.md history.json curated.json contributors.json \
|
||||
contributors-light.svg contributors-dark.svg \
|
||||
star-history-light.svg star-history-dark.svg
|
||||
if git diff --cached --quiet; then
|
||||
echo "Repository cards are already current."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git commit -s -m "chore: update repository cards $(date -u +%F)"
|
||||
# A normal push preserves history and refuses concurrent overwrites.
|
||||
git push origin "HEAD:refs/heads/$OUTPUT_BRANCH"
|
||||
+98
-3
@@ -9,6 +9,33 @@ and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-0
|
||||
|
||||
### Authorization and security
|
||||
|
||||
- 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://github.com/pgsty/silo.pgsty.com/blob/7bd2d57c2ce5aaa804d0b1a2fe0e5eed69d15235/content/operations/replication/iam-upgrade.md).
|
||||
- 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](docs/security/advisories.md).
|
||||
@@ -22,17 +49,85 @@ and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-0
|
||||
|
||||
### Object storage and replication
|
||||
|
||||
- Make `ListMultipartUploads` discover quorum-valid uploads from durable state
|
||||
across pools, erasure sets and drives, then apply S3 prefix, delimiter,
|
||||
marker, ordering and 1,000-entry pagination semantics globally (#198). New uploads
|
||||
store their canonical bucket and key as reserved fields in the existing
|
||||
quorum-written `xl.meta`; completion removes those upload-only fields. Native
|
||||
markers remain usable after their upload is completed or canceled. Strict
|
||||
listing returns a diagnostic 503 for legacy uploads or uncertain coverage;
|
||||
the default remains the released exact-key/cache-based `legacy` behavior.
|
||||
Opt into strict mode only through `MINIO_API_MULTIPART_LISTING=strict`, after
|
||||
upgrading every writer, draining old uploads, checking the read-only admin
|
||||
`multipart-preflight` report and validating scan capacity. The process-only
|
||||
setting is not persisted into shared API configuration. Historical
|
||||
`multipart_listing` keys are ignored and can be removed with a targeted
|
||||
`mcli admin config reset ALIAS api multipart_listing` before rollback. Per-process
|
||||
admission, directory-entry, worker and time budgets bound scan scheduling;
|
||||
each page still scans durable state. See [issue #79](https://github.com/pgsty/silo/issues/79)
|
||||
and its [design record](https://silo.pgsty.com/blog/design/list-multipart-uploads/).
|
||||
Thanks to mr javad seydi (@mrjavadseydi) for the original implementation.
|
||||
- Retain released read-quorum and best-effort multipart cancellation in default
|
||||
legacy mode. Strict mode requires majority deletion acknowledgements and
|
||||
permits retries below read quorum. When most drives were already empty,
|
||||
failed deletion of an observed remnant now returns 503 instead of being
|
||||
masked by empty-drive successes. Wrong-key or wrong-bucket cancellation
|
||||
preserves the valid upload's cache entry; successful cancellation notifies
|
||||
peers with the request context after the distributed lock is released.
|
||||
The HTTP response for an absent
|
||||
upload remains 204; this is not proof of physical cleanup. **Known boundary:**
|
||||
delayed creation writes can still restore an upload after cancellation;
|
||||
this change does not add a durable creation fence.
|
||||
- Preserve object tags during multi-pool metadata reconciliation by reading the
|
||||
resolved tag field together with its revision (#189). Previously, reconciliation
|
||||
could replace existing tags with an empty value.
|
||||
- Preserve the tag revision on SSE-KMS metadata replication (#193), and advance
|
||||
tag revisions monotonically on local PUT/DELETE tagging (#196). Empty tags
|
||||
participate in reconciliation as an ordered deletion, preventing older
|
||||
events from restoring removed tags. SSE-C key rotation also retains the tag
|
||||
revision. Malformed historical revisions can fail and retry; their missing
|
||||
history is not reconstructed by the upgrade.
|
||||
- Complete delete-marker version purges and preserve their identity and retry
|
||||
state through MRF recovery (#196). Recovery accepts a 405 marker response only
|
||||
when its version, bucket, object name and modification time match the task.
|
||||
Purge audit status is normalized from `COMPLETE` to `COMPLETED`.
|
||||
Thanks to Julien Laurenceau (@julienlau) for the investigation and proposed
|
||||
fix in #184 that helped shape this follow-up.
|
||||
- Restore only the six replication-specific metadata fields after ordinary
|
||||
request metadata extraction (#194). This prevents transport-only `aws-chunked`
|
||||
from being stored as Content-Encoding while preserving the signed-header
|
||||
protections. Trusted Snowball entries no longer inherit the outer archive's
|
||||
ordinary metadata. Thanks to Mikhail Khadarenka (@chodorenko) for the fix in #187.
|
||||
**Existing data:** these repairs prevent new errors; they do not scan or rewrite
|
||||
historical object metadata, recover lost tags or prove that old purge work has
|
||||
converged. Follow the [read-only audit procedure](https://github.com/pgsty/silo.pgsty.com/blob/7bd2d57c2ce5aaa804d0b1a2fe0e5eed69d15235/content/operations/replication/replica-metadata-audit.md)
|
||||
before planning any repair of stored state.
|
||||
|
||||
- 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 metadata cannot be read from any pool, conditional
|
||||
**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.
|
||||
|
||||
- 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.
|
||||
|
||||
- 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
|
||||
@@ -61,7 +156,7 @@ and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-0
|
||||
- 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; complete delete-marker purges and report bounded MRF drops.
|
||||
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](docs/investigations/issue-77-current.md).
|
||||
@@ -73,7 +168,7 @@ and [complete commit range](https://github.com/pgsty/silo/compare/RELEASE.2026-0
|
||||
- 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.0; select Console
|
||||
`v0.0.0-20260913015128-417559bb2c97` and MC
|
||||
`v0.0.0-20260916034812-56dfe455ac2f` and MC
|
||||
`v0.0.0-20260913012246-4f609a4da3bb` with explicit PGSTY replacements.
|
||||
- Pin upstream minio-go `v7.3.1-0.20260910142817-60bd07042d49`; refresh Go x/*
|
||||
modules and security fixes including bounded AMQP frame handling. Keep Go
|
||||
|
||||
+7
-3
@@ -1,8 +1,9 @@
|
||||
# Contributors
|
||||
|
||||
SILO is built by **41 community contributors**, including its maintainers. This record covers every
|
||||
SILO is built by **42 community contributors**, including its maintainers. This record covers every
|
||||
human author who has opened an issue or pull request in the repositories listed below, in any state.
|
||||
It was checked against the complete, paginated GitHub API records on **2026-09-07 03:00:14 UTC**.
|
||||
The full API audit below dates to **2026-09-07 03:00:14 UTC**; later individually verified
|
||||
reports include Jiri Pejchal’s Console #52, added on **2026-09-16**.
|
||||
|
||||
Contributors appear once in the avatar wall: authors of merged PRs first, other PR authors next,
|
||||
and issue-only authors after them. Within each group, substantial features, security and correctness
|
||||
@@ -31,6 +32,7 @@ the Git history and [NOTICE](NOTICE); this record covers activity in the PGSTY r
|
||||
<a href="https://github.com/cbornet"><img src="https://silo.pgsty.com/images/contributors/cbornet.svg" width="60" height="60" alt="@cbornet" title="@cbornet — Reported multipart and streaming checksum defects and missing-bucket semantics"></a>
|
||||
<a href="https://github.com/vampywiz17"><img src="https://silo.pgsty.com/images/contributors/vampywiz17.svg" width="60" height="60" alt="@vampywiz17" title="@vampywiz17 — Reported LDAP TLS and Console login regressions"></a>
|
||||
<a href="https://github.com/orenyomtov"><img src="https://silo.pgsty.com/images/contributors/orenyomtov.svg" width="60" height="60" alt="@orenyomtov" title="@orenyomtov — Reported the unsigned-header CopyObject cross-object read (SN-2026-011)"></a>
|
||||
<a href="https://github.com/jiri-pejchal"><img src="https://silo.pgsty.com/images/contributors/jiri-pejchal.svg" width="60" height="60" alt="@jiri-pejchal" title="@jiri-pejchal — Reported the Console share proxy exposure of internal metrics"></a>
|
||||
<a href="https://github.com/mumu-lab"><img src="https://silo.pgsty.com/images/contributors/mumu-lab.svg" width="48" height="48" alt="@mumu-lab" title="@mumu-lab — Reported bucket quota metrics reading a deprecated field"></a>
|
||||
<a href="https://github.com/jvasile"><img src="https://silo.pgsty.com/images/contributors/jvasile.svg" width="48" height="48" alt="@jvasile" title="@jvasile — Reported missing user, group, and defaults in Debian packages"></a>
|
||||
<a href="https://github.com/pmezhuev"><img src="https://silo.pgsty.com/images/contributors/pmezhuev.svg" width="48" height="48" alt="@pmezhuev" title="@pmezhuev — Reported missing RPM package signatures"></a>
|
||||
@@ -98,6 +100,7 @@ for their reports as well; the avatar wall and community total still count each
|
||||
| [@cbornet](https://github.com/cbornet) | [pgsty/silo#31](https://github.com/pgsty/silo/issues/31) Multipart uploads with FULL_OBJECT CRC32 not working<br>[pgsty/silo#32](https://github.com/pgsty/silo/issues/32) `listObjects` should return `NoSuchBucket` when the bucket doesn't exist and prefix is passed<br>[pgsty/silo#107](https://github.com/pgsty/silo/issues/107) PutObject fails with chunked encoding and checksumType |
|
||||
| [@vampywiz17](https://github.com/vampywiz17) | [pgsty/silo#15](https://github.com/pgsty/silo/issues/15) LDAP TLS regression in RELEASE.2026-03-21T00-00-00Z breaks built-in Console and external Console LDAP login on Kubernetes Tenant<br>[pgsty/silo#108](https://github.com/pgsty/silo/issues/108) Web Console login regression in RELEASE.2026-09-03T13-18-01Z (local and LDAP users fail) |
|
||||
| [@orenyomtov](https://github.com/orenyomtov) | Private security disclosure: a presigned or signed PUT could be turned into a server-side CopyObject read of any object the signing key can reach via an unsigned `x-amz-copy-source` header. Fixed as [`SN-2026-011`](https://github.com/pgsty/silo/blob/main/docs/security/advisories.md) ([pgsty/silo#173](https://github.com/pgsty/silo/pull/173)) |
|
||||
| [Jiri Pejchal (@jiri-pejchal)](https://github.com/jiri-pejchal) | [pgsty/silo-console#52](https://github.com/pgsty/silo-console/issues/52) — Reported anonymous access to internal metrics through the public object-sharing proxy, distinguished the demonstrated impact from hypothetical redirect abuse, and proposed server-side controls |
|
||||
| [@mumu-lab](https://github.com/mumu-lab) | [pgsty/silo#106](https://github.com/pgsty/silo/issues/106) 监控指标读取已弃用的 BucketQuota.Quota 字段导致 Quota 指标无值 |
|
||||
| [@jvasile](https://github.com/jvasile) | [pgsty/silo#33](https://github.com/pgsty/silo/issues/33) .deb doesn't create user/group/default files |
|
||||
| [@pmezhuev](https://github.com/pmezhuev) | [pgsty/silo#43](https://github.com/pgsty/silo/issues/43) RPM package for RELEASE.2026-06-18T00-00-00Z is missing GPG signature |
|
||||
@@ -125,7 +128,8 @@ for their reports as well; the avatar wall and community total still count each
|
||||
## Audit scope
|
||||
|
||||
All issue and PR pages were read without a date cutoff. Counts below include automated accounts;
|
||||
the **40-person** roll excludes the two bots, Copilot and dependabot. The maintained product stack
|
||||
the historical **40-person** audit excluded the two bots, Copilot and dependabot.
|
||||
The current 42-person list also includes subsequent verified reports. The maintained product stack
|
||||
is SILO, Console, mcli, and silo-pkg; the SDK, KES, website, and older documentation repository were
|
||||
also checked for community submissions.
|
||||
|
||||
|
||||
@@ -123,53 +123,25 @@ Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md); e
|
||||
|
||||
## Contributors
|
||||
|
||||
**41 community contributors** build SILO, Console, mcli, shared packages, and related projects. The list includes maintainers and every human Issue or PR author, ordered by merged PRs, other PRs, then issue reports. Gold rings highlight significant contributions.
|
||||
<!-- Generated by silo.pgsty.com/bin/contributors.py. -->
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Vonng"><img src="https://silo.pgsty.com/images/contributors/Vonng.svg" width="60" height="60" alt="@Vonng" title="@Vonng — Maintains SILO, Console, mcli, shared packages, releases, and documentation"></a>
|
||||
<a href="https://github.com/h5vx"><img src="https://silo.pgsty.com/images/contributors/h5vx.svg" width="60" height="60" alt="@h5vx" title="@h5vx — Implemented per-bucket CORS configuration and enforcement"></a>
|
||||
<a href="https://github.com/mrjavadseydi"><img src="https://silo.pgsty.com/images/contributors/mrjavadseydi.svg" width="60" height="60" alt="@mrjavadseydi" title="@mrjavadseydi — Fixed effective bucket quota metrics; proposed access-frequency ILM"></a>
|
||||
<a href="https://github.com/Dansyuqri"><img src="https://silo.pgsty.com/images/contributors/Dansyuqri.svg" width="60" height="60" alt="@Dansyuqri" title="@Dansyuqri — Added ChecksumType to multipart completion responses"></a>
|
||||
<a href="https://github.com/ycjlin"><img src="https://silo.pgsty.com/images/contributors/ycjlin.svg" width="60" height="60" alt="@ycjlin" title="@ycjlin — Fixed missing-bucket ListObjects semantics"></a>
|
||||
<a href="https://github.com/pinginfo"><img src="https://silo.pgsty.com/images/contributors/pinginfo.svg" width="60" height="60" alt="@pinginfo" title="@pinginfo — Repaired bucket notification streaming"></a>
|
||||
<a href="https://github.com/ZouhairCharef"><img src="https://silo.pgsty.com/images/contributors/ZouhairCharef.svg" width="60" height="60" alt="@ZouhairCharef" title="@ZouhairCharef — Patched CVE-2026-34986 in go-jose"></a>
|
||||
<a href="https://github.com/mfredenhagen"><img src="https://silo.pgsty.com/images/contributors/mfredenhagen.svg" width="60" height="60" alt="@mfredenhagen" title="@mfredenhagen — Patched CVE-2026-39883 in OpenTelemetry"></a>
|
||||
<a href="https://github.com/waterkip"><img src="https://silo.pgsty.com/images/contributors/waterkip.svg" width="60" height="60" alt="@waterkip" title="@waterkip — Repointed documentation links to the SILO portal"></a>
|
||||
<a href="https://github.com/mikemikimike"><img src="https://silo.pgsty.com/images/contributors/mikemikimike.svg" width="60" height="60" alt="@mikemikimike" title="@mikemikimike — Contributed the replicated SSE-C plaintext part-size fix"></a>
|
||||
<a href="https://github.com/metaneutrons"><img src="https://silo.pgsty.com/images/contributors/metaneutrons.svg" width="60" height="60" alt="@metaneutrons" title="@metaneutrons — Reported and proposed explicit-version delete authorization"></a>
|
||||
<a href="https://github.com/magicxor"><img src="https://silo.pgsty.com/images/contributors/magicxor.svg" width="60" height="60" alt="@magicxor" title="@magicxor — Reported and proposed conditional DELETE support for If-Match"></a>
|
||||
<a href="https://github.com/davinkevin"><img src="https://silo.pgsty.com/images/contributors/davinkevin.svg" width="60" height="60" alt="@davinkevin" title="@davinkevin — Proposed the distroless container image and dependency automation"></a>
|
||||
<a href="https://github.com/lem21h"><img src="https://silo.pgsty.com/images/contributors/lem21h.svg" width="48" height="48" alt="@lem21h" title="@lem21h — Proposed robustness and goroutine improvements"></a>
|
||||
<a href="https://github.com/sulin37392"><img src="https://silo.pgsty.com/images/contributors/sulin37392.svg" width="48" height="48" alt="@sulin37392" title="@sulin37392 — Proposed dependency updates"></a>
|
||||
<a href="https://github.com/cbornet"><img src="https://silo.pgsty.com/images/contributors/cbornet.svg" width="60" height="60" alt="@cbornet" title="@cbornet — Reported multipart and streaming checksum defects and missing-bucket semantics"></a>
|
||||
<a href="https://github.com/vampywiz17"><img src="https://silo.pgsty.com/images/contributors/vampywiz17.svg" width="60" height="60" alt="@vampywiz17" title="@vampywiz17 — Reported LDAP TLS and Console login regressions"></a>
|
||||
<a href="https://github.com/orenyomtov"><img src="https://silo.pgsty.com/images/contributors/orenyomtov.svg" width="60" height="60" alt="@orenyomtov" title="@orenyomtov — Reported the unsigned-header CopyObject cross-object read (SN-2026-011)"></a>
|
||||
<a href="https://github.com/mumu-lab"><img src="https://silo.pgsty.com/images/contributors/mumu-lab.svg" width="48" height="48" alt="@mumu-lab" title="@mumu-lab — Reported bucket quota metrics reading a deprecated field"></a>
|
||||
<a href="https://github.com/jvasile"><img src="https://silo.pgsty.com/images/contributors/jvasile.svg" width="48" height="48" alt="@jvasile" title="@jvasile — Reported missing user, group, and defaults in Debian packages"></a>
|
||||
<a href="https://github.com/pmezhuev"><img src="https://silo.pgsty.com/images/contributors/pmezhuev.svg" width="48" height="48" alt="@pmezhuev" title="@pmezhuev — Reported missing RPM package signatures"></a>
|
||||
<a href="https://github.com/TLINDEN"><img src="https://silo.pgsty.com/images/contributors/TLINDEN.svg" width="48" height="48" alt="@TLINDEN" title="@TLINDEN — Reported the missing client in release tarballs"></a>
|
||||
<a href="https://github.com/makinikm"><img src="https://silo.pgsty.com/images/contributors/makinikm.svg" width="48" height="48" alt="@makinikm" title="@makinikm — Reported the missing client in the container image"></a>
|
||||
<a href="https://github.com/meesudzu"><img src="https://silo.pgsty.com/images/contributors/meesudzu.svg" width="48" height="48" alt="@meesudzu" title="@meesudzu — Requested the migration guide from upstream MinIO"></a>
|
||||
<a href="https://github.com/kuldeep-link11"><img src="https://silo.pgsty.com/images/contributors/kuldeep-link11.svg" width="48" height="48" alt="@kuldeep-link11" title="@kuldeep-link11 — Reported NATS JWT credentials and target reload issues"></a>
|
||||
<a href="https://github.com/sargarass"><img src="https://silo.pgsty.com/images/contributors/sargarass.svg" width="48" height="48" alt="@sargarass" title="@sargarass — Reported ListMultipartUploads prefix and pagination semantics"></a>
|
||||
<a href="https://github.com/liuhaodongliu990-cmyk"><img src="https://silo.pgsty.com/images/contributors/liuhaodongliu990-cmyk.svg" width="48" height="48" alt="@liuhaodongliu990-cmyk" title="@liuhaodongliu990-cmyk — Reported indeterminate progress for prefix downloads"></a>
|
||||
<a href="https://github.com/Xavier-777"><img src="https://silo.pgsty.com/images/contributors/Xavier-777.svg" width="48" height="48" alt="@Xavier-777" title="@Xavier-777 — Reported Console lifecycle management and file preview gaps"></a>
|
||||
<a href="https://github.com/spaceg00se-r"><img src="https://silo.pgsty.com/images/contributors/spaceg00se-r.svg" width="48" height="48" alt="@spaceg00se-r" title="@spaceg00se-r — Requested cpuv1 support and reported a workflow token failure"></a>
|
||||
<a href="https://github.com/kh0mka"><img src="https://silo.pgsty.com/images/contributors/kh0mka.svg" width="48" height="48" alt="@kh0mka" title="@kh0mka — Reported inter-node I/O timeouts in ReadFileStreamHandler"></a>
|
||||
<a href="https://github.com/bagutzu"><img src="https://silo.pgsty.com/images/contributors/bagutzu.svg" width="48" height="48" alt="@bagutzu" title="@bagutzu — Requested KES-compatible external KMS and OpenBao support"></a>
|
||||
<a href="https://github.com/DestroyLee"><img src="https://silo.pgsty.com/images/contributors/DestroyLee.svg" width="48" height="48" alt="@DestroyLee" title="@DestroyLee — Reported the missing documentation navigation"></a>
|
||||
<a href="https://github.com/mosesdd"><img src="https://silo.pgsty.com/images/contributors/mosesdd.svg" width="48" height="48" alt="@mosesdd" title="@mosesdd — Requested a maintained Helm chart"></a>
|
||||
<a href="https://github.com/zylpsrs"><img src="https://silo.pgsty.com/images/contributors/zylpsrs.svg" width="48" height="48" alt="@zylpsrs" title="@zylpsrs — Reported missing Console tiering and site replication"></a>
|
||||
<a href="https://github.com/heroes1412"><img src="https://silo.pgsty.com/images/contributors/heroes1412.svg" width="48" height="48" alt="@heroes1412" title="@heroes1412 — Reported the unusable profiling option"></a>
|
||||
<a href="https://github.com/redfoxfox"><img src="https://silo.pgsty.com/images/contributors/redfoxfox.svg" width="48" height="48" alt="@redfoxfox" title="@redfoxfox — Reported Chinese documentation availability"></a>
|
||||
<a href="https://github.com/jiadzh"><img src="https://silo.pgsty.com/images/contributors/jiadzh.svg" width="48" height="48" alt="@jiadzh" title="@jiadzh — Requested Windows build guidance"></a>
|
||||
<a href="https://github.com/AntonOfTheWoods"><img src="https://silo.pgsty.com/images/contributors/AntonOfTheWoods.svg" width="48" height="48" alt="@AntonOfTheWoods" title="@AntonOfTheWoods — Asked for clarity on Helm chart and operator options"></a>
|
||||
<a href="https://github.com/chalukyaj"><img src="https://silo.pgsty.com/images/contributors/chalukyaj.svg" width="48" height="48" alt="@chalukyaj" title="@chalukyaj — Proposed making the SILO Operator easier to discover"></a>
|
||||
<a href="https://github.com/nsanitate"><img src="https://silo.pgsty.com/images/contributors/nsanitate.svg" width="48" height="48" alt="@nsanitate" title="@nsanitate — Proposed CNCF Sandbox governance"></a>
|
||||
<a href="https://github.com/Kesavaambati"><img src="https://silo.pgsty.com/images/contributors/Kesavaambati.svg" width="48" height="48" alt="@Kesavaambati" title="@Kesavaambati — Asked about community support and image maintenance"></a>
|
||||
</p>
|
||||
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.
|
||||
|
||||
[View the full contribution record](CONTRIBUTORS.md) for each person's proposals, fixes, and reports.
|
||||
<a href="CONTRIBUTORS.md">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/pgsty/silo/codex/repository-cards/contributors-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/pgsty/silo/codex/repository-cards/contributors-light.svg" alt="SILO community contributors">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
[View contribution notes and actual PR status](CONTRIBUTORS.md).
|
||||
|
||||
## Star History
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/pgsty/silo/codex/repository-cards/star-history-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/pgsty/silo/codex/repository-cards/star-history-light.svg" alt="SILO GitHub star history">
|
||||
</picture>
|
||||
|
||||
## Background
|
||||
|
||||
|
||||
+17
-45
@@ -102,53 +102,25 @@ S3 API、`MINIO_*` 环境变量、`minio_*` 指标、`x-minio-*` 头、`/minio/*
|
||||
|
||||
## 贡献者
|
||||
|
||||
**41 位社区贡献者**共同建设 SILO、Console、mcli、公共包与相关项目。名单包含维护者,以及所有提出 Issue 或 PR 的真人作者;按已合并 PR、其他 PR、Issue 报告排序,黄圈标记显著贡献。
|
||||
<!-- Generated by silo.pgsty.com/bin/contributors.py. -->
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Vonng"><img src="https://silo.pgsty.com/images/contributors/Vonng.svg" width="60" height="60" alt="@Vonng" title="@Vonng — 维护 SILO、Console、mcli、公共包、发行与文档"></a>
|
||||
<a href="https://github.com/h5vx"><img src="https://silo.pgsty.com/images/contributors/h5vx.svg" width="60" height="60" alt="@h5vx" title="@h5vx — 实现单桶 CORS 配置与请求执行"></a>
|
||||
<a href="https://github.com/mrjavadseydi"><img src="https://silo.pgsty.com/images/contributors/mrjavadseydi.svg" width="60" height="60" alt="@mrjavadseydi" title="@mrjavadseydi — 修复有效桶配额指标,并提交按访问频率分层的 ILM 方案"></a>
|
||||
<a href="https://github.com/Dansyuqri"><img src="https://silo.pgsty.com/images/contributors/Dansyuqri.svg" width="60" height="60" alt="@Dansyuqri" title="@Dansyuqri — 为分片上传完成响应补充 ChecksumType"></a>
|
||||
<a href="https://github.com/ycjlin"><img src="https://silo.pgsty.com/images/contributors/ycjlin.svg" width="60" height="60" alt="@ycjlin" title="@ycjlin — 修复缺失桶的 ListObjects 语义"></a>
|
||||
<a href="https://github.com/pinginfo"><img src="https://silo.pgsty.com/images/contributors/pinginfo.svg" width="60" height="60" alt="@pinginfo" title="@pinginfo — 修复桶通知的流式输出"></a>
|
||||
<a href="https://github.com/ZouhairCharef"><img src="https://silo.pgsty.com/images/contributors/ZouhairCharef.svg" width="60" height="60" alt="@ZouhairCharef" title="@ZouhairCharef — 修复 go-jose 中的 CVE-2026-34986"></a>
|
||||
<a href="https://github.com/mfredenhagen"><img src="https://silo.pgsty.com/images/contributors/mfredenhagen.svg" width="60" height="60" alt="@mfredenhagen" title="@mfredenhagen — 修复 OpenTelemetry 中的 CVE-2026-39883"></a>
|
||||
<a href="https://github.com/waterkip"><img src="https://silo.pgsty.com/images/contributors/waterkip.svg" width="60" height="60" alt="@waterkip" title="@waterkip — 将文档链接指向 SILO 门户"></a>
|
||||
<a href="https://github.com/mikemikimike"><img src="https://silo.pgsty.com/images/contributors/mikemikimike.svg" width="60" height="60" alt="@mikemikimike" title="@mikemikimike — 提交 SSE-C 复制分片明文尺寸修复"></a>
|
||||
<a href="https://github.com/metaneutrons"><img src="https://silo.pgsty.com/images/contributors/metaneutrons.svg" width="60" height="60" alt="@metaneutrons" title="@metaneutrons — 报告并提交显式版本删除鉴权方案"></a>
|
||||
<a href="https://github.com/magicxor"><img src="https://silo.pgsty.com/images/contributors/magicxor.svg" width="60" height="60" alt="@magicxor" title="@magicxor — 报告并提交 DELETE If-Match 条件请求支持方案"></a>
|
||||
<a href="https://github.com/davinkevin"><img src="https://silo.pgsty.com/images/contributors/davinkevin.svg" width="60" height="60" alt="@davinkevin" title="@davinkevin — 提交 distroless 容器镜像与依赖自动更新方案"></a>
|
||||
<a href="https://github.com/lem21h"><img src="https://silo.pgsty.com/images/contributors/lem21h.svg" width="48" height="48" alt="@lem21h" title="@lem21h — 提交健壮性与 goroutine 改进"></a>
|
||||
<a href="https://github.com/sulin37392"><img src="https://silo.pgsty.com/images/contributors/sulin37392.svg" width="48" height="48" alt="@sulin37392" title="@sulin37392 — 提交依赖更新"></a>
|
||||
<a href="https://github.com/cbornet"><img src="https://silo.pgsty.com/images/contributors/cbornet.svg" width="60" height="60" alt="@cbornet" title="@cbornet — 报告分片与流式校验和缺陷及缺失桶语义问题"></a>
|
||||
<a href="https://github.com/vampywiz17"><img src="https://silo.pgsty.com/images/contributors/vampywiz17.svg" width="60" height="60" alt="@vampywiz17" title="@vampywiz17 — 报告 LDAP TLS 与 Console 登录回归"></a>
|
||||
<a href="https://github.com/orenyomtov"><img src="https://silo.pgsty.com/images/contributors/orenyomtov.svg" width="60" height="60" alt="@orenyomtov" title="@orenyomtov — 报告未签名头导致的 CopyObject 跨对象读取(SN-2026-011)"></a>
|
||||
<a href="https://github.com/mumu-lab"><img src="https://silo.pgsty.com/images/contributors/mumu-lab.svg" width="48" height="48" alt="@mumu-lab" title="@mumu-lab — 报告桶配额指标读取已弃用字段的问题"></a>
|
||||
<a href="https://github.com/jvasile"><img src="https://silo.pgsty.com/images/contributors/jvasile.svg" width="48" height="48" alt="@jvasile" title="@jvasile — 报告 Debian 包缺少用户、用户组与默认配置"></a>
|
||||
<a href="https://github.com/pmezhuev"><img src="https://silo.pgsty.com/images/contributors/pmezhuev.svg" width="48" height="48" alt="@pmezhuev" title="@pmezhuev — 报告 RPM 包缺少 GPG 签名"></a>
|
||||
<a href="https://github.com/TLINDEN"><img src="https://silo.pgsty.com/images/contributors/TLINDEN.svg" width="48" height="48" alt="@TLINDEN" title="@TLINDEN — 报告发布压缩包缺少客户端"></a>
|
||||
<a href="https://github.com/makinikm"><img src="https://silo.pgsty.com/images/contributors/makinikm.svg" width="48" height="48" alt="@makinikm" title="@makinikm — 报告容器镜像缺少客户端"></a>
|
||||
<a href="https://github.com/meesudzu"><img src="https://silo.pgsty.com/images/contributors/meesudzu.svg" width="48" height="48" alt="@meesudzu" title="@meesudzu — 提出从上游 MinIO 迁移的指南需求"></a>
|
||||
<a href="https://github.com/kuldeep-link11"><img src="https://silo.pgsty.com/images/contributors/kuldeep-link11.svg" width="48" height="48" alt="@kuldeep-link11" title="@kuldeep-link11 — 报告 NATS JWT 凭据与通知目标重载问题"></a>
|
||||
<a href="https://github.com/sargarass"><img src="https://silo.pgsty.com/images/contributors/sargarass.svg" width="48" height="48" alt="@sargarass" title="@sargarass — 报告 ListMultipartUploads 前缀与分页语义问题"></a>
|
||||
<a href="https://github.com/liuhaodongliu990-cmyk"><img src="https://silo.pgsty.com/images/contributors/liuhaodongliu990-cmyk.svg" width="48" height="48" alt="@liuhaodongliu990-cmyk" title="@liuhaodongliu990-cmyk — 报告前缀下载进度显示异常"></a>
|
||||
<a href="https://github.com/Xavier-777"><img src="https://silo.pgsty.com/images/contributors/Xavier-777.svg" width="48" height="48" alt="@Xavier-777" title="@Xavier-777 — 报告 Console 生命周期管理与文件预览缺失"></a>
|
||||
<a href="https://github.com/spaceg00se-r"><img src="https://silo.pgsty.com/images/contributors/spaceg00se-r.svg" width="48" height="48" alt="@spaceg00se-r" title="@spaceg00se-r — 提出 cpuv1 支持需求并报告工作流令牌错误"></a>
|
||||
<a href="https://github.com/kh0mka"><img src="https://silo.pgsty.com/images/contributors/kh0mka.svg" width="48" height="48" alt="@kh0mka" title="@kh0mka — 报告 ReadFileStreamHandler 节点间 I/O 超时"></a>
|
||||
<a href="https://github.com/bagutzu"><img src="https://silo.pgsty.com/images/contributors/bagutzu.svg" width="48" height="48" alt="@bagutzu" title="@bagutzu — 提出兼容 KES 的外部 KMS 与 OpenBao 支持需求"></a>
|
||||
<a href="https://github.com/DestroyLee"><img src="https://silo.pgsty.com/images/contributors/DestroyLee.svg" width="48" height="48" alt="@DestroyLee" title="@DestroyLee — 报告文档目录导航缺失"></a>
|
||||
<a href="https://github.com/mosesdd"><img src="https://silo.pgsty.com/images/contributors/mosesdd.svg" width="48" height="48" alt="@mosesdd" title="@mosesdd — 提出维护 Helm Chart 的需求"></a>
|
||||
<a href="https://github.com/zylpsrs"><img src="https://silo.pgsty.com/images/contributors/zylpsrs.svg" width="48" height="48" alt="@zylpsrs" title="@zylpsrs — 报告 Console 缺少分层与站点复制"></a>
|
||||
<a href="https://github.com/heroes1412"><img src="https://silo.pgsty.com/images/contributors/heroes1412.svg" width="48" height="48" alt="@heroes1412" title="@heroes1412 — 报告性能分析选项不可用"></a>
|
||||
<a href="https://github.com/redfoxfox"><img src="https://silo.pgsty.com/images/contributors/redfoxfox.svg" width="48" height="48" alt="@redfoxfox" title="@redfoxfox — 报告中文文档站点不可用"></a>
|
||||
<a href="https://github.com/jiadzh"><img src="https://silo.pgsty.com/images/contributors/jiadzh.svg" width="48" height="48" alt="@jiadzh" title="@jiadzh — 提出 Windows 构建指导需求"></a>
|
||||
<a href="https://github.com/AntonOfTheWoods"><img src="https://silo.pgsty.com/images/contributors/AntonOfTheWoods.svg" width="48" height="48" alt="@AntonOfTheWoods" title="@AntonOfTheWoods — 提出明确 Helm Chart 与 Operator 选项的需求"></a>
|
||||
<a href="https://github.com/chalukyaj"><img src="https://silo.pgsty.com/images/contributors/chalukyaj.svg" width="48" height="48" alt="@chalukyaj" title="@chalukyaj — 提出改善 SILO Operator 可发现性的建议"></a>
|
||||
<a href="https://github.com/nsanitate"><img src="https://silo.pgsty.com/images/contributors/nsanitate.svg" width="48" height="48" alt="@nsanitate" title="@nsanitate — 提出加入 CNCF Sandbox 的治理建议"></a>
|
||||
<a href="https://github.com/Kesavaambati"><img src="https://silo.pgsty.com/images/contributors/Kesavaambati.svg" width="48" height="48" alt="@Kesavaambati" title="@Kesavaambati — 提出社区支持与容器镜像维护问题"></a>
|
||||
</p>
|
||||
每位 issue 或 PR 的作者都是 SILO 社区的一员,包括尚未合并的工作。已合并的修复、被采纳的方案和有效报告优先展示,其余参考首次参与时间;金色圆环突出经过审核的显著贡献。
|
||||
|
||||
[查看完整贡献记录](CONTRIBUTORS.md),了解每位贡献者的提案、修复与问题报告。
|
||||
<a href="CONTRIBUTORS.md">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/pgsty/silo/codex/repository-cards/contributors-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/pgsty/silo/codex/repository-cards/contributors-light.svg" alt="SILO 社区贡献者">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
[查看贡献记录与实际 PR 状态](CONTRIBUTORS.md)。
|
||||
|
||||
## Star History
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/pgsty/silo/codex/repository-cards/star-history-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/pgsty/silo/codex/repository-cards/star-history-light.svg" alt="SILO GitHub 星标历史">
|
||||
</picture>
|
||||
|
||||
## 背景
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
"MINIO_API_GZIP_OBJECTS",
|
||||
"MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH",
|
||||
"MINIO_API_LIST_QUORUM",
|
||||
"MINIO_API_MULTIPART_LISTING",
|
||||
"MINIO_API_OBJECT_MAX_VERSIONS",
|
||||
"MINIO_API_ODIRECT",
|
||||
"MINIO_API_REMOTE_TRANSPORT_DEADLINE",
|
||||
@@ -766,6 +767,7 @@
|
||||
"/metrics/v3",
|
||||
"/minio/grid/",
|
||||
"/minio/grid/lock/",
|
||||
"/multipart-preflight",
|
||||
"/netperf",
|
||||
"/notification",
|
||||
"/oauth2/callback",
|
||||
@@ -934,6 +936,7 @@
|
||||
"arn:minio:kms:::this-is-disregarded",
|
||||
"arn:minio:kms:::xyz-test-key",
|
||||
"arn:minio:replication:",
|
||||
"arn:minio:replication::",
|
||||
"arn:minio:replication::8320b6d18f9032b4700f1f03b50d8d1853de8f22cab86931ee794e12f190852c:destinationbucket",
|
||||
"arn:minio:replication:::",
|
||||
"arn:minio:replication:::dest-bucket",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,163 @@
|
||||
# Copyright (c) 2026 Feng Ruohang
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""Pure, self-contained SVG rendering for SILO's README cards."""
|
||||
from datetime import date, timedelta
|
||||
from html import escape
|
||||
import math
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
themes = {
|
||||
'light': dict(bg='#ffffff', wash='#f2f7fc', edge='#d9e3ee', ink='#16222e',
|
||||
muted='#62758a', blue='#1d588c', copper='#b4762e', grid='#e5edf5',
|
||||
line='#2b6ca3', ring='#dce5ef', field='#f7f9fc', label='#3d4e61'),
|
||||
'dark': dict(bg='#101923', wash='#152738', edge='#2b3c50', ink='#e8eef6',
|
||||
muted='#93a3b8', blue='#7fb8e8', copper='#e0a35c', grid='#263749',
|
||||
line='#5da2dd', ring='#3a4e63', field='#0b1119', label='#b6c2d2'),
|
||||
}
|
||||
|
||||
def read_emblem(path):
|
||||
emblem = ET.parse(path).getroot()
|
||||
body = ''.join(ET.tostring(child, encoding='unicode') for child in emblem
|
||||
if child.tag.rsplit('}', 1)[-1] in ('defs', 'g'))
|
||||
return '\n'.join(line.rstrip() for line in body.splitlines()).strip()
|
||||
|
||||
|
||||
def txt(x, y, value, size=14, color=None, weight=400, anchor='start', mono=False, spacing=None):
|
||||
family = 'Menlo,Consolas,monospace' if mono else 'Arial,Helvetica,sans-serif'
|
||||
extra = f' letter-spacing="{spacing}"' if spacing is not None else ''
|
||||
return (f'<text x="{x}" y="{y}" font-family="{family}" font-size="{size}" '
|
||||
f'font-weight="{weight}" fill="{color}" text-anchor="{anchor}"{extra}>'
|
||||
f'{escape(str(value))}</text>')
|
||||
|
||||
|
||||
def start(height, theme, title, description, emblem_body):
|
||||
t = themes[theme]
|
||||
return [f'<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="{height}" '
|
||||
f'viewBox="0 0 1000 {height}" role="img" aria-labelledby="title desc">',
|
||||
f'<title id="title">{escape(title)}</title><desc id="desc">{escape(description)}</desc>',
|
||||
'<defs><linearGradient id="surface" x1="0" y1="1" x2="1" y2="0">'
|
||||
f'<stop offset="0" stop-color="{t["bg"]}"/>'
|
||||
f'<stop offset="1" stop-color="{t["wash"]}"/></linearGradient>'
|
||||
'<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">'
|
||||
f'<stop offset="0" stop-color="{t["blue"]}"/>'
|
||||
f'<stop offset="1" stop-color="{t["copper"]}"/></linearGradient>'
|
||||
'<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">'
|
||||
f'<stop offset="0" stop-color="{t["line"]}" stop-opacity=".22"/>'
|
||||
f'<stop offset="1" stop-color="{t["line"]}" stop-opacity=".015"/>'
|
||||
'</linearGradient></defs>',
|
||||
f'<rect x=".75" y=".75" width="998.5" height="{height-1.5}" rx="22" '
|
||||
f'fill="url(#surface)" stroke="{t["edge"]}" stroke-width="1.5"/>',
|
||||
f'<svg x="40" y="25" width="25" height="25" viewBox="230 213 570 570">{emblem_body}</svg>']
|
||||
|
||||
|
||||
def heading(parts, t, eyebrow, title, subtitle, value, value_label):
|
||||
parts.extend([
|
||||
txt(76, 43, eyebrow, 11, t['muted'], 600, mono=True, spacing=1.7),
|
||||
txt(40, 94, title, 32, t['ink'], 700),
|
||||
txt(41, 123, subtitle, 14, t['muted']),
|
||||
txt(958, 89, f'{value:,}', 45, t['ink'], 700, anchor='end'),
|
||||
txt(957, 114, value_label, 10, t['muted'], 600, anchor='end', mono=True, spacing=1.5),
|
||||
f'<path d="M40 146 H960" stroke="{t["edge"]}"/>',
|
||||
])
|
||||
|
||||
|
||||
def contributors(theme, people, snapshot, emblem_body):
|
||||
height = 206 + 76 * math.ceil(len(people) / 10)
|
||||
t = themes[theme]
|
||||
parts = start(height, theme, f'SILO community — {len(people)} contributors',
|
||||
f'The existing SILO community roll, including code, proposals and reports across related projects. '
|
||||
f'Gold rings retain the existing significant-contribution designation. Snapshot {snapshot}.', emblem_body)
|
||||
heading(parts, t, 'SILO / COMMUNITY', 'Contributors',
|
||||
'Code, proposals & reports across SILO and related projects', len(people), 'COMMUNITY CONTRIBUTORS')
|
||||
for row in range(math.ceil(len(people) / 10)):
|
||||
group = people[row * 10:(row + 1) * 10]
|
||||
row_width = len(group) * 91
|
||||
for col, person in enumerate(group):
|
||||
x = (1000 - row_width) / 2 + col * 91 + 45.5
|
||||
y = 199 + row * 76
|
||||
identifier = f'avatar-{row}-{col}'
|
||||
featured = bool(person.get('featured'))
|
||||
parts.append(f'<g><title>@{escape(person["handle"])} — {escape(person["what"])}</title>')
|
||||
parts.append(f'<defs><clipPath id="{identifier}"><circle cx="{x}" cy="{y}" r="29"/></clipPath></defs>')
|
||||
if featured:
|
||||
parts.append(f'<circle cx="{x}" cy="{y}" r="34" fill="{t["copper"]}" opacity=".09"/>')
|
||||
if person.get('avatarDataUrl'):
|
||||
parts.append(f'<image x="{x-29}" y="{y-29}" width="58" height="58" '
|
||||
f'clip-path="url(#{identifier})" href="{escape(person["avatarDataUrl"])}"/>')
|
||||
else:
|
||||
parts.append(f'<circle cx="{x}" cy="{y}" r="29" fill="{t["ring"]}"/>')
|
||||
parts.append(txt(x, y + 9, person['handle'][0].upper(), 26, t['ink'], 700, 'middle'))
|
||||
parts.append(f'<circle cx="{x}" cy="{y}" r="30.5" fill="none" '
|
||||
f'stroke="{t["copper"] if featured else t["ring"]}" stroke-width="{2 if featured else 1.25}"/></g>')
|
||||
parts.extend([
|
||||
f'<path d="M40 {height-42} H960" stroke="{t["edge"]}"/>',
|
||||
f'<circle cx="47" cy="{height-21}" r="4" fill="none" stroke="{t["copper"]}" stroke-width="1.5"/>',
|
||||
txt(61, height-17, 'Gold rings mark significant contributions', 12, t['muted']),
|
||||
txt(959, height-17, f'AS OF {snapshot}', 10, t['muted'], 500, 'end', mono=True, spacing=.6),
|
||||
'</svg>',
|
||||
])
|
||||
return ''.join(parts)
|
||||
|
||||
|
||||
def stars(theme, history, snapshot, emblem_body):
|
||||
points = history['points']
|
||||
star_count = points[-1]['stars']
|
||||
t = themes[theme]
|
||||
provenance = ('Initial history reconstructed · Daily totals since ' + history['bootstrap']['through'] + ' · UTC'
|
||||
if history['bootstrap']['reconstructed'] else 'Observed daily star totals · UTC')
|
||||
parts = start(558, theme, f'SILO star history — {star_count:,} stars',
|
||||
f'GitHub repository pgsty/silo. {star_count:,} stars as of {snapshot}. ' +
|
||||
provenance, emblem_body)
|
||||
heading(parts, t, 'SILO / GITHUB', 'Star History', 'pgsty/silo', star_count, 'GITHUB STARS')
|
||||
left, right, top, bottom = 76, 958, 177, 440
|
||||
begin = date.fromisoformat(points[0]['date'])
|
||||
end = date.fromisoformat(points[-1]['date'])
|
||||
days = max(1, (end - begin).days)
|
||||
maximum = max(500, math.ceil(max(p['stars'] for p in points) / 500) * 500)
|
||||
tick_step = 10 ** max(0, int(math.log10(maximum)))
|
||||
xy = lambda day, n: (left + (right-left)*(date.fromisoformat(day)-begin).days / days,
|
||||
bottom-(bottom-top)*n/maximum)
|
||||
for value in range(0, maximum+1, tick_step):
|
||||
y = xy(points[0]['date'], value)[1]
|
||||
parts.append(f'<path d="M{left} {y:.2f} H{right}" stroke="{t["grid"]}" stroke-dasharray="4 6"/>')
|
||||
parts.append(txt(left-16, round(y+4, 2), f'{value / 1000:g}k' if value >= 1000 else str(value), 12, t['muted'], anchor='end'))
|
||||
dates = sorted({begin + timedelta(days=round((end-begin).days*i/5)) for i in range(6)})
|
||||
ticks = [(d.isoformat(), d.strftime('%b %Y') if days > 90 else d.strftime('%b %d')) for d in dates]
|
||||
for day, label in ticks:
|
||||
x = xy(day, 0)[0]
|
||||
parts.append(f'<path d="M{x:.2f} {top} V{bottom}" stroke="{t["grid"]}" stroke-opacity=".65"/>')
|
||||
anchor = 'start' if day == points[0]['date'] else 'end' if day == snapshot else 'middle'
|
||||
parts.append(txt(round(x, 2), 466, label, 12, t['muted'], anchor=anchor))
|
||||
coords = [xy(p['date'], p['stars']) for p in points]
|
||||
line = 'M' + ' L'.join(f'{x:.2f} {y:.2f}' for x, y in coords)
|
||||
area = line + f' L{coords[-1][0]:.2f} {bottom} L{left} {bottom} Z'
|
||||
parts.extend([
|
||||
f'<path d="{area}" fill="url(#area)"/>',
|
||||
f'<path d="{line}" fill="none" stroke="url(#accent)" stroke-width="3" '
|
||||
'stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
f'<path d="M{left} {bottom} H{right}" stroke="{t["edge"]}"/>',
|
||||
])
|
||||
x, y = coords[-1]
|
||||
parts.extend([
|
||||
f'<circle cx="{x}" cy="{y}" r="10" fill="{t["copper"]}" opacity=".12"/>',
|
||||
f'<circle cx="{x}" cy="{y}" r="5" fill="{t["copper"]}" stroke="{t["bg"]}" stroke-width="2"/>',
|
||||
f'<path d="M40 491 H960" stroke="{t["edge"]}"/>',
|
||||
txt(40, 516, f'{begin:%b %Y} — {end:%b %Y}'.upper(), 10, t['muted'], 500, mono=True, spacing=.7),
|
||||
txt(959, 516, f'SNAPSHOT {snapshot}', 10, t['muted'], 500, 'end', mono=True, spacing=.6),
|
||||
txt(40, 539, provenance, 11, t['muted']),
|
||||
'</svg>',
|
||||
])
|
||||
return ''.join(parts)
|
||||
@@ -0,0 +1 @@
|
||||
PyYAML==6.0.3
|
||||
@@ -0,0 +1,178 @@
|
||||
# Copyright (c) 2026 Feng Ruohang
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""Regression checks for historical accuracy, contributor scope and SVG safety."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from urllib.error import URLError
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import render
|
||||
import update
|
||||
|
||||
NS = {'s': 'http://www.w3.org/2000/svg'}
|
||||
PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a7mgAAAAASUVORK5CYII=')
|
||||
|
||||
|
||||
def person(handle='Alice', group='reports', featured=False):
|
||||
return {'handle': handle, 'group': group, 'featured': featured,
|
||||
'what': 'A reviewed contribution', 'firstContribution': '2026-09-01'}
|
||||
|
||||
|
||||
def history():
|
||||
return {'repository': 'pgsty/silo',
|
||||
'bootstrap': {'through': '2026-09-15', 'reconstructed': True},
|
||||
'points': [{'date': '2026-09-14', 'stars': 100}, {'date': '2026-09-15', 'stars': 105}]}
|
||||
|
||||
|
||||
class HistoryTests(unittest.TestCase):
|
||||
def test_new_day_preserves_old_counts_and_unstars(self):
|
||||
before = history()
|
||||
after = update.update_history(before, '2026-09-16', 103)
|
||||
self.assertEqual(after['points'][:-1], before['points'])
|
||||
self.assertEqual(after['points'][-1], {'date': '2026-09-16', 'stars': 103})
|
||||
self.assertEqual(before, history())
|
||||
|
||||
def test_same_day_rerun_replaces_instead_of_appending(self):
|
||||
first = update.update_history(history(), '2026-09-15', 107)
|
||||
self.assertEqual(len(first['points']), 2)
|
||||
self.assertEqual(first, update.update_history(first, '2026-09-15', 107))
|
||||
|
||||
def test_missing_days_are_not_invented(self):
|
||||
result = update.update_history(history(), '2026-09-18', 106)
|
||||
self.assertEqual([p['date'] for p in result['points']], ['2026-09-14', '2026-09-15', '2026-09-18'])
|
||||
|
||||
def test_rejects_wrong_repository_and_corrupt_history(self):
|
||||
cases = []
|
||||
wrong = history(); wrong['repository'] = 'someone/else'; cases.append(wrong)
|
||||
duplicate = history(); duplicate['points'].append(duplicate['points'][-1]); cases.append(duplicate)
|
||||
unordered = history(); unordered['points'].reverse(); cases.append(unordered)
|
||||
negative = history(); negative['points'][0]['stars'] = -1; cases.append(negative)
|
||||
future = history(); future['points'][-1]['date'] = '2026-09-20'; cases.append(future)
|
||||
for case in cases:
|
||||
with self.subTest(case=case), self.assertRaises(ValueError):
|
||||
update.update_history(case, '2026-09-16', 100)
|
||||
|
||||
def test_first_run_has_no_fabricated_history(self):
|
||||
result = update.update_history(None, '2026-09-16', 10)
|
||||
self.assertFalse(result['bootstrap']['reconstructed'])
|
||||
self.assertEqual(result['points'], [{'date': '2026-09-16', 'stars': 10}])
|
||||
|
||||
|
||||
class ContributorTests(unittest.TestCase):
|
||||
def test_retries_truncated_json_before_using_it(self):
|
||||
with patch('update.request', side_effect=[b'{"partial":', b'{"ok":true}']), patch('update.time.sleep'):
|
||||
self.assertEqual(update.GitHub('').get('repos/pgsty/silo'), {'ok': True})
|
||||
|
||||
def test_paginates_past_one_full_page(self):
|
||||
class API(update.GitHub):
|
||||
def __init__(self): self.calls = []
|
||||
def get(self, path):
|
||||
self.calls.append(path)
|
||||
return list(range(100)) if 'page=1&' in path else [100]
|
||||
api = API()
|
||||
self.assertEqual(len(list(api.issues('pgsty/silo'))), 101)
|
||||
self.assertIn('state=all', api.calls[0])
|
||||
self.assertIn('page=2&', api.calls[1])
|
||||
|
||||
def test_bots_deduplication_unmerged_work_and_reviewed_credit(self):
|
||||
def issue(login, kind='issue', user_type='User'):
|
||||
item = {'user': {'login': login, 'type': user_type, 'avatar_url': ''}, 'created_at': '2026-09-02T00:00:00Z'}
|
||||
if kind != 'issue': item['pull_request'] = {'merged_at': None if kind == 'open' else '2026-09-03T00:00:00Z'}
|
||||
return item
|
||||
class API:
|
||||
def issues(self, _repo):
|
||||
return [issue('alice'), issue('Bob', 'open'), issue('Bob', 'merged'),
|
||||
issue('Carol', 'open'), issue('Copilot'), issue('robot', user_type='Bot')]
|
||||
curated = {'repositories': ['pgsty/silo', 'pgsty/mc'], 'bots': ['Copilot'],
|
||||
'people': [person('Alice', featured=True), person('Reporter'), person('Copilot')]}
|
||||
result = update.collect_people(API(), curated)
|
||||
self.assertEqual({p['handle'] for p in result}, {'Alice', 'Bob', 'Carol', 'Reporter'})
|
||||
self.assertEqual(result[0]['handle'], 'Bob')
|
||||
self.assertEqual(result[1]['handle'], 'Carol')
|
||||
self.assertTrue(next(p for p in result if p['handle'] == 'Alice')['featured'])
|
||||
self.assertEqual(next(p for p in result if p['handle'] == 'Bob')['group'], 'code')
|
||||
self.assertFalse(next(p for p in result if p['handle'] == 'Carol')['featured'])
|
||||
|
||||
def test_newer_reviewed_preview_survives_until_site_catches_up(self):
|
||||
remote = {'updated': '2026-09-16T03:00:00+00:00'}
|
||||
cached = {'updated': '2026-09-16T04:00:00+00:00'}
|
||||
self.assertIs(update.select_curated(remote, cached), cached)
|
||||
newer = {'updated': '2026-09-17T03:00:00+00:00'}
|
||||
self.assertIs(update.select_curated(newer, cached), newer)
|
||||
|
||||
def test_avatar_failure_reuses_raster_cache(self):
|
||||
previous = {**person(), 'avatarDataUrl': update.raster_data_url(PNG)}
|
||||
with patch('update.request', side_effect=URLError('unavailable')):
|
||||
result = update.add_avatars(None, [{**person(), 'avatarUrl': 'https://avatars.githubusercontent.com/u/1'}], [previous])
|
||||
self.assertEqual(result[0]['avatarDataUrl'], previous['avatarDataUrl'])
|
||||
with self.assertRaises(ValueError): update.raster_data_url(b'<svg onload="bad()"/>')
|
||||
with self.assertRaises(ValueError): update.cached_avatar({'avatarDataUrl': 'data:image/svg+xml;base64,PHN2Zy8+'})
|
||||
|
||||
def test_fetch_failure_leaves_published_assets_untouched(self):
|
||||
class API:
|
||||
def get(self, path):
|
||||
if path == 'repos/pgsty/silo': return {'full_name': 'pgsty/silo', 'stargazers_count': 106}
|
||||
raise URLError('roster unavailable')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
out = Path(directory)
|
||||
original = json.dumps(history())
|
||||
(out / 'history.json').write_text(original)
|
||||
(out / 'contributors-light.svg').write_text('previous image')
|
||||
with self.assertRaises(URLError): update.refresh(out, API(), Path('.'))
|
||||
self.assertEqual((out / 'history.json').read_text(), original)
|
||||
self.assertEqual((out / 'contributors-light.svg').read_text(), 'previous image')
|
||||
|
||||
|
||||
class RenderTests(unittest.TestCase):
|
||||
def test_real_emblem_generates_clean_xml(self):
|
||||
emblem = render.read_emblem(Path(__file__).resolve().parents[2] / '.github/silo.svg')
|
||||
svg = render.contributors('light', [person()], '2026-09-16', emblem)
|
||||
ET.fromstring(svg)
|
||||
self.assertTrue(all(line == line.rstrip() for line in svg.splitlines()))
|
||||
|
||||
def test_all_avatars_fit_when_the_roster_grows(self):
|
||||
people = [{**person(f'person-{i}'), 'avatarDataUrl': update.raster_data_url(PNG)} for i in range(151)]
|
||||
for theme in ('light', 'dark'):
|
||||
root = ET.fromstring(render.contributors(theme, people, '2026-09-16', ''))
|
||||
images = root.findall('.//s:image', NS)
|
||||
self.assertEqual(len(images), 151)
|
||||
footer = float(root.attrib['height']) - 42
|
||||
self.assertTrue(all(float(i.attrib['y']) + float(i.attrib['height']) < footer for i in images))
|
||||
self.assertTrue(all(i.attrib['href'].startswith('data:image/png;base64,') for i in images))
|
||||
|
||||
def test_untrusted_text_is_escaped(self):
|
||||
data = [{**person(), 'what': '<script>alert("x")</script> & contributions'}]
|
||||
svg = render.contributors('light', data, '2026-09-16', '')
|
||||
root = ET.fromstring(svg)
|
||||
self.assertEqual(root.findall('.//s:script', NS), [])
|
||||
self.assertIn('<script>', svg)
|
||||
|
||||
def test_single_point_and_decreasing_star_history_render(self):
|
||||
for data in (update.update_history(None, '2026-09-16', 0), update.update_history(history(), '2026-09-16', 90)):
|
||||
for theme in ('light', 'dark'):
|
||||
svg = render.stars(theme, data, '2026-09-16', '')
|
||||
ET.fromstring(svg)
|
||||
self.assertNotIn('nan', svg.lower())
|
||||
self.assertNotIn('inf', svg.lower())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Feng Ruohang
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""Refresh the generated-asset checkout; publishing is handled by the workflow."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, datetime, timezone
|
||||
import json
|
||||
from http.client import IncompleteRead
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import yaml
|
||||
|
||||
import render
|
||||
|
||||
REPOSITORY = 'pgsty/silo'
|
||||
SOURCE = 'repos/pgsty/silo.pgsty.com/contents/data/home/contributors.yaml?ref=main'
|
||||
GROUPS = ('code', 'proposed', 'reports')
|
||||
HANDLE = re.compile(r'[A-Za-z0-9][A-Za-z0-9-]{0,38}\Z')
|
||||
|
||||
|
||||
def request(url, token='', limit=8 * 1024 * 1024):
|
||||
headers = {'User-Agent': 'silo-repository-cards', 'Accept': 'application/vnd.github+json'}
|
||||
if urlparse(url).netloc == 'api.github.com':
|
||||
headers['X-GitHub-Api-Version'] = '2022-11-28'
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with urlopen(Request(url, headers=headers), timeout=25) as response:
|
||||
data = response.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
raise ValueError('Response exceeds the size limit')
|
||||
expected = response.headers.get('Content-Length')
|
||||
if expected is not None and len(data) != int(expected):
|
||||
raise URLError('Incomplete response body')
|
||||
return data
|
||||
except HTTPError as exc:
|
||||
if exc.code < 500 or attempt == 2:
|
||||
raise
|
||||
except (URLError, TimeoutError, IncompleteRead):
|
||||
if attempt == 2:
|
||||
raise
|
||||
time.sleep(attempt + 1)
|
||||
|
||||
|
||||
class GitHub:
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
|
||||
def get(self, path):
|
||||
for attempt in range(3):
|
||||
try:
|
||||
return json.loads(request('https://api.github.com/' + path, self.token))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
if attempt == 2:
|
||||
raise ValueError(f'Incomplete or invalid GitHub JSON: {path}') from exc
|
||||
time.sleep(attempt + 1)
|
||||
|
||||
def issues(self, repository):
|
||||
page = 1
|
||||
while True:
|
||||
batch = self.get(f'repos/{repository}/issues?state=all&per_page=100&page={page}&sort=created&direction=asc')
|
||||
if not isinstance(batch, list):
|
||||
raise ValueError(f'Invalid issues response for {repository}')
|
||||
yield from batch
|
||||
if len(batch) < 100:
|
||||
return
|
||||
page += 1
|
||||
|
||||
|
||||
def curated_snapshot(data, revision):
|
||||
updated = str(data['updated'])
|
||||
datetime.fromisoformat(updated)
|
||||
repositories = [item['repo'] for item in data['repositories']]
|
||||
if not repositories or any(not re.fullmatch(r'pgsty/[A-Za-z0-9_.-]+', repo) for repo in repositories):
|
||||
raise ValueError('Invalid contributor repository scope')
|
||||
people = []
|
||||
for group in GROUPS:
|
||||
for entry in data[group]:
|
||||
if not HANDLE.fullmatch(entry['handle']):
|
||||
raise ValueError('Invalid GitHub contributor handle')
|
||||
people.append({
|
||||
'handle': entry['handle'], 'group': group,
|
||||
'featured': bool(entry.get('featured')), 'what': entry['what'],
|
||||
'firstContribution': str(entry.get('firstContribution', '9999-12-31')),
|
||||
})
|
||||
if not people or len({p['handle'].lower() for p in people}) != len(people):
|
||||
raise ValueError('Empty or duplicate contributor roster')
|
||||
return {'updated': updated, 'revision': revision, 'repositories': repositories,
|
||||
'bots': data.get('bots', ['Copilot', 'dependabot[bot]']), 'people': people}
|
||||
|
||||
|
||||
def select_curated(remote, cached):
|
||||
# The initial, approved preview can contain reviewed credit not published by
|
||||
# the companion site yet. Keep that newer snapshot until the site catches up.
|
||||
if cached and datetime.fromisoformat(cached['updated']) > datetime.fromisoformat(remote['updated']):
|
||||
return cached
|
||||
return remote
|
||||
|
||||
|
||||
def collect_people(api, curated):
|
||||
bots = {name.lower() for name in curated['bots']}
|
||||
people = {p['handle'].lower(): dict(p) for p in curated['people']
|
||||
if p['handle'].lower() not in bots and not p['handle'].lower().endswith('[bot]')}
|
||||
order = {p['handle'].lower(): index for index, p in enumerate(curated['people'])}
|
||||
for repository in curated['repositories']:
|
||||
print(f'Reading issue and PR authors: {repository}', flush=True)
|
||||
for issue in api.issues(repository):
|
||||
user = issue.get('user') or {}
|
||||
handle = user.get('login', '')
|
||||
key = handle.lower()
|
||||
if user.get('type') != 'User' or key in bots or key.endswith('[bot]'):
|
||||
continue
|
||||
if not HANDLE.fullmatch(handle):
|
||||
raise ValueError('Invalid issue author')
|
||||
pr = issue.get('pull_request')
|
||||
group = 'code' if pr and pr.get('merged_at') else 'proposed' if pr else 'reports'
|
||||
first = issue['created_at'][:10]
|
||||
date.fromisoformat(first)
|
||||
person = people.setdefault(key, {
|
||||
'handle': handle, 'group': group, 'featured': False,
|
||||
'what': 'Contributed an issue or pull request to SILO and related projects',
|
||||
'firstContribution': first,
|
||||
})
|
||||
person['avatarUrl'] = user.get('avatar_url', '')
|
||||
person['firstContribution'] = min(person['firstContribution'], first)
|
||||
if GROUPS.index(group) < GROUPS.index(person['group']):
|
||||
person['group'] = group
|
||||
if not people:
|
||||
raise ValueError('No human contributors were collected')
|
||||
return sorted(people.values(), key=lambda p: (
|
||||
GROUPS.index(p['group']), not p['featured'],
|
||||
order.get(p['handle'].lower(), len(order)), p['firstContribution'], p['handle'].lower()))
|
||||
|
||||
|
||||
def raster_data_url(data):
|
||||
if data.startswith(b'\x89PNG\r\n\x1a\n'):
|
||||
mime = 'image/png'
|
||||
elif data.startswith(b'\xff\xd8\xff'):
|
||||
mime = 'image/jpeg'
|
||||
elif data.startswith((b'GIF87a', b'GIF89a')):
|
||||
mime = 'image/gif'
|
||||
elif data[:4] == b'RIFF' and data[8:12] == b'WEBP':
|
||||
mime = 'image/webp'
|
||||
else:
|
||||
raise ValueError('Avatar is not a raster image')
|
||||
return f'data:{mime};base64,' + base64.b64encode(data).decode('ascii')
|
||||
|
||||
|
||||
def cached_avatar(person):
|
||||
value = person.get('avatarDataUrl', '')
|
||||
if not value:
|
||||
return ''
|
||||
prefix, encoded = value.split(',', 1)
|
||||
if prefix not in ('data:image/png;base64', 'data:image/jpeg;base64', 'data:image/gif;base64', 'data:image/webp;base64'):
|
||||
raise ValueError('Invalid cached avatar format')
|
||||
raw = base64.b64decode(encoded, validate=True)
|
||||
if len(raw) > 512 * 1024 or raster_data_url(raw) != value:
|
||||
raise ValueError('Invalid cached avatar')
|
||||
return value
|
||||
|
||||
|
||||
def add_avatars(api, people, previous):
|
||||
cached = {p['handle'].lower(): cached_avatar(p) for p in previous}
|
||||
|
||||
def update(person):
|
||||
person = dict(person)
|
||||
try:
|
||||
url = person.pop('avatarUrl', '') or api.get('users/' + person['handle'])['avatar_url']
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != 'https' or parsed.netloc != 'avatars.githubusercontent.com':
|
||||
raise ValueError('Unexpected avatar host')
|
||||
data = request(url + ('&' if '?' in url else '?') + 's=96', limit=512 * 1024)
|
||||
person['avatarDataUrl'] = raster_data_url(data)
|
||||
except (HTTPError, URLError, TimeoutError, IncompleteRead, ValueError, KeyError) as exc:
|
||||
person.pop('avatarUrl', None)
|
||||
person['avatarDataUrl'] = cached.get(person['handle'].lower(), '')
|
||||
print(f'Avatar fallback for @{person["handle"]}: {type(exc).__name__}', file=sys.stderr)
|
||||
return person
|
||||
|
||||
with ThreadPoolExecutor(max_workers=6) as pool:
|
||||
return list(pool.map(update, people))
|
||||
|
||||
|
||||
def update_history(history, day, stars):
|
||||
date.fromisoformat(day)
|
||||
if type(stars) is not int or stars < 0:
|
||||
raise ValueError('Invalid repository star count')
|
||||
if history is None:
|
||||
history = {'repository': REPOSITORY, 'bootstrap': {'through': day, 'reconstructed': False}, 'points': []}
|
||||
if history['repository'] != REPOSITORY:
|
||||
raise ValueError('Star history belongs to a different repository')
|
||||
date.fromisoformat(history['bootstrap']['through'])
|
||||
dates = []
|
||||
for point in history['points']:
|
||||
date.fromisoformat(point['date'])
|
||||
if type(point['stars']) is not int or point['stars'] < 0:
|
||||
raise ValueError('Invalid historical star count')
|
||||
dates.append(point['date'])
|
||||
if dates != sorted(set(dates)) or any(d > day for d in dates):
|
||||
raise ValueError('History contains duplicate, unordered, or future dates')
|
||||
# Replace today's observation, preserve previous days, and allow unstars.
|
||||
points = [dict(p) for p in history['points'] if p['date'] != day]
|
||||
points.append({'date': day, 'stars': stars})
|
||||
return {**history, 'points': points}
|
||||
|
||||
|
||||
def read_json(path, default=None):
|
||||
return json.loads(path.read_text()) if path.exists() else default
|
||||
|
||||
|
||||
def refresh(output, api, source_root):
|
||||
day = datetime.now(timezone.utc).date().isoformat()
|
||||
metadata = api.get('repos/' + REPOSITORY)
|
||||
if metadata['full_name'].lower() != REPOSITORY:
|
||||
raise ValueError('Unexpected repository metadata')
|
||||
history = update_history(read_json(output / 'history.json'), day, metadata['stargazers_count'])
|
||||
source = api.get(SOURCE)
|
||||
reviewed = yaml.safe_load(base64.b64decode(source['content'], validate=False))
|
||||
curated = select_curated(curated_snapshot(reviewed, source['sha']), read_json(output / 'curated.json'))
|
||||
people = collect_people(api, curated)
|
||||
previous = read_json(output / 'contributors.json', {}).get('people', [])
|
||||
people = add_avatars(api, people, previous)
|
||||
emblem = render.read_emblem(source_root / '.github/silo.svg')
|
||||
payloads = {}
|
||||
for theme in ('light', 'dark'):
|
||||
payloads[f'contributors-{theme}.svg'] = render.contributors(theme, people, day, emblem) + '\n'
|
||||
payloads[f'star-history-{theme}.svg'] = render.stars(theme, history, day, emblem) + '\n'
|
||||
for svg in payloads.values():
|
||||
ET.fromstring(svg)
|
||||
for name, data in {
|
||||
'history.json': history,
|
||||
'curated.json': curated,
|
||||
'contributors.json': {'repository': REPOSITORY, 'updated': day, 'people': people},
|
||||
}.items():
|
||||
payloads[name] = json.dumps(data, indent=2, ensure_ascii=False) + '\n'
|
||||
payloads['README.md'] = f'''# SILO repository cards
|
||||
|
||||
Generated by [Repository Cards](https://github.com/pgsty/silo/actions/workflows/repository-cards.yml)
|
||||
at 00:00 UTC daily (08:00 Asia/Shanghai). GitHub may queue scheduled runs.
|
||||
|
||||
Snapshot: {day}. {metadata['stargazers_count']:,} stars; {len(people)} community contributors.
|
||||
|
||||
- `contributors-light.svg` / `contributors-dark.svg`: human issue and PR authors across the SILO project scope, plus reviewed acknowledgements. Bots are excluded. Gold rings follow the reviewed companion-site roster; new authors are collected automatically.
|
||||
- `star-history-light.svg` / `star-history-dark.svg`: initial history reconstructed from the then-current stargazers; later points are daily observed totals, including decreases. Missing days are not fabricated.
|
||||
- `curated.json`: a cache of reviewed contributor credit from `pgsty/silo.pgsty.com/data/home/contributors.yaml`. The approved initial preview may be newer than the published site; a newer reviewed snapshot is retained until the site catches up.
|
||||
- `contributors.json`: generated contributor data and embedded raster avatars. Failed avatar refreshes use the previous image, or an initial when no image is available.
|
||||
- `history.json`: persistent daily totals. Keep this file when regenerating images.
|
||||
|
||||
The SVGs are self-contained. Source and instructions live on the default branch;
|
||||
this branch contains generated assets only. Do not merge it into `main`.
|
||||
'''
|
||||
# Collect and validate everything before touching the publication checkout.
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
for filename, text in payloads.items():
|
||||
(output / filename).write_text(text)
|
||||
print(f'{day}: {len(people)} contributors; {metadata["stargazers_count"]:,} stars; {len(history["points"])} history points')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
configured = os.environ.get('GITHUB_REPOSITORY', REPOSITORY)
|
||||
if configured.lower() != REPOSITORY:
|
||||
raise SystemExit('This workflow is scoped to pgsty/silo')
|
||||
refresh(args.output, GitHub(os.environ.get('GH_TOKEN', '')), Path(__file__).resolve().parents[2])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -163,6 +163,7 @@ func registerAdminRouter(router *mux.Router, enableConfigOps bool) {
|
||||
|
||||
// StorageInfo operations
|
||||
adminRouter.Methods(http.MethodGet).Path(adminVersion + "/storageinfo").HandlerFunc(adminMiddleware(adminAPI.StorageInfoHandler, traceAllFlag))
|
||||
adminRouter.Methods(http.MethodGet).Path(adminVersion + "/multipart-preflight").HandlerFunc(adminMiddleware(adminAPI.MultipartPreflightHandler, traceAllFlag))
|
||||
// DataUsageInfo operations
|
||||
adminRouter.Methods(http.MethodGet).Path(adminVersion + "/datausageinfo").HandlerFunc(adminMiddleware(adminAPI.DataUsageInfoHandler, traceAllFlag))
|
||||
// Metrics operation
|
||||
|
||||
@@ -450,6 +450,9 @@ const (
|
||||
ErrAdminNoSecretKey
|
||||
|
||||
ErrIAMNotInitialized
|
||||
ErrMultipartListingLegacy
|
||||
ErrMultipartListingIdentity
|
||||
ErrSlowDown
|
||||
|
||||
apiErrCodeEnd // This is used only for the testing code
|
||||
)
|
||||
@@ -1336,6 +1339,21 @@ var errorCodes = errorCodeMap{
|
||||
Description: "IAM sub-system not initialized yet, please try again.",
|
||||
HTTPStatusCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
ErrMultipartListingLegacy: {
|
||||
Code: "MultipartListingNotReady",
|
||||
Description: "Legacy multipart uploads prevent a complete listing. Upgrade all writers, drain old uploads and run the multipart preflight check.",
|
||||
HTTPStatusCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
ErrMultipartListingIdentity: {
|
||||
Code: "MultipartListingMetadataInvalid",
|
||||
Description: "Multipart upload metadata is inconsistent. Run the multipart preflight check to locate the affected storage set.",
|
||||
HTTPStatusCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
ErrSlowDown: {
|
||||
Code: "SlowDown",
|
||||
Description: "Please reduce your request rate",
|
||||
HTTPStatusCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
ErrBucketMetadataNotInitialized: {
|
||||
Code: "XMinioBucketMetadataNotInitialized",
|
||||
Description: "Bucket metadata not initialized yet, please try again.",
|
||||
@@ -2173,6 +2191,10 @@ func toAPIErrorCode(ctx context.Context, err error) (apiErr APIErrorCode) {
|
||||
err = unwrapAll(err)
|
||||
|
||||
switch err {
|
||||
case errMultipartListingLegacy:
|
||||
apiErr = ErrMultipartListingLegacy
|
||||
case errMultipartListingIdentity:
|
||||
apiErr = ErrMultipartListingIdentity
|
||||
case errCompleteMultipartChecksumMismatch, errCompleteMultipartChecksumTypeMismatch:
|
||||
apiErr = ErrBadDigest
|
||||
case errMissingPartChecksum:
|
||||
@@ -2303,6 +2325,8 @@ func toAPIErrorCode(ctx context.Context, err error) (apiErr APIErrorCode) {
|
||||
}
|
||||
|
||||
switch err.(type) {
|
||||
case SlowDown:
|
||||
apiErr = ErrSlowDown
|
||||
case StorageFull:
|
||||
apiErr = ErrStorageFull
|
||||
case hash.BadDigest:
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ import (
|
||||
const (
|
||||
maxObjectList = 1000 // Limit number of objects in a listObjectsResponse/listObjectsVersionsResponse.
|
||||
maxDeleteList = 1000 // Limit number of objects deleted in a delete call.
|
||||
maxUploadsList = 10000 // Limit number of uploads in a listUploadsResponse.
|
||||
maxUploadsList = 1000 // Limit number of uploads in a listUploadsResponse.
|
||||
maxPartsList = 10000 // Limit number of parts in a listPartsResponse.
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -277,14 +277,6 @@ func (api objectAPIHandlers) ListMultipartUploadsHandler(w http.ResponseWriter,
|
||||
return
|
||||
}
|
||||
|
||||
if keyMarker != "" {
|
||||
// Marker not common with prefix is not implemented.
|
||||
if !HasPrefix(keyMarker, prefix) {
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrNotImplemented), r.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
listMultipartsInfo, err := objectAPI.ListMultipartUploads(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
||||
if err != nil {
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
|
||||
@@ -425,7 +425,7 @@ func testListMultipartUploadsHandler(obj ObjectLayer, instanceType, bucketName s
|
||||
shouldPass: true,
|
||||
},
|
||||
// Test case - 4.
|
||||
// Setting Invalid prefix and marker combination.
|
||||
// A key marker outside the prefix is valid and produces an empty page.
|
||||
{
|
||||
bucket: bucketName,
|
||||
prefix: "asia",
|
||||
@@ -435,8 +435,8 @@ func testListMultipartUploadsHandler(obj ObjectLayer, instanceType, bucketName s
|
||||
maxUploads: "0",
|
||||
accessKey: credentials.AccessKey,
|
||||
secretKey: credentials.SecretKey,
|
||||
expectedRespStatus: http.StatusNotImplemented,
|
||||
shouldPass: false,
|
||||
expectedRespStatus: http.StatusOK,
|
||||
shouldPass: true,
|
||||
},
|
||||
// Test case - 5.
|
||||
// Invalid upload id and marker combination.
|
||||
|
||||
@@ -418,6 +418,9 @@ func getReplicationState(rinfos replicatedInfos, prevState ReplicationState, vID
|
||||
|
||||
for _, rinfo := range rinfos.Targets {
|
||||
if rinfo.ResyncTimestamp != "" {
|
||||
if rs.ResetStatusesMap == nil {
|
||||
rs.ResetStatusesMap = make(map[string]string)
|
||||
}
|
||||
rs.ResetStatusesMap[targetResetHeader(rinfo.Arn)] = rinfo.ResyncTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
+111
-63
@@ -425,6 +425,7 @@ func checkReplicateDelete(ctx context.Context, bucket string, dobj ObjectToDelet
|
||||
// the mere presence or absence of the target version.
|
||||
func replicateDelete(ctx context.Context, dobj DeletedObjectReplicationInfo, objectAPI ObjectLayer) replicatedInfos {
|
||||
var replicationStatus replication.StatusType
|
||||
isPurge := dobj.isVersionPurge()
|
||||
bucket := dobj.Bucket
|
||||
versionID := dobj.DeleteMarkerVersionID
|
||||
if versionID == "" {
|
||||
@@ -484,6 +485,7 @@ func replicateDelete(ctx context.Context, dobj DeletedObjectReplicationInfo, obj
|
||||
lk := objectAPI.NewNSLock(bucket, "/[replicate]/"+dobj.ObjectName)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
dobj.RetryCount++
|
||||
globalReplicationPool.Get().queueMRFSave(dobj.ToMRFEntry())
|
||||
sendEvent(eventArgs{
|
||||
BucketName: bucket,
|
||||
@@ -546,25 +548,38 @@ func replicateDelete(ctx context.Context, dobj DeletedObjectReplicationInfo, obj
|
||||
replicationStatus = rinfos.ReplicationStatus()
|
||||
prevStatus := dobj.DeleteMarkerReplicationStatus()
|
||||
|
||||
if dobj.VersionID != "" {
|
||||
prevStatus = replication.StatusType(dobj.VersionPurgeStatus())
|
||||
replicationStatus = replication.StatusType(rinfos.VersionPurgeStatus())
|
||||
if isPurge {
|
||||
prevStatus = purgeReplicationStatus(dobj.VersionPurgeStatus())
|
||||
replicationStatus = purgeReplicationStatus(rinfos.VersionPurgeStatus())
|
||||
}
|
||||
|
||||
// to decrement pending count later.
|
||||
for _, rinfo := range rinfos.Targets {
|
||||
if rinfo.ReplicationStatus != rinfo.PrevReplicationStatus {
|
||||
globalReplicationStats.Load().Update(dobj.Bucket, rinfo, replicationStatus,
|
||||
prevStatus)
|
||||
status, previous := rinfo.ReplicationStatus, rinfo.PrevReplicationStatus
|
||||
if isPurge {
|
||||
status = purgeReplicationStatus(rinfo.VersionPurgeStatus)
|
||||
previous = purgeReplicationStatus(dobj.ReplicationState.PurgeTargets[rinfo.Arn])
|
||||
}
|
||||
if status != previous {
|
||||
globalReplicationStats.Load().Update(dobj.Bucket, rinfo, status, previous)
|
||||
}
|
||||
}
|
||||
|
||||
eventName := event.ObjectReplicationComplete
|
||||
if replicationStatus == replication.Failed {
|
||||
eventName = event.ObjectReplicationFailed
|
||||
dobj.RetryCount++
|
||||
globalReplicationPool.Get().queueMRFSave(dobj.ToMRFEntry())
|
||||
}
|
||||
drs := getReplicationState(rinfos, dobj.ReplicationState, dobj.VersionID)
|
||||
if isPurge {
|
||||
// A purge must not rewrite the marker's creation/replica metadata.
|
||||
// Multiple serialized empty target statuses can parse as nonempty,
|
||||
// so explicitly send an empty creation update to the metadata writer.
|
||||
drs.ReplicationStatusInternal = ""
|
||||
drs.Targets = nil
|
||||
drs.ReplicaStatus = ""
|
||||
}
|
||||
if replicationStatus != prevStatus {
|
||||
drs.ReplicationTimeStamp = UTCNow()
|
||||
}
|
||||
@@ -606,6 +621,7 @@ func replicateDelete(ctx context.Context, dobj DeletedObjectReplicationInfo, obj
|
||||
}
|
||||
|
||||
func replicateDeleteToTarget(ctx context.Context, dobj DeletedObjectReplicationInfo, tgt *TargetClient) (rinfo replicatedTargetInfo) {
|
||||
isPurge := dobj.isVersionPurge()
|
||||
versionID := dobj.DeleteMarkerVersionID
|
||||
if versionID == "" {
|
||||
versionID = dobj.VersionID
|
||||
@@ -615,42 +631,50 @@ func replicateDeleteToTarget(ctx context.Context, dobj DeletedObjectReplicationI
|
||||
rinfo.OpType = dobj.OpType
|
||||
rinfo.endpoint = tgt.EndpointURL().Host
|
||||
rinfo.secure = tgt.EndpointURL().Scheme == "https"
|
||||
// A purge leaves ReplicationStatus empty: the metadata writer interprets
|
||||
// that as preserving the entire creation-status block, including targets
|
||||
// outside this fan-out. Only VersionPurgeStatus records the purge outcome.
|
||||
defer func() {
|
||||
if rinfo.ReplicationStatus == replication.Completed && tgt.ResetID != "" && dobj.OpType == replication.ExistingObjectReplicationType {
|
||||
completed := rinfo.ReplicationStatus == replication.Completed
|
||||
if isPurge {
|
||||
completed = rinfo.VersionPurgeStatus == replication.VersionPurgeComplete
|
||||
}
|
||||
if completed && tgt.ResetID != "" && dobj.OpType == replication.ExistingObjectReplicationType {
|
||||
rinfo.ResyncTimestamp = fmt.Sprintf("%s;%s", UTCNow().Format(http.TimeFormat), tgt.ResetID)
|
||||
}
|
||||
}()
|
||||
|
||||
if dobj.VersionID == "" && rinfo.PrevReplicationStatus == replication.Completed && dobj.OpType != replication.ExistingObjectReplicationType {
|
||||
if !isPurge && rinfo.PrevReplicationStatus == replication.Completed && dobj.OpType != replication.ExistingObjectReplicationType {
|
||||
rinfo.ReplicationStatus = rinfo.PrevReplicationStatus
|
||||
return rinfo
|
||||
}
|
||||
if dobj.VersionID != "" && rinfo.VersionPurgeStatus == replication.VersionPurgeComplete {
|
||||
if isPurge && rinfo.VersionPurgeStatus == replication.VersionPurgeComplete {
|
||||
return rinfo
|
||||
}
|
||||
if globalBucketTargetSys.isOffline(tgt.EndpointURL()) {
|
||||
replLogOnceIf(ctx, fmt.Errorf("remote target is offline for bucket:%s arn:%s", dobj.Bucket, tgt.ARN), "replication-target-offline-delete-"+tgt.ARN)
|
||||
rinfo.Err = fmt.Errorf("remote target is offline for bucket:%s arn:%s", dobj.Bucket, tgt.ARN)
|
||||
replLogOnceIf(ctx, rinfo.Err, "replication-target-offline-delete-"+tgt.ARN)
|
||||
sendEvent(eventArgs{
|
||||
BucketName: dobj.Bucket,
|
||||
Object: ObjectInfo{
|
||||
Bucket: dobj.Bucket,
|
||||
Name: dobj.ObjectName,
|
||||
VersionID: dobj.VersionID,
|
||||
VersionID: versionID,
|
||||
DeleteMarker: dobj.DeleteMarker,
|
||||
},
|
||||
UserAgent: "Internal: [Replication]",
|
||||
Host: globalLocalNodeName,
|
||||
EventName: event.ObjectReplicationNotTracked,
|
||||
})
|
||||
if dobj.VersionID == "" {
|
||||
rinfo.ReplicationStatus = replication.Failed
|
||||
} else {
|
||||
if isPurge {
|
||||
rinfo.VersionPurgeStatus = replication.VersionPurgeFailed
|
||||
} else {
|
||||
rinfo.ReplicationStatus = replication.Failed
|
||||
}
|
||||
return rinfo
|
||||
}
|
||||
// early return if already replicated delete marker for existing object replication/ healing delete markers
|
||||
if dobj.DeleteMarkerVersionID != "" {
|
||||
if !isPurge && dobj.DeleteMarkerVersionID != "" {
|
||||
toi, err := tgt.StatObject(ctx, tgt.Bucket, dobj.ObjectName, minio.StatObjectOptions{
|
||||
VersionID: versionID,
|
||||
Internal: minio.AdvancedGetOptions{
|
||||
@@ -662,16 +686,10 @@ func replicateDeleteToTarget(ctx context.Context, dobj DeletedObjectReplicationI
|
||||
switch {
|
||||
case isErrMethodNotAllowed(serr):
|
||||
// delete marker already replicated
|
||||
if dobj.VersionID == "" && rinfo.VersionPurgeStatus.Empty() {
|
||||
rinfo.ReplicationStatus = replication.Completed
|
||||
return rinfo
|
||||
}
|
||||
rinfo.ReplicationStatus = replication.Completed
|
||||
return rinfo
|
||||
case isErrObjectNotFound(serr), isErrVersionNotFound(serr):
|
||||
// version being purged is already not found on target.
|
||||
if !rinfo.VersionPurgeStatus.Empty() {
|
||||
rinfo.VersionPurgeStatus = replication.VersionPurgeComplete
|
||||
return rinfo
|
||||
}
|
||||
// The marker still needs to be created on the target.
|
||||
case isErrReadQuorum(serr), isErrWriteQuorum(serr):
|
||||
// destination has some quorum issues, perform removeObject() anyways
|
||||
// to complete the operation.
|
||||
@@ -691,7 +709,7 @@ func replicateDeleteToTarget(ctx context.Context, dobj DeletedObjectReplicationI
|
||||
rmErr := tgt.RemoveObject(ctx, tgt.Bucket, dobj.ObjectName, minio.RemoveObjectOptions{
|
||||
VersionID: versionID,
|
||||
Internal: minio.AdvancedRemoveOptions{
|
||||
ReplicationDeleteMarker: dobj.DeleteMarkerVersionID != "",
|
||||
ReplicationDeleteMarker: !isPurge && dobj.DeleteMarkerVersionID != "",
|
||||
ReplicationMTime: dobj.DeleteMarkerMTime.Time,
|
||||
ReplicationStatus: minio.ReplicationStatusReplica,
|
||||
ReplicationRequest: true, // always set this to distinguish between `mc mirror` replication and serverside
|
||||
@@ -699,20 +717,20 @@ func replicateDeleteToTarget(ctx context.Context, dobj DeletedObjectReplicationI
|
||||
})
|
||||
if rmErr != nil {
|
||||
rinfo.Err = rmErr
|
||||
if dobj.VersionID == "" {
|
||||
rinfo.ReplicationStatus = replication.Failed
|
||||
} else {
|
||||
if isPurge {
|
||||
rinfo.VersionPurgeStatus = replication.VersionPurgeFailed
|
||||
} else {
|
||||
rinfo.ReplicationStatus = replication.Failed
|
||||
}
|
||||
replLogIf(ctx, fmt.Errorf("unable to replicate delete marker to %s: %s/%s(%s): %w", tgt.EndpointURL(), tgt.Bucket, dobj.ObjectName, versionID, rmErr))
|
||||
if rmErr != nil && minio.IsNetworkOrHostDown(rmErr, true) && !globalBucketTargetSys.isOffline(tgt.EndpointURL()) {
|
||||
globalBucketTargetSys.markOffline(tgt.EndpointURL())
|
||||
}
|
||||
} else {
|
||||
if dobj.VersionID == "" {
|
||||
rinfo.ReplicationStatus = replication.Completed
|
||||
} else {
|
||||
if isPurge {
|
||||
rinfo.VersionPurgeStatus = replication.VersionPurgeComplete
|
||||
} else {
|
||||
rinfo.ReplicationStatus = replication.Completed
|
||||
}
|
||||
}
|
||||
return rinfo
|
||||
@@ -781,6 +799,18 @@ func (m caseInsensitiveMap) Lookup(key string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// replicationTaggingTimestamp carries a recorded removal even when tags are
|
||||
// empty. Only legacy nonempty tags use ModTime; absence is not a tombstone.
|
||||
func replicationTaggingTimestamp(objInfo ObjectInfo) (time.Time, error) {
|
||||
if stamp, ok := caseInsensitiveMap(objInfo.UserDefined).Lookup(ReservedMetadataPrefixLower + TaggingTimestamp); ok {
|
||||
return time.Parse(time.RFC3339Nano, stamp)
|
||||
}
|
||||
if objInfo.UserTags != "" {
|
||||
return objInfo.ModTime, nil
|
||||
}
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
func putReplicationOpts(ctx context.Context, sc string, objInfo ObjectInfo) (putOpts minio.PutObjectOptions, isMP bool, err error) {
|
||||
meta := make(map[string]string)
|
||||
isSSEC := crypto.SSEC.IsEncrypted(objInfo.UserDefined)
|
||||
@@ -850,17 +880,12 @@ func putReplicationOpts(ctx context.Context, sc string, objInfo ObjectInfo) (put
|
||||
tag, _ := tags.ParseObjectTags(objInfo.UserTags)
|
||||
if tag != nil {
|
||||
putOpts.UserTags = tag.ToMap()
|
||||
// set tag timestamp in opts
|
||||
tagTimestamp := objInfo.ModTime
|
||||
if tagTmstampStr, ok := objInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp]; ok {
|
||||
tagTimestamp, err = time.Parse(time.RFC3339Nano, tagTmstampStr)
|
||||
if err != nil {
|
||||
return putOpts, false, err
|
||||
}
|
||||
}
|
||||
putOpts.Internal.TaggingTimestamp = tagTimestamp
|
||||
}
|
||||
}
|
||||
putOpts.Internal.TaggingTimestamp, err = replicationTaggingTimestamp(objInfo)
|
||||
if err != nil {
|
||||
return putOpts, false, err
|
||||
}
|
||||
|
||||
lkMap := caseInsensitiveMap(objInfo.UserDefined)
|
||||
if lang, ok := lkMap.Lookup(xhttp.ContentLanguage); ok {
|
||||
@@ -1003,6 +1028,12 @@ func getReplicationAction(oi1 ObjectInfo, oi2 minio.ObjectInfo, opType replicati
|
||||
if (oi2.UserTagCount > 0 && !reflect.DeepEqual(oi2Map, t.ToMap())) || (oi2.UserTagCount != len(t.ToMap())) {
|
||||
return replicateMetadata
|
||||
}
|
||||
// HEAD does not report the tag revision. Equal values can hide a newer
|
||||
// deletion or re-addition, so scheduled metadata/heal work must deliver it.
|
||||
// Completed objects are still excluded by the existing scanner gates.
|
||||
if _, ok := caseInsensitiveMap(oi1.UserDefined).Lookup(ReservedMetadataPrefixLower + TaggingTimestamp); ok {
|
||||
return replicateMetadata
|
||||
}
|
||||
|
||||
// Compare only necessary headers
|
||||
compareKeys := []string{
|
||||
@@ -1269,9 +1300,6 @@ func replicateObject(ctx context.Context, ri ReplicateObjectInfo, objectAPI Obje
|
||||
oi.UserDefined[targetResetHeader(rinfo.Arn)] = rinfo.ResyncTimestamp
|
||||
}
|
||||
}
|
||||
if ri.UserTags != "" {
|
||||
oi.UserDefined[xhttp.AmzObjectTagging] = ri.UserTags
|
||||
}
|
||||
return dsc, nil
|
||||
},
|
||||
}
|
||||
@@ -1689,14 +1717,11 @@ applyAction:
|
||||
if _, ok := lkMap.Lookup(xhttp.AmzObjectLockRetainUntilDate); ok {
|
||||
dstOpts.Internal.RetentionTimestamp = objInfo.ModTime
|
||||
}
|
||||
if objInfo.UserTags != "" {
|
||||
dstOpts.Internal.TaggingTimestamp = objInfo.ModTime
|
||||
}
|
||||
if tagTmStr, ok := lkMap.Lookup(ReservedMetadataPrefixLower + TaggingTimestamp); ok {
|
||||
ondiskTimestamp, err := time.Parse(time.RFC3339, tagTmStr)
|
||||
if err == nil {
|
||||
dstOpts.Internal.TaggingTimestamp = ondiskTimestamp
|
||||
}
|
||||
dstOpts.Internal.TaggingTimestamp, rinfo.Err = replicationTaggingTimestamp(objInfo)
|
||||
if rinfo.Err != nil {
|
||||
rinfo.ReplicationStatus = replication.Failed
|
||||
replLogIf(ctx, fmt.Errorf("invalid tagging timestamp for object %s/%s(%s): %w", bucket, object, objInfo.VersionID, rinfo.Err))
|
||||
return rinfo
|
||||
}
|
||||
if retTmStr, ok := lkMap.Lookup(ReservedMetadataPrefixLower + ObjectLockRetentionTimestamp); ok {
|
||||
ondiskTimestamp, err := time.Parse(time.RFC3339, retTmStr)
|
||||
@@ -1916,11 +1941,26 @@ func filterReplicationStatusMetadata(metadata map[string]string) map[string]stri
|
||||
// DeletedObjectReplicationInfo has info on deleted object
|
||||
type DeletedObjectReplicationInfo struct {
|
||||
DeletedObject
|
||||
Bucket string
|
||||
EventType string
|
||||
OpType replication.Type
|
||||
ResetID string
|
||||
TargetArn string
|
||||
Bucket string
|
||||
EventType string
|
||||
OpType replication.Type
|
||||
ResetID string
|
||||
TargetArn string
|
||||
RetryCount int
|
||||
}
|
||||
|
||||
// isVersionPurge also recognizes the old marker-shaped purge task. Use the
|
||||
// operation's state, rather than one target's possibly missing purge entry.
|
||||
func (di DeletedObjectReplicationInfo) isVersionPurge() bool {
|
||||
return di.VersionID != "" || di.DeleteMarkerVersionID != "" && !di.VersionPurgeStatus().Empty()
|
||||
}
|
||||
|
||||
// Purge metadata uses COMPLETE; operation statistics and audit use COMPLETED.
|
||||
func purgeReplicationStatus(status VersionPurgeStatusType) replication.StatusType {
|
||||
if replication.StatusType(status) == replication.CompletedLegacy {
|
||||
return replication.Completed
|
||||
}
|
||||
return replication.StatusType(status)
|
||||
}
|
||||
|
||||
// ToMRFEntry returns the relevant info needed by MRF
|
||||
@@ -1930,9 +1970,10 @@ func (di DeletedObjectReplicationInfo) ToMRFEntry() MRFReplicateEntry {
|
||||
versionID = di.VersionID
|
||||
}
|
||||
return MRFReplicateEntry{
|
||||
Bucket: di.Bucket,
|
||||
Object: di.ObjectName,
|
||||
versionID: versionID,
|
||||
Bucket: di.Bucket,
|
||||
Object: di.ObjectName,
|
||||
versionID: versionID,
|
||||
RetryCount: di.RetryCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2424,6 +2465,7 @@ func (p *ReplicationPool) queueReplicaDeleteTask(doi DeletedObjectReplicationInf
|
||||
case <-p.ctx.Done():
|
||||
case ch <- doi:
|
||||
default:
|
||||
doi.RetryCount++
|
||||
p.queueMRFSave(doi.ToMRFEntry())
|
||||
p.mu.RLock()
|
||||
prio := p.priority
|
||||
@@ -3787,9 +3829,10 @@ func queueReplicationHeal(ctx context.Context, bucket string, oi ObjectInfo, rcf
|
||||
DeleteMarkerMTime: DeleteMarkerMTime{roi.ModTime},
|
||||
DeleteMarker: roi.DeleteMarker,
|
||||
},
|
||||
Bucket: roi.Bucket,
|
||||
OpType: replication.HealReplicationType,
|
||||
EventType: ReplicateHealDelete,
|
||||
Bucket: roi.Bucket,
|
||||
OpType: replication.HealReplicationType,
|
||||
EventType: ReplicateHealDelete,
|
||||
RetryCount: retryCount,
|
||||
}
|
||||
// heal delete marker replication failure or versioned delete replication failure
|
||||
if roi.ReplicationStatus == replication.Pending ||
|
||||
@@ -4072,7 +4115,12 @@ func (p *ReplicationPool) queueMRFHeal() error {
|
||||
VersionID: vID,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
// A versioned marker lookup returns its metadata with a 405. Only
|
||||
// accept that error with a real, matching marker identity.
|
||||
validMarker := isErrMethodNotAllowed(err) && oi.DeleteMarker &&
|
||||
vID != "" && oi.VersionID == vID && !oi.ModTime.IsZero() &&
|
||||
oi.Bucket == e.Bucket && oi.Name != "" && oi.Name == decodeDirObject(e.Object)
|
||||
if err != nil && !validMarker || oi.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -445,6 +445,7 @@ func buildServerCtxt(ctx *cli.Context, ctxt *serverCtxt) (err error) {
|
||||
ctxt.SendBufSize = ctx.Int("send-buf-size")
|
||||
ctxt.RecvBufSize = ctx.Int("recv-buf-size")
|
||||
ctxt.IdleTimeout = ctx.Duration("idle-timeout")
|
||||
ctxt.ReadHeaderTimeout = ctx.Duration("read-header-timeout")
|
||||
ctxt.UserTimeout = ctx.Duration("conn-user-timeout")
|
||||
|
||||
if conf := ctx.String("config"); len(conf) > 0 {
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
// Copyright (c) 2026 mr javad seydi and Ruohang Feng
|
||||
//
|
||||
// This file is part of Silo Object Storage stack.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pgsty/silo-pkg/v3/policy"
|
||||
)
|
||||
|
||||
const multipartScanEntryLimit = 100_000
|
||||
|
||||
var (
|
||||
multipartScanSlots = make(chan struct{}, 2)
|
||||
errMultipartListingLegacy = errors.New("legacy multipart uploads require a coordinated upgrade and drain")
|
||||
errMultipartListingIdentity = errors.New("multipart upload identity is invalid")
|
||||
)
|
||||
|
||||
// One budget and admission slot cover the entire request, including all pools.
|
||||
// A slot is released only after the scan workers have actually stopped.
|
||||
type multipartScan struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
remaining atomic.Int64
|
||||
metadataSlots chan struct{}
|
||||
preflight bool
|
||||
sets []multipartScanSet
|
||||
}
|
||||
|
||||
type multipartScanSet struct {
|
||||
Pool int `json:"pool"`
|
||||
Set int `json:"set"`
|
||||
Drives int `json:"drives"`
|
||||
ScannedDrives int `json:"scannedDrives"`
|
||||
UncoveredDrives []int `json:"uncoveredDrives,omitempty"`
|
||||
Candidates int `json:"candidates"`
|
||||
LegacyUploads int `json:"legacyUploads"`
|
||||
OldestLegacy time.Time `json:"oldestLegacy,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type multipartPreflightReport struct {
|
||||
Ready bool `json:"ready"`
|
||||
Complete bool `json:"complete"`
|
||||
Mode string `json:"mode"`
|
||||
ScannedEntries int64 `json:"scannedEntries"`
|
||||
LegacyUploads int `json:"legacyUploads"`
|
||||
Sets []multipartScanSet `json:"sets"`
|
||||
}
|
||||
|
||||
func startMultipartScan(ctx context.Context, preflight bool) (*multipartScan, error) {
|
||||
select {
|
||||
case multipartScanSlots <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
return nil, SlowDown{}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
s := &multipartScan{ctx: ctx, cancel: cancel, preflight: preflight, metadataSlots: make(chan struct{}, multipartMetadataScanConcurrency)}
|
||||
s.remaining.Store(multipartScanEntryLimit)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *multipartScan) close() {
|
||||
s.cancel()
|
||||
<-multipartScanSlots
|
||||
}
|
||||
|
||||
func (s *multipartScan) listDir(disk StorageAPI, bucket, dir string) ([]string, error) {
|
||||
if err := s.ctx.Err(); err != nil {
|
||||
return nil, SlowDown{}
|
||||
}
|
||||
remaining := s.remaining.Load()
|
||||
if remaining <= 0 {
|
||||
return nil, SlowDown{}
|
||||
}
|
||||
// Request one extra entry to detect overflow, never a silently partial page.
|
||||
entries, err := disk.ListDir(s.ctx, bucket, minioMetaMultipartBucket, dir, int(remaining)+1)
|
||||
if errors.Is(err, errFileNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.remaining.Add(-int64(len(entries))) < 0 {
|
||||
return nil, SlowDown{}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *multipartScan) listUploadDirs(disk StorageAPI, bucket string) ([]string, error) {
|
||||
hashDirs, err := s.listDir(disk, bucket, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var candidates []string
|
||||
for _, hashDir := range hashDirs {
|
||||
if !strings.HasSuffix(hashDir, SlashSeparator) {
|
||||
continue
|
||||
}
|
||||
hashDir = strings.TrimSuffix(hashDir, SlashSeparator)
|
||||
uploadDirs, err := s.listDir(disk, bucket, hashDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, uploadDir := range uploadDirs {
|
||||
if strings.HasSuffix(uploadDir, SlashSeparator) {
|
||||
candidates = append(candidates, pathJoin(hashDir, strings.TrimSuffix(uploadDir, SlashSeparator)))
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// Identity is immutable at hash/uploadUUID. Check the hash before using even
|
||||
// a single source drive to exclude another bucket. Missing/bad identities must
|
||||
// fall back to the full metadata read; they cannot prove absence.
|
||||
func (er erasureObjects) multipartIdentity(fi FileInfo, shaDir string) (string, string, bool) {
|
||||
bucket, object := fi.Metadata[multipartMetaBucket], fi.Metadata[multipartMetaObject]
|
||||
return bucket, object, bucket != "" && object != "" && IsValidBucketName(bucket) &&
|
||||
IsValidObjectPrefix(object) && er.getMultipartSHADir(bucket, object) == shaDir
|
||||
}
|
||||
|
||||
func (er erasureObjects) readMultipartUploadCandidate(s *multipartScan, bucket, candidate string, source StorageAPI) (MultipartInfo, bool, bool, error) {
|
||||
if s.ctx.Err() != nil {
|
||||
return MultipartInfo{}, false, false, SlowDown{}
|
||||
}
|
||||
shaDir, uploadUUID, ok := strings.Cut(candidate, SlashSeparator)
|
||||
if !ok || shaDir == "" || uploadUUID == "" || strings.Contains(uploadUUID, SlashSeparator) {
|
||||
return MultipartInfo{}, false, false, errMultipartListingIdentity
|
||||
}
|
||||
if !s.preflight && bucket != "" && source != nil {
|
||||
fi, err := source.ReadVersion(s.ctx, bucket, minioMetaMultipartBucket, candidate, "", ReadOptions{})
|
||||
if err == nil {
|
||||
storedBucket, _, valid := er.multipartIdentity(fi, shaDir)
|
||||
if valid && storedBucket != bucket {
|
||||
return MultipartInfo{}, false, false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.ctx.Err() != nil {
|
||||
return MultipartInfo{}, false, false, SlowDown{}
|
||||
}
|
||||
select {
|
||||
case s.metadataSlots <- struct{}{}:
|
||||
defer func() { <-s.metadataSlots }()
|
||||
case <-s.ctx.Done():
|
||||
return MultipartInfo{}, false, false, SlowDown{}
|
||||
}
|
||||
disks := er.getDisks()
|
||||
metadata, errs := readAllFileInfo(s.ctx, disks, bucket, minioMetaMultipartBucket, candidate, "", false, false)
|
||||
if s.preflight {
|
||||
// Readiness is stronger than listing liveness: even one readable legacy
|
||||
// copy must be drained, and an unreadable copy cannot certify readiness.
|
||||
var oldest MultipartInfo
|
||||
legacy := false
|
||||
_, nativeID := multipartUploadTime(uploadUUID)
|
||||
for i, err := range errs {
|
||||
if errors.Is(err, errFileNotFound) || errors.Is(err, errFileVersionNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return MultipartInfo{}, false, false, err
|
||||
}
|
||||
fi := metadata[i]
|
||||
storedBucket, storedObject, valid := er.multipartIdentity(fi, shaDir)
|
||||
if storedBucket != "" && storedObject != "" && !valid {
|
||||
return MultipartInfo{}, false, false, errMultipartListingIdentity
|
||||
}
|
||||
if !valid || !nativeID {
|
||||
info := multipartUploadInfo(storedBucket, storedObject, uploadUUID, fi.ModTime)
|
||||
if !legacy || info.Initiated.Before(oldest.Initiated) {
|
||||
oldest = info
|
||||
}
|
||||
legacy = true
|
||||
}
|
||||
}
|
||||
if legacy {
|
||||
return oldest, false, true, nil
|
||||
}
|
||||
}
|
||||
readQuorum, _, err := objectQuorumFromMeta(s.ctx, metadata, errs, er.defaultParityCount)
|
||||
if err != nil {
|
||||
return MultipartInfo{}, false, false, err
|
||||
}
|
||||
_, modTime, etag := listOnlineDisks(disks, metadata, errs, readQuorum)
|
||||
if err := reduceReadQuorumErrs(s.ctx, errs, objectOpIgnoredErrs, readQuorum); err != nil {
|
||||
return MultipartInfo{}, false, false, err
|
||||
}
|
||||
fi, err := pickValidFileInfo(s.ctx, metadata, modTime, etag, readQuorum)
|
||||
if err != nil {
|
||||
return MultipartInfo{}, false, false, err
|
||||
}
|
||||
storedBucket, storedObject, valid := er.multipartIdentity(fi, shaDir)
|
||||
info := multipartUploadInfo(storedBucket, storedObject, uploadUUID, fi.ModTime)
|
||||
if storedBucket == "" || storedObject == "" {
|
||||
return info, false, true, nil
|
||||
}
|
||||
if !valid {
|
||||
return MultipartInfo{}, false, false, fmt.Errorf("%w: %s", errMultipartListingIdentity, candidate)
|
||||
}
|
||||
if _, ok := multipartUploadTime(uploadUUID); !ok {
|
||||
return info, false, true, nil
|
||||
}
|
||||
if bucket != "" && bucket != storedBucket {
|
||||
return MultipartInfo{}, false, false, nil
|
||||
}
|
||||
return info, true, false, nil
|
||||
}
|
||||
|
||||
func (er erasureObjects) scanMultipartUploads(s *multipartScan, bucket string, poolIdx, setIdx int) ([]MultipartInfo, bool, error) {
|
||||
disks := er.getDisks()
|
||||
report := multipartScanSet{Pool: poolIdx, Set: setIdx, Drives: er.setDriveCount}
|
||||
var resultErr error
|
||||
defer func() {
|
||||
if resultErr != nil {
|
||||
report.Error = resultErr.Error()
|
||||
}
|
||||
s.sets = append(s.sets, report)
|
||||
}()
|
||||
var candidateMu sync.Mutex
|
||||
candidates := make(map[string]StorageAPI)
|
||||
errs := make([]error, len(disks))
|
||||
var wg sync.WaitGroup
|
||||
for i, disk := range disks {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if disk == nil || !disk.IsOnline() {
|
||||
errs[i] = errDiskNotFound
|
||||
return
|
||||
}
|
||||
paths, err := s.listUploadDirs(disk, bucket)
|
||||
errs[i] = err
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
candidateMu.Lock()
|
||||
defer candidateMu.Unlock()
|
||||
for _, p := range paths {
|
||||
candidates[p] = disk
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err == nil {
|
||||
report.ScannedDrives++
|
||||
} else {
|
||||
report.UncoveredDrives = append(report.UncoveredDrives, i)
|
||||
if _, limited := err.(SlowDown); limited {
|
||||
resultErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
report.Candidates = len(candidates)
|
||||
// A successful scan must intersect every metadata read quorum, including
|
||||
// records left with R copies by a partially failed cancellation.
|
||||
if report.ScannedDrives < er.setDriveCount/2+1 && resultErr == nil {
|
||||
resultErr = toObjectErr(errErasureReadQuorum, bucket)
|
||||
}
|
||||
if resultErr != nil {
|
||||
return nil, false, resultErr
|
||||
}
|
||||
type candidate struct {
|
||||
path string
|
||||
source StorageAPI
|
||||
}
|
||||
jobs := make(chan candidate)
|
||||
var mu sync.Mutex
|
||||
var uploads []MultipartInfo
|
||||
for range min(16, len(candidates)) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for c := range jobs {
|
||||
mu.Lock()
|
||||
stopped := resultErr != nil
|
||||
mu.Unlock()
|
||||
if stopped || s.ctx.Err() != nil {
|
||||
continue
|
||||
}
|
||||
upload, found, legacy, err := er.readMultipartUploadCandidate(s, bucket, c.path, c.source)
|
||||
if errors.Is(err, errFileNotFound) || errors.Is(err, errFileVersionNotFound) {
|
||||
continue
|
||||
}
|
||||
mu.Lock()
|
||||
switch {
|
||||
case err != nil && resultErr == nil:
|
||||
resultErr = err
|
||||
case legacy:
|
||||
report.LegacyUploads++
|
||||
if report.OldestLegacy.IsZero() || upload.Initiated.Before(report.OldestLegacy) {
|
||||
report.OldestLegacy = upload.Initiated
|
||||
}
|
||||
case found && !s.preflight:
|
||||
uploads = append(uploads, upload)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
dispatch:
|
||||
for p, source := range candidates {
|
||||
select {
|
||||
case jobs <- candidate{p, source}:
|
||||
case <-s.ctx.Done():
|
||||
break dispatch
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
if s.ctx.Err() != nil {
|
||||
resultErr = SlowDown{}
|
||||
}
|
||||
return uploads, report.LegacyUploads != 0, resultErr
|
||||
}
|
||||
|
||||
// multipartPreflight scans all pools, sets and drives, independent of caches.
|
||||
// A majority suffices for normal listing; upgrade readiness requires every
|
||||
// drive to have been inspected. The operator must also upgrade all writers.
|
||||
func (z *erasureServerPools) multipartPreflight(ctx context.Context) (multipartPreflightReport, error) {
|
||||
s, err := startMultipartScan(ctx, true)
|
||||
if err != nil {
|
||||
return multipartPreflightReport{}, err
|
||||
}
|
||||
defer s.close()
|
||||
report := multipartPreflightReport{Complete: true, Mode: "strict"}
|
||||
if globalAPIConfig.getMultipartListingLegacy() {
|
||||
report.Mode = "legacy"
|
||||
}
|
||||
for p, pool := range z.serverPools {
|
||||
for i, set := range pool.sets {
|
||||
_, _, _ = set.scanMultipartUploads(s, "", p, i)
|
||||
}
|
||||
}
|
||||
report.Sets = s.sets
|
||||
for _, set := range s.sets {
|
||||
report.LegacyUploads += set.LegacyUploads
|
||||
if set.Error != "" || set.ScannedDrives != set.Drives {
|
||||
report.Complete = false
|
||||
}
|
||||
}
|
||||
report.ScannedEntries = multipartScanEntryLimit - s.remaining.Load()
|
||||
report.Ready = report.Complete && report.LegacyUploads == 0
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// MultipartPreflightHandler is a read-only storage-admin diagnostic. It never
|
||||
// accepts an arbitrary deletion path or changes upload lifetime settings.
|
||||
func (a adminAPIHandlers) MultipartPreflightHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
obj, _ := validateAdminReq(ctx, w, r, policy.StorageInfoAdminAction)
|
||||
if obj == nil {
|
||||
return
|
||||
}
|
||||
z, ok := obj.(*erasureServerPools)
|
||||
if !ok {
|
||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrNotImplemented), r.URL)
|
||||
return
|
||||
}
|
||||
report, err := z.multipartPreflight(ctx)
|
||||
if err != nil {
|
||||
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
writeSuccessResponseJSON(w, b)
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
// Copyright (c) 2026 Ruohang Feng
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads", "AbortMultipart"}, MakeBucketOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setMultipartListingTestMode(t, false)
|
||||
var firstID, secondID string
|
||||
for attempt := 0; attempt < 32; attempt++ {
|
||||
one, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
two, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if one.UploadID < two.UploadID {
|
||||
firstID, secondID = one.UploadID, two.UploadID
|
||||
break
|
||||
}
|
||||
for _, id := range []string{one.UploadID, two.UploadID} {
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "a", id, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if firstID == "" {
|
||||
t.Fatal("could not construct increasing upload IDs")
|
||||
}
|
||||
if _, err := z.NewMultipartUpload(t.Context(), bucket, "b", ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := func(method, u string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req, err := newTestSignedRequestV4(method, u, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
list := func(keyMarker, uploadMarker string, limit int) ListMultipartUploadsResponse {
|
||||
t.Helper()
|
||||
rec := request(http.MethodGet, getListMultipartUploadsURLWithParams("", bucket, "", keyMarker, uploadMarker, "", strconv.Itoa(limit)))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list HTTP %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var result ListMultipartUploadsResponse
|
||||
if err := xml.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
first := list("", "", 1)
|
||||
if len(first.Uploads) != 1 || first.Uploads[0].UploadID != firstID || !first.IsTruncated {
|
||||
t.Fatalf("unexpected first page: %+v", first)
|
||||
}
|
||||
rec := request(http.MethodDelete, getAbortMultipartUploadURL("", bucket, "a", firstID))
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("abort HTTP %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rest := list(first.NextKeyMarker, first.NextUploadIDMarker, 10)
|
||||
var keys []string
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
type multipartListingFaultDisk struct {
|
||||
StorageAPI
|
||||
read func(context.Context, string, string, string, string, ReadOptions) (FileInfo, error)
|
||||
delete func(context.Context, string, string, DeleteOptions) error
|
||||
}
|
||||
|
||||
func (d multipartListingFaultDisk) ReadVersion(ctx context.Context, original, volume, object, version string, opts ReadOptions) (FileInfo, error) {
|
||||
if d.read != nil {
|
||||
return d.read(ctx, original, volume, object, version, opts)
|
||||
}
|
||||
return d.StorageAPI.ReadVersion(ctx, original, volume, object, version, opts)
|
||||
}
|
||||
|
||||
func (d multipartListingFaultDisk) Delete(ctx context.Context, volume, object string, opts DeleteOptions) error {
|
||||
if d.delete != nil {
|
||||
return d.delete(ctx, volume, object, opts)
|
||||
}
|
||||
return d.StorageAPI.Delete(ctx, volume, object, opts)
|
||||
}
|
||||
|
||||
func TestMultipartListingLegacyPreflight(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
const other = "multipart-legacy-other"
|
||||
if err := z.MakeBucket(t.Context(), other, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := z.NewMultipartUpload(t.Context(), other, "old", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi, metadata, err := set.checkUploadIDExists(t.Context(), other, "old", old.UploadID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range metadata {
|
||||
delete(metadata[i].Metadata, multipartMetaBucket)
|
||||
delete(metadata[i].Metadata, multipartMetaObject)
|
||||
}
|
||||
if _, err = writeAllMetadata(t.Context(), set.getDisks(), other, minioMetaMultipartBucket,
|
||||
set.getUploadIDDir(other, "old", old.UploadID), metadata, fi.WriteQuorum(set.defaultWQuorum())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z.mpCache.Clear()
|
||||
t.Run("legacy-upgrade", func(t *testing.T) {
|
||||
setMultipartListingTestMode(t, true)
|
||||
exact, err := z.ListMultipartUploads(t.Context(), other, "old", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, exact, "old")
|
||||
const empty = "multipart-legacy-empty"
|
||||
if err := z.MakeBucket(t.Context(), empty, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := z.ListMultipartUploads(t.Context(), empty, "", "", "", "", 10)
|
||||
if err != nil || len(got.Uploads) != 0 {
|
||||
t.Fatalf("old upload in another bucket broke legacy listing: %+v %v", got, err)
|
||||
}
|
||||
})
|
||||
_, err = z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
if !errors.Is(err, errMultipartListingLegacy) {
|
||||
t.Fatalf("old upload in another bucket: %v", err)
|
||||
}
|
||||
report, err := z.multipartPreflight(t.Context())
|
||||
if err != nil || report.Ready || !report.Complete || report.LegacyUploads != 1 {
|
||||
t.Fatalf("legacy preflight: %+v %v", report, err)
|
||||
}
|
||||
if err = z.AbortMultipartUpload(t.Context(), other, "old", old.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err = z.multipartPreflight(t.Context())
|
||||
if err != nil || !report.Ready || report.LegacyUploads != 0 {
|
||||
t.Fatalf("drained preflight: %+v %v", report, err)
|
||||
}
|
||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, got, "a")
|
||||
}
|
||||
|
||||
func TestMultipartListingIdentityFallback(t *testing.T) {
|
||||
for _, kind := range []string{"old", "corrupt", "read-failure", "wrong-bucket"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
if _, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := set.getDisks
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
var first atomic.Bool
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i, d := range disks {
|
||||
disks[i] = multipartListingFaultDisk{StorageAPI: d, read: func(ctx context.Context, b, v, p, version string, opts ReadOptions) (FileInfo, error) {
|
||||
fi, err := d.ReadVersion(ctx, b, v, p, version, opts)
|
||||
if v == minioMetaMultipartBucket && first.CompareAndSwap(false, true) {
|
||||
if kind == "read-failure" {
|
||||
return FileInfo{}, errDiskNotFound
|
||||
}
|
||||
if kind == "corrupt" {
|
||||
return FileInfo{}, errFileCorrupt
|
||||
}
|
||||
fi.Metadata = cloneMSS(fi.Metadata)
|
||||
if kind == "old" {
|
||||
delete(fi.Metadata, multipartMetaBucket)
|
||||
} else {
|
||||
fi.Metadata[multipartMetaBucket] = "another-bucket"
|
||||
}
|
||||
}
|
||||
return fi, err
|
||||
}}
|
||||
}
|
||||
return disks
|
||||
}
|
||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, got, "a")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortPoolsAndRetry(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
setMultipartListingTestMode(t, false)
|
||||
mp, err := z.serverPools[1].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emptySet := z.serverPools[0].getHashedSet("a")
|
||||
original := emptySet.getDisks
|
||||
t.Cleanup(func() { emptySet.getDisks = original })
|
||||
emptySet.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i, d := range disks {
|
||||
disks[i] = multipartListingFaultDisk{StorageAPI: d, read: func(context.Context, string, string, string, string, ReadOptions) (FileInfo, error) {
|
||||
return FileInfo{}, errDiskNotFound
|
||||
}}
|
||||
}
|
||||
return disks
|
||||
}
|
||||
err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{})
|
||||
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
||||
t.Fatalf("unknown pool must not acknowledge cancellation: %v", err)
|
||||
}
|
||||
emptySet.getDisks = original
|
||||
err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{})
|
||||
if _, ok := err.(InvalidUploadID); !ok {
|
||||
t.Fatalf("retry after all pools confirm absence: %v", err)
|
||||
}
|
||||
mp, err = z.serverPools[1].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = z.serverPools[1].GetMultipartInfo(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err == nil {
|
||||
t.Fatal("empty first pool hid the actual upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortRetryBelowReadQuorum(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := set.getDisks
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
disks[3] = nil
|
||||
d := disks[2]
|
||||
disks[2] = multipartListingFaultDisk{StorageAPI: d, delete: func(ctx context.Context, v, p string, opts DeleteOptions) error {
|
||||
if err := d.Delete(ctx, v, p, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
return context.DeadlineExceeded // operation finished, acknowledgement lost
|
||||
}}
|
||||
return disks
|
||||
}
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err == nil {
|
||||
t.Fatal("lost acknowledgement must fail")
|
||||
}
|
||||
set.getDisks = original
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("one remaining metadata copy prevented retry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingMarkerHTTP(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads"}, MakeBucketOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setMultipartListingTestMode(t, false)
|
||||
for _, tc := range []struct {
|
||||
key, marker string
|
||||
status int
|
||||
}{
|
||||
{"", "not-base64=", 200},
|
||||
{"a", "not-base64=", 404},
|
||||
{"a", base64.RawURLEncoding.EncodeToString([]byte("not-native")), 400},
|
||||
{"a", multipartListingTestID(time.Unix(100, 0), 1), 200},
|
||||
} {
|
||||
u := getListMultipartUploadsURLWithParams("", bucket, "", tc.key, tc.marker, "", "10")
|
||||
req, err := newTestSignedRequestV4(http.MethodGet, u, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("key=%q marker=%q: %d %s", tc.key, tc.marker, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartPreflightAdminHTTP(t *testing.T) {
|
||||
bed, err := prepareAdminErasureTestBed(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { bed.done(); bed.objLayer.Shutdown(context.Background()); removeRoots(bed.erasureDirs) })
|
||||
const target = "/minio/admin/v3/multipart-preflight"
|
||||
rec := httptest.NewRecorder()
|
||||
bed.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
if rec.Code != 403 {
|
||||
t.Fatalf("anonymous preflight: %d", rec.Code)
|
||||
}
|
||||
req, err := newTestSignedRequestV4(http.MethodGet, target, 0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
bed.router.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("admin preflight: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var report multipartPreflightReport
|
||||
if err = json.Unmarshal(rec.Body.Bytes(), &report); err != nil || !report.Ready || !report.Complete {
|
||||
t.Fatalf("preflight: %+v %v", report, err)
|
||||
}
|
||||
for range cap(multipartScanSlots) {
|
||||
scan, err := startMultipartScan(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer scan.close()
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
bed.router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("busy admin preflight: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type multipartLateCreateDisk struct {
|
||||
StorageAPI
|
||||
late chan<- func() error
|
||||
}
|
||||
|
||||
func (d multipartLateCreateDisk) WriteMetadata(_ context.Context, original, volume, object string, fi FileInfo) error {
|
||||
if volume == minioMetaMultipartBucket {
|
||||
d.late <- func() error { return d.StorageAPI.WriteMetadata(context.Background(), original, volume, object, fi) }
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
return d.StorageAPI.WriteMetadata(context.Background(), original, volume, object, fi)
|
||||
}
|
||||
|
||||
// This characterization is deliberately NOT an assertion of terminal abort
|
||||
// correctness. It preserves an executable example of the separately scoped
|
||||
// late-creation-write limitation. Change the expectation when fencing is added.
|
||||
func TestMultipartAbortLateCreateBoundary(t *testing.T) {
|
||||
obj, dirs, err := prepareErasure(t.Context(), 16)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
setMultipartListingTestMode(t, false)
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||
saved := globalStorageClass
|
||||
globalStorageClass.Update(storageclass.Config{Standard: storageclass.StorageClass{Parity: 8}})
|
||||
t.Cleanup(func() { globalStorageClass.Update(saved) })
|
||||
const bucket = "multipart-late-create"
|
||||
if err = z.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set := z.serverPools[0].getHashedSet("a")
|
||||
original := set.getDisks
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
late := make(chan func() error, 16)
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i := 9; i < 16; i++ {
|
||||
disks[i] = multipartLateCreateDisk{disks[i], late}
|
||||
}
|
||||
return disks
|
||||
}
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(late) != 7 {
|
||||
t.Fatalf("expected seven timed-out creation writes, got %d", len(late))
|
||||
}
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i := 2; i < 9; i++ {
|
||||
disks[i] = nil
|
||||
}
|
||||
return disks
|
||||
}
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 7 {
|
||||
if err = (<-late)(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
set.getDisks = original
|
||||
_, metadata, err := set.checkUploadIDExists(t.Context(), bucket, "a", mp.UploadID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("late creation boundary changed; review and remove the documented limitation: %v", err)
|
||||
}
|
||||
count := 0
|
||||
for _, fi := range metadata {
|
||||
if fi.IsValid() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 14 {
|
||||
t.Fatalf("expected fourteen resurrected copies, got %d", count)
|
||||
}
|
||||
t.Log("KNOWN UNRESOLVED BOUNDARY: seven delayed creation writes plus seven offline copies restore a writable upload after acknowledged cancellation")
|
||||
}
|
||||
|
||||
type multipartListingCountingDisk struct {
|
||||
StorageAPI
|
||||
directoryCalls *atomic.Int64
|
||||
metadataCalls *atomic.Int64
|
||||
}
|
||||
|
||||
func (d multipartListingCountingDisk) ListDir(ctx context.Context, original, volume, dir string, count int) ([]string, error) {
|
||||
if volume == minioMetaMultipartBucket {
|
||||
d.directoryCalls.Add(1)
|
||||
}
|
||||
return d.StorageAPI.ListDir(ctx, original, volume, dir, count)
|
||||
}
|
||||
|
||||
func (d multipartListingCountingDisk) ReadVersion(ctx context.Context, original, volume, object, version string, opts ReadOptions) (FileInfo, error) {
|
||||
if volume == minioMetaMultipartBucket {
|
||||
d.metadataCalls.Add(1)
|
||||
}
|
||||
return d.StorageAPI.ReadVersion(ctx, original, volume, object, version, opts)
|
||||
}
|
||||
|
||||
// Counts storage API calls; this is not a deployment throughput benchmark.
|
||||
func TestMultipartListingScanCosts(t *testing.T) {
|
||||
obj, dirs, err := prepareErasure(t.Context(), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
setMultipartListingTestMode(t, false)
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||
const bucket, otherBucket = "r9-scan-target", "r9-scan-unrelated"
|
||||
for _, name := range []string{bucket, otherBucket} {
|
||||
if err := z.MakeBucket(t.Context(), name, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := z.NewMultipartUpload(t.Context(), bucket, "dir/target", ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var directoryCalls, metadataCalls atomic.Int64
|
||||
for _, pool := range z.serverPools {
|
||||
for _, set := range pool.sets {
|
||||
original := set.getDisks
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := original()
|
||||
wrapped := make([]StorageAPI, len(disks))
|
||||
for i, disk := range disks {
|
||||
if disk != nil {
|
||||
wrapped[i] = multipartListingCountingDisk{disk, &directoryCalls, &metadataCalls}
|
||||
}
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
}
|
||||
}
|
||||
measure := func(label string) (int64, int64) {
|
||||
t.Helper()
|
||||
directoryCalls.Store(0)
|
||||
metadataCalls.Store(0)
|
||||
result, err := z.ListMultipartUploads(t.Context(), bucket, "dir/", "", "", "", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, result, "dir/target")
|
||||
d, m := directoryCalls.Load(), metadataCalls.Load()
|
||||
t.Logf("%s: max-uploads=1 returned=%d ListDir=%d ReadVersion=%d", label, len(result.Uploads), d, m)
|
||||
return d, m
|
||||
}
|
||||
_, baseline := measure("no unrelated uploads")
|
||||
for n := 0; n < 32; n++ {
|
||||
if _, err := z.NewMultipartUpload(t.Context(), otherBucket, fmt.Sprintf("other/%03d", n), ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, loaded := measure("32 uploads in another bucket")
|
||||
_, repeated := measure("same query repeated")
|
||||
if loaded != baseline+32 || repeated != loaded {
|
||||
t.Fatalf("unexpected scan accounting: baseline=%d loaded=%d repeated=%d", baseline, loaded, repeated)
|
||||
}
|
||||
}
|
||||
|
||||
func multipartListingTestID(created time.Time, n int) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("00000000-0000-4000-8000-000000000000.00000000-0000-4000-8000-%012xx%d", n, created.UnixNano())))
|
||||
}
|
||||
|
||||
func multipartListingFixture(t *testing.T) (*erasureServerPools, *erasureObjects, string) {
|
||||
t.Helper()
|
||||
obj, dirs, err := prepareErasure(t.Context(), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||
const bucket = "multipart-listing-test"
|
||||
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setMultipartListingTestMode(t, false)
|
||||
return z, z.serverPools[0].getHashedSet("a"), bucket
|
||||
}
|
||||
|
||||
func TestMultipartListingMissingMarkers(t *testing.T) {
|
||||
base := time.Unix(100, 0)
|
||||
sameTime := []string{multipartListingTestID(base.Add(time.Second), 1), multipartListingTestID(base.Add(time.Second), 2), multipartListingTestID(base.Add(time.Second), 3)}
|
||||
slices.Sort(sameTime)
|
||||
uploads := []MultipartInfo{
|
||||
{Bucket: "bucket", Object: "a", UploadID: multipartListingTestID(base, 1), Initiated: base},
|
||||
{Bucket: "bucket", Object: "a", UploadID: sameTime[1], Initiated: base.Add(time.Second)},
|
||||
{Bucket: "bucket", Object: "b", UploadID: multipartListingTestID(base, 4), Initiated: base},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
created time.Time
|
||||
id int
|
||||
want []string
|
||||
}{
|
||||
{"before", base.Add(-time.Second), 0, []string{"a", "a", "b"}},
|
||||
{"between", base.Add(time.Second / 2), 2, []string{"a", "b"}},
|
||||
{"same-time-before", base.Add(time.Second), 2, []string{"a", "b"}},
|
||||
{"same-time-after", base.Add(time.Second), 4, []string{"b"}},
|
||||
{"after", base.Add(2 * time.Second), 5, []string{"b"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
marker := multipartListingTestID(tc.created, tc.id)
|
||||
if tc.name == "same-time-before" {
|
||||
marker = sameTime[0]
|
||||
}
|
||||
if tc.name == "same-time-after" {
|
||||
marker = sameTime[2]
|
||||
}
|
||||
if err := checkListMultipartArgs(t.Context(), "bucket", "", "a", marker, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := paginateMultipartUploads(uploads, "", "a", marker, "", 10)
|
||||
if !slices.Equal(multipartUploadKeys(got.Uploads), tc.want) {
|
||||
t.Fatalf("got %v want %v", multipartUploadKeys(got.Uploads), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingAbortRecovery(t *testing.T) {
|
||||
for _, offline := range []int{1, 2} {
|
||||
t.Run(fmt.Sprint(offline), func(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := set.getDisks
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i := 0; i < offline; i++ {
|
||||
disks[i] = nil
|
||||
}
|
||||
return disks
|
||||
}
|
||||
err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{})
|
||||
if offline == 1 && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if offline == 2 && (err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503) {
|
||||
t.Fatalf("two offline drives must not acknowledge cancellation: %v", err)
|
||||
}
|
||||
set.getDisks = original
|
||||
if offline == 2 {
|
||||
// Retry a partially completed deletion after the original disks return.
|
||||
if err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 100)
|
||||
if err != nil || len(got.Uploads) != 0 {
|
||||
t.Fatalf("canceled upload reappeared: %+v %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingCoverage(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := set.getDisks
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
disks := original()
|
||||
// Leave a readable two-copy record, simulating a partially failed abort.
|
||||
for _, d := range disks[:2] {
|
||||
if err = d.Delete(t.Context(), minioMetaMultipartBucket, set.getUploadIDDir(bucket, "a", mp.UploadID), DeleteOptions{Recursive: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return []StorageAPI{disks[0], disks[1], nil, nil} }
|
||||
_, err = z.ListMultipartUploads(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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, got, "a")
|
||||
report, err := z.multipartPreflight(t.Context())
|
||||
if err != nil || report.Ready || report.Complete || len(report.Sets[0].UncoveredDrives) != 1 {
|
||||
t.Fatalf("offline upgrade preflight: %+v %v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingBudgetAndAdmission(t *testing.T) {
|
||||
_, set, bucket := multipartListingFixture(t)
|
||||
if _, err := set.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scan, err := startMultipartScan(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer scan.close()
|
||||
scan.remaining.Store(1)
|
||||
_, _, err = set.scanMultipartUploads(scan, bucket, 0, 0)
|
||||
var limited SlowDown
|
||||
if !errors.As(err, &limited) {
|
||||
t.Fatalf("budget: %v", err)
|
||||
}
|
||||
if apiErr := toAPIError(t.Context(), err); apiErr.HTTPStatusCode != http.StatusServiceUnavailable || apiErr.Code != "SlowDown" {
|
||||
t.Fatalf("budget error mapping: %+v", apiErr)
|
||||
}
|
||||
second, err := startMultipartScan(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.close()
|
||||
if third, err := startMultipartScan(t.Context(), false); err == nil {
|
||||
third.close()
|
||||
t.Fatal("third scan admitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingAdmissionHTTP(t *testing.T) {
|
||||
z, _, _ := multipartListingFixture(t)
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, []string{"ListMultipartUploads"}, MakeBucketOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setMultipartListingTestMode(t, false)
|
||||
for range cap(multipartScanSlots) {
|
||||
scan, err := startMultipartScan(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer scan.close()
|
||||
}
|
||||
req, err := newTestSignedRequestV4(http.MethodGet,
|
||||
getListMultipartUploadsURLWithParams("", bucket, "", "", "", "", "1"),
|
||||
0, nil, globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var response APIErrorResponse
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingPreflightMinorityLegacy(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "old", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := set.getUploadIDDir(bucket, "old", mp.UploadID)
|
||||
disks := set.getDisks()
|
||||
fi, err := disks[0].ReadVersion(t.Context(), bucket, minioMetaMultipartBucket, path, "", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(fi.Metadata, multipartMetaBucket)
|
||||
delete(fi.Metadata, multipartMetaObject)
|
||||
if err = disks[0].WriteMetadata(t.Context(), bucket, minioMetaMultipartBucket, path, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range disks[1:] {
|
||||
if err = d.Delete(t.Context(), minioMetaMultipartBucket, path, DeleteOptions{Recursive: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
report, err := z.multipartPreflight(t.Context())
|
||||
if err != nil || report.Ready || !report.Complete || report.LegacyUploads != 1 {
|
||||
t.Fatalf("minority legacy copy must prevent readiness: %+v %v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartListingCancellationRetainsAdmission(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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
original := set.getDisks
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
entered := make(chan struct{}, 40)
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
defer once.Do(func() { close(release) })
|
||||
var reads atomic.Int32
|
||||
set.getDisks = func() []StorageAPI {
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i, d := range disks {
|
||||
disks[i] = multipartListingFaultDisk{StorageAPI: d, read: func(ctx context.Context, b, v, p, version string, opts ReadOptions) (FileInfo, error) {
|
||||
if v != minioMetaMultipartBucket {
|
||||
return d.ReadVersion(ctx, b, v, p, version, opts)
|
||||
}
|
||||
reads.Add(1)
|
||||
entered <- struct{}{}
|
||||
// Model an RPC which does not return immediately on cancellation.
|
||||
<-release
|
||||
return FileInfo{}, ctx.Err()
|
||||
}}
|
||||
}
|
||||
return disks
|
||||
}
|
||||
second, err := startMultipartScan(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.close()
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := z.ListMultipartUploads(ctx, bucket, "", "", "", "", 10)
|
||||
done <- err
|
||||
}()
|
||||
for range 16 {
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("identity workers did not start")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if extra, err := startMultipartScan(t.Context(), false); err == nil {
|
||||
extra.close()
|
||||
t.Fatal("canceled request released admission while RPCs were still running")
|
||||
}
|
||||
start := time.Now()
|
||||
once.Do(func() { close(release) })
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("canceled scan returned a successful partial list")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("scan did not stop after blocked RPCs returned")
|
||||
}
|
||||
if got := reads.Load(); got != 16 {
|
||||
t.Fatalf("scheduled more identity/metadata reads after cancellation: %d", got)
|
||||
}
|
||||
t.Logf("16 identity RPCs bounded; admission held until return; cancellation settled in %s", time.Since(start))
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// Copyright (c) 2026 Ruohang Feng
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/config/api"
|
||||
"github.com/minio/minio/internal/config/storageclass"
|
||||
"github.com/minio/minio/internal/dsync"
|
||||
"github.com/minio/minio/internal/grid"
|
||||
xnet "github.com/pgsty/silo-pkg/v3/net"
|
||||
)
|
||||
|
||||
func setMultipartListingTestMode(t *testing.T, legacy bool) {
|
||||
t.Helper()
|
||||
globalAPIConfig.mu.Lock()
|
||||
previous := globalAPIConfig.multipartListingStrict
|
||||
globalAPIConfig.multipartListingStrict = !legacy
|
||||
globalAPIConfig.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
globalAPIConfig.mu.Lock()
|
||||
globalAPIConfig.multipartListingStrict = previous
|
||||
globalAPIConfig.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestMultipartListingDefaultMode(t *testing.T) {
|
||||
var uninitialized apiConfig
|
||||
if !uninitialized.getMultipartListingLegacy() {
|
||||
t.Fatal("runtime zero value enabled strict mode before config initialization")
|
||||
}
|
||||
for _, mode := range []string{"", "legacy", "strict"} {
|
||||
var local apiConfig
|
||||
local.init(api.Config{RequestsMax: 1, MultipartListing: mode}, []int{4}, false)
|
||||
if local.getMultipartListingLegacy() != (mode != "strict") {
|
||||
t.Fatalf("unexpected mode for %q", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortLegacyAvailability(t *testing.T) {
|
||||
for _, drives := range []int{4, 16} {
|
||||
for _, offline := range []int{0, 1, drives / 2} {
|
||||
t.Run(fmt.Sprintf("drives=%d/offline=%d", drives, offline), func(t *testing.T) {
|
||||
obj, dirs, err := prepareErasure(t.Context(), drives)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
t.Cleanup(func() { z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||
setMultipartListingTestMode(t, true)
|
||||
previous := globalStorageClass
|
||||
globalStorageClass.Update(storageclass.Config{Standard: storageclass.StorageClass{Parity: drives / 2}})
|
||||
t.Cleanup(func() { globalStorageClass.Update(previous) })
|
||||
const bucket, key = "multipart-legacy-availability", "keep-available"
|
||||
if err := z.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, key, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set := z.serverPools[0].getHashedSet(key)
|
||||
original := set.getDisks
|
||||
disks := original()
|
||||
set.getDisks = func() []StorageAPI {
|
||||
visible := append([]StorageAPI(nil), disks...)
|
||||
clear(visible[:offline])
|
||||
return visible
|
||||
}
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, key, mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("released read-quorum availability regressed: %v", err)
|
||||
}
|
||||
for _, disk := range disks[offline:] {
|
||||
_, err := disk.ReadVersion(t.Context(), bucket, minioMetaMultipartBucket, set.getUploadIDDir(bucket, key, mp.UploadID), "", ReadOptions{})
|
||||
if !errors.Is(err, errFileNotFound) {
|
||||
t.Fatalf("online replica not cleaned: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortLegacyPoolOrder(t *testing.T) {
|
||||
for _, owner := range []int{0, 1} {
|
||||
t.Run(fmt.Sprintf("owner=%d", owner), func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
setMultipartListingTestMode(t, true)
|
||||
mp, err := z.serverPools[owner].NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emptySet := z.serverPools[1-owner].getHashedSet("a")
|
||||
original := emptySet.getDisks
|
||||
t.Cleanup(func() { emptySet.getDisks = original })
|
||||
emptySet.getDisks = func() []StorageAPI { return make([]StorageAPI, emptySet.setDriveCount) }
|
||||
err = z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{})
|
||||
if owner == 0 && err != nil {
|
||||
t.Fatalf("unrelated later pool blocked legacy cancellation: %v", err)
|
||||
}
|
||||
if owner == 1 {
|
||||
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
||||
t.Fatalf("unknown earlier pool must retain released error behavior: %v", err)
|
||||
}
|
||||
if _, err := z.serverPools[owner].GetMultipartInfo(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("later pool visited despite earlier error: %v", err)
|
||||
}
|
||||
emptySet.getDisks = original
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortMinorityLegacyCleanup(t *testing.T) {
|
||||
for _, failDelete := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("delete-fails=%v", failDelete), func(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "old", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := set.getUploadIDDir(bucket, "old", mp.UploadID)
|
||||
original := set.getDisks
|
||||
disks := original()
|
||||
fi, err := disks[0].ReadVersion(t.Context(), bucket, minioMetaMultipartBucket, path, "", ReadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(fi.Metadata, multipartMetaBucket)
|
||||
delete(fi.Metadata, multipartMetaObject)
|
||||
if err := disks[0].WriteMetadata(t.Context(), bucket, minioMetaMultipartBucket, path, fi); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range disks[1:] {
|
||||
if err := d.Delete(t.Context(), minioMetaMultipartBucket, path, DeleteOptions{Recursive: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
report, err := z.multipartPreflight(t.Context())
|
||||
if err != nil || report.Ready || report.LegacyUploads != 1 {
|
||||
t.Fatalf("invalid legacy fixture: %+v %v", report, err)
|
||||
}
|
||||
if failDelete {
|
||||
set.getDisks = func() []StorageAPI {
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
wrapped[0] = multipartListingFaultDisk{StorageAPI: disks[0], delete: func(context.Context, string, string, DeleteOptions) error { return errFaultyDisk }}
|
||||
return wrapped
|
||||
}
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
err := z.AbortMultipartUpload(t.Context(), bucket, "old", mp.UploadID, ObjectOptions{})
|
||||
if err == nil || toAPIError(t.Context(), err).HTTPStatusCode != 503 {
|
||||
t.Fatalf("empty drives masked failed deletion of the observed replica: %v", err)
|
||||
}
|
||||
if _, present := z.mpCache.Load(mp.UploadID); !present {
|
||||
t.Fatal("failed cancellation evicted upload from cache")
|
||||
}
|
||||
set.getDisks = original
|
||||
}
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "old", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err = z.multipartPreflight(t.Context())
|
||||
if err != nil || !report.Ready || report.LegacyUploads != 0 {
|
||||
t.Fatalf("acknowledged cleanup left online legacy remnants: %+v %v", report, err)
|
||||
}
|
||||
if _, present := z.mpCache.Load(mp.UploadID); present {
|
||||
t.Fatal("successful cancellation retained cache entry")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortWrongTargetKeepsCache(t *testing.T) {
|
||||
for _, legacy := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("legacy=%v", legacy), func(t *testing.T) {
|
||||
z, _, bucket := multipartListingFixture(t)
|
||||
setMultipartListingTestMode(t, legacy)
|
||||
const other = "multipart-other-bucket"
|
||||
if err := z.MakeBucket(t.Context(), other, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "valid-key", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, target := range [][2]string{{bucket, "wrong-key"}, {other, "valid-key"}} {
|
||||
err := z.AbortMultipartUpload(t.Context(), target[0], target[1], mp.UploadID, ObjectOptions{})
|
||||
var invalid InvalidUploadID
|
||||
if !errors.As(err, &invalid) {
|
||||
t.Fatalf("wrong target result: %v", err)
|
||||
}
|
||||
if _, present := z.mpCache.Load(mp.UploadID); !present {
|
||||
t.Fatal("wrong-target abort evicted another upload")
|
||||
}
|
||||
if _, err := z.GetMultipartInfo(t.Context(), bucket, "valid-key", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("wrong-target abort harmed valid upload: %v", err)
|
||||
}
|
||||
}
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "valid-key", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var invalid InvalidUploadID
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "valid-key", mp.UploadID, ObjectOptions{}); !errors.As(err, &invalid) {
|
||||
t.Fatalf("repeat abort: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortRejectsUnsafeID(t *testing.T) {
|
||||
for _, legacy := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("legacy=%v", legacy), func(t *testing.T) {
|
||||
z, _, bucket := multipartListingFixture(t)
|
||||
setMultipartListingTestMode(t, legacy)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "keep", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, suffix := range []string{".", "..", "../other", "a/b", "a\\b", ""} {
|
||||
id := base64.RawURLEncoding.EncodeToString([]byte("deployment." + suffix))
|
||||
err := z.AbortMultipartUpload(t.Context(), bucket, "keep", id, ObjectOptions{})
|
||||
var invalid InvalidUploadID
|
||||
if !errors.As(err, &invalid) {
|
||||
t.Fatalf("unsafe ID accepted: suffix=%q err=%v", suffix, err)
|
||||
}
|
||||
if _, err := z.GetMultipartInfo(t.Context(), bucket, "keep", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("valid upload harmed by rejected ID: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortPeerNotification(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
tg, err := grid.SetupTestGrid(2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(tg.Cleanup)
|
||||
var notifications atomic.Int32
|
||||
if err := cleanupUploadIDCacheMetaRPC.Register(tg.Managers[1], func(*grid.MSS) (grid.NoPayload, *grid.RemoteErr) {
|
||||
notifications.Add(1)
|
||||
return grid.NoPayload{}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, err := xnet.ParseHost(strings.TrimPrefix(tg.Hosts[1], "http://"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := globalNotificationSys
|
||||
globalNotificationSys = &NotificationSys{peerClients: []*peerRESTClient{{
|
||||
host: host,
|
||||
gridConn: func() *grid.Connection { return tg.Managers[0].Connection(tg.Hosts[1]) },
|
||||
}}}
|
||||
t.Cleanup(func() { globalNotificationSys = previous })
|
||||
// Use the real distributed lock implementation: Unlock cancels its derived
|
||||
// context. Local locks do not, and would hide a broken notification context.
|
||||
oldMutex, oldLockers := set.nsMutex, set.getLockers
|
||||
lockers := []dsync.NetLocker{newLocker(), newLocker()}
|
||||
set.nsMutex = newNSLock(true)
|
||||
set.getLockers = func() ([]dsync.NetLocker, string) { return lockers, "multipart-test" }
|
||||
t.Cleanup(func() { set.nsMutex, set.getLockers = oldMutex, oldLockers })
|
||||
for _, legacy := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("legacy=%v", legacy), func(t *testing.T) {
|
||||
setMultipartListingTestMode(t, legacy)
|
||||
for range 4 {
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "valid", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := notifications.Load()
|
||||
var invalid InvalidUploadID
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "wrong", mp.UploadID, ObjectOptions{}); !errors.As(err, &invalid) {
|
||||
t.Fatalf("wrong target: %v", err)
|
||||
}
|
||||
if notifications.Load() != before {
|
||||
t.Fatal("wrong-target abort sent a destructive peer notification")
|
||||
}
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "valid", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notifications.Load() != before+1 {
|
||||
t.Fatal("successful abort lost peer notification after unlocking")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartAbortStrictMajority(t *testing.T) {
|
||||
z, set, bucket := multipartListingFixture(t)
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, "a", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := set.getDisks
|
||||
disks := original()
|
||||
set.getDisks = func() []StorageAPI {
|
||||
wrapped := append([]StorageAPI(nil), disks...)
|
||||
wrapped[0] = multipartListingFaultDisk{StorageAPI: disks[0], delete: func(context.Context, string, string, DeleteOptions) error { return errFaultyDisk }}
|
||||
return wrapped
|
||||
}
|
||||
t.Cleanup(func() { set.getDisks = original })
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("ordinary majority cancellation was tightened: %v", err)
|
||||
}
|
||||
// One failed deletion is tolerated in the ordinary majority path. Retrying
|
||||
// after recovery must also clean the now-minority remnant.
|
||||
if _, err := disks[0].ReadVersion(t.Context(), bucket, minioMetaMultipartBucket, set.getUploadIDDir(bucket, "a", mp.UploadID), "", ReadOptions{}); err != nil {
|
||||
t.Fatalf("fault fixture did not leave a remnant: %v", err)
|
||||
}
|
||||
set.getDisks = original
|
||||
if err := z.AbortMultipartUpload(t.Context(), bucket, "a", mp.UploadID, ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
+271
-35
@@ -31,6 +31,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/klauspost/readahead"
|
||||
"github.com/minio/minio-go/v7/pkg/set"
|
||||
"github.com/minio/minio/internal/config/storageclass"
|
||||
@@ -44,6 +45,15 @@ import (
|
||||
"github.com/pgsty/silo-pkg/v3/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
multipartMetaBucket = ReservedMetadataPrefixLower + "multipart-v1-bucket"
|
||||
multipartMetaObject = ReservedMetadataPrefixLower + "multipart-v1-object"
|
||||
|
||||
// ponytail: keep scan concurrency fixed until the multipart-list benchmark
|
||||
// establishes a better adaptive limit.
|
||||
multipartMetadataScanConcurrency = 4
|
||||
)
|
||||
|
||||
func (er erasureObjects) getUploadIDDir(bucket, object, uploadID string) string {
|
||||
uploadUUID := uploadID
|
||||
uploadBytes, err := base64.RawURLEncoding.DecodeString(uploadID)
|
||||
@@ -251,14 +261,152 @@ func (er erasureObjects) cleanupStaleUploadsOnDisk(ctx context.Context, disk Sto
|
||||
})
|
||||
}
|
||||
|
||||
// ListMultipartUploads - lists all the pending multipart
|
||||
// uploads for a particular object in a bucket.
|
||||
//
|
||||
// Implements minimal S3 compatible ListMultipartUploads API. We do
|
||||
// not support prefix based listing, this is a deliberate attempt
|
||||
// towards simplification of multipart APIs.
|
||||
// The resulting ListMultipartsInfo structure is unmarshalled directly as XML.
|
||||
func (er erasureObjects) ListMultipartUploads(ctx context.Context, bucket, object, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (result ListMultipartsInfo, err error) {
|
||||
func multipartUploadInfo(bucket, object, uploadUUID string, fallback time.Time) MultipartInfo {
|
||||
initiated := fallback
|
||||
if parsed, ok := multipartUploadTime(uploadUUID); ok {
|
||||
initiated = parsed
|
||||
}
|
||||
return MultipartInfo{
|
||||
Bucket: bucket,
|
||||
Object: object,
|
||||
UploadID: base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s.%s", globalDeploymentID(), uploadUUID)),
|
||||
Initiated: initiated,
|
||||
}
|
||||
}
|
||||
|
||||
// multipartUploadTime is shared by stored records and continuation markers.
|
||||
// The time is part of the immutable upload ID, so a removed marker still
|
||||
// identifies the same ordering boundary.
|
||||
func multipartUploadTime(uploadUUID string) (time.Time, bool) {
|
||||
if len(uploadUUID) < 38 || uploadUUID[36] != 'x' {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if _, err := uuid.Parse(uploadUUID[:36]); err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ns, err := strconv.ParseInt(uploadUUID[37:], 10, 64)
|
||||
if err != nil || ns <= 0 || strconv.FormatInt(ns, 10) != uploadUUID[37:] {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(0, ns), true
|
||||
}
|
||||
|
||||
func multipartMarkerTime(uploadID string) (time.Time, bool) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(uploadID)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
_, uploadUUID, ok := strings.Cut(string(b), ".")
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return multipartUploadTime(uploadUUID)
|
||||
}
|
||||
|
||||
type multipartListEntry struct {
|
||||
upload *MultipartInfo
|
||||
commonPrefix string
|
||||
}
|
||||
|
||||
// paginateMultipartUploads applies the S3 ordering, prefix, delimiter, marker,
|
||||
// and page rules exactly once after all pools and sets have been merged.
|
||||
func paginateMultipartUploads(uploads []MultipartInfo, prefix, keyMarker, uploadIDMarker, delimiter string, maxUploads int) ListMultipartsInfo {
|
||||
if maxUploads > maxUploadsList {
|
||||
maxUploads = maxUploadsList
|
||||
}
|
||||
result := ListMultipartsInfo{
|
||||
MaxUploads: maxUploads,
|
||||
KeyMarker: keyMarker,
|
||||
UploadIDMarker: uploadIDMarker,
|
||||
Prefix: prefix,
|
||||
Delimiter: delimiter,
|
||||
}
|
||||
|
||||
deduplicated := make([]MultipartInfo, 0, len(uploads))
|
||||
seenUploads := make(map[string]struct{}, len(uploads))
|
||||
for _, upload := range uploads {
|
||||
identity := upload.Bucket + "\x00" + upload.Object + "\x00" + upload.UploadID
|
||||
if _, ok := seenUploads[identity]; ok {
|
||||
continue
|
||||
}
|
||||
seenUploads[identity] = struct{}{}
|
||||
deduplicated = append(deduplicated, upload)
|
||||
}
|
||||
sort.Slice(deduplicated, func(i, j int) bool {
|
||||
if deduplicated[i].Object != deduplicated[j].Object {
|
||||
return deduplicated[i].Object < deduplicated[j].Object
|
||||
}
|
||||
if !deduplicated[i].Initiated.Equal(deduplicated[j].Initiated) {
|
||||
return deduplicated[i].Initiated.Before(deduplicated[j].Initiated)
|
||||
}
|
||||
return deduplicated[i].UploadID < deduplicated[j].UploadID
|
||||
})
|
||||
|
||||
markerTime, _ := multipartMarkerTime(uploadIDMarker)
|
||||
seenPrefixes := make(map[string]struct{})
|
||||
entries := make([]multipartListEntry, 0, len(deduplicated))
|
||||
for i := range deduplicated {
|
||||
upload := &deduplicated[i]
|
||||
if !strings.HasPrefix(upload.Object, prefix) {
|
||||
continue
|
||||
}
|
||||
if keyMarker != "" {
|
||||
switch strings.Compare(upload.Object, keyMarker) {
|
||||
case -1:
|
||||
continue
|
||||
case 0:
|
||||
if uploadIDMarker == "" || upload.Initiated.Before(markerTime) ||
|
||||
(upload.Initiated.Equal(markerTime) && upload.UploadID <= uploadIDMarker) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if delimiter != "" {
|
||||
remainder := strings.TrimPrefix(upload.Object, prefix)
|
||||
if i := strings.Index(remainder, delimiter); i >= 0 {
|
||||
commonPrefix := prefix + remainder[:i+len(delimiter)]
|
||||
if keyMarker != "" && commonPrefix <= keyMarker {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenPrefixes[commonPrefix]; ok {
|
||||
continue
|
||||
}
|
||||
seenPrefixes[commonPrefix] = struct{}{}
|
||||
entries = append(entries, multipartListEntry{commonPrefix: commonPrefix})
|
||||
continue
|
||||
}
|
||||
}
|
||||
entries = append(entries, multipartListEntry{upload: upload})
|
||||
}
|
||||
|
||||
if maxUploads <= 0 {
|
||||
return result
|
||||
}
|
||||
pageSize := min(maxUploads, len(entries))
|
||||
for _, entry := range entries[:pageSize] {
|
||||
if entry.upload != nil {
|
||||
result.Uploads = append(result.Uploads, *entry.upload)
|
||||
continue
|
||||
}
|
||||
result.CommonPrefixes = append(result.CommonPrefixes, entry.commonPrefix)
|
||||
}
|
||||
result.IsTruncated = pageSize < len(entries)
|
||||
if result.IsTruncated && pageSize > 0 {
|
||||
last := entries[pageSize-1]
|
||||
if last.upload != nil {
|
||||
result.NextKeyMarker = last.upload.Object
|
||||
result.NextUploadIDMarker = last.upload.UploadID
|
||||
} else {
|
||||
result.NextKeyMarker = last.commonPrefix
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// listMultipartUploadsExact preserves the hashed exact-object lookup used by
|
||||
// multipart write placement and by rolling-upgrade legacy mode.
|
||||
func (er erasureObjects) listMultipartUploadsExact(ctx context.Context, bucket, object, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (result ListMultipartsInfo, err error) {
|
||||
auditObjectErasureSet(ctx, "ListMultipartUploads", object, &er)
|
||||
|
||||
result.MaxUploads = maxUploads
|
||||
@@ -311,20 +459,16 @@ func (er erasureObjects) ListMultipartUploads(ctx context.Context, bucket, objec
|
||||
if populatedUploadIDs.Contains(uploadID) {
|
||||
continue
|
||||
}
|
||||
// If present, use time stored in ID.
|
||||
startTime := time.Now()
|
||||
if split := strings.Split(uploadID, "x"); len(split) == 2 {
|
||||
t, err := strconv.ParseInt(split[1], 10, 64)
|
||||
if err == nil {
|
||||
startTime = time.Unix(0, t)
|
||||
var fallback time.Time
|
||||
if _, ok := multipartUploadTime(uploadID); !ok {
|
||||
fi, err := disk.ReadVersion(ctx, bucket, minioMetaMultipartBucket,
|
||||
pathJoin(er.getMultipartSHADir(bucket, object), uploadID), "", ReadOptions{})
|
||||
if err != nil {
|
||||
return result, toObjectErr(err, bucket, object)
|
||||
}
|
||||
fallback = fi.ModTime
|
||||
}
|
||||
uploads = append(uploads, MultipartInfo{
|
||||
Bucket: bucket,
|
||||
Object: object,
|
||||
UploadID: base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s.%s", globalDeploymentID(), uploadID)),
|
||||
Initiated: startTime,
|
||||
})
|
||||
uploads = append(uploads, multipartUploadInfo(bucket, object, uploadID, fallback))
|
||||
populatedUploadIDs.Add(uploadID)
|
||||
}
|
||||
|
||||
@@ -365,6 +509,25 @@ func (er erasureObjects) ListMultipartUploads(ctx context.Context, bucket, objec
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (er erasureObjects) ListMultipartUploads(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
|
||||
}
|
||||
scan, err := startMultipartScan(ctx, false)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
defer scan.close()
|
||||
uploads, legacy, err := er.scanMultipartUploads(scan, bucket, 0, 0)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
if legacy {
|
||||
return ListMultipartsInfo{}, errMultipartListingLegacy
|
||||
}
|
||||
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
|
||||
}
|
||||
|
||||
// newMultipartUpload - wrapper for initializing a new multipart
|
||||
// request; returns a unique upload id.
|
||||
//
|
||||
@@ -402,6 +565,8 @@ func (er erasureObjects) newMultipartUpload(ctx context.Context, bucket string,
|
||||
}
|
||||
|
||||
userDefined := cloneMSS(opts.UserDefined)
|
||||
userDefined[multipartMetaBucket] = bucket
|
||||
userDefined[multipartMetaObject] = object
|
||||
if opts.PreserveETag != "" {
|
||||
userDefined["etag"] = opts.PreserveETag
|
||||
}
|
||||
@@ -1459,6 +1624,8 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
||||
// Remove superfluous internal headers.
|
||||
delete(fi.Metadata, hash.MinIOMultipartChecksum)
|
||||
delete(fi.Metadata, hash.MinIOMultipartChecksumType)
|
||||
delete(fi.Metadata, multipartMetaBucket)
|
||||
delete(fi.Metadata, multipartMetaObject)
|
||||
|
||||
// Save the final object size and modtime.
|
||||
fi.Size = objectSize
|
||||
@@ -1586,22 +1753,91 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
||||
return fi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended), nil
|
||||
}
|
||||
|
||||
// AbortMultipartUpload - aborts an ongoing multipart operation
|
||||
// signified by the input uploadID. This is an atomic operation
|
||||
// doesn't require clients to initiate multiple such requests.
|
||||
//
|
||||
// All parts are purged from all disks and reference to the uploadID
|
||||
// would be removed from the system, rollback is not possible on this
|
||||
// operation.
|
||||
func (er erasureObjects) AbortMultipartUpload(ctx context.Context, bucket, object, uploadID string, opts ObjectOptions) (err error) {
|
||||
// abortMultipartUpload retains read-quorum validation and best-effort cleanup
|
||||
// in legacy mode. Strict mode requires majority deletion acknowledgements and
|
||||
// permits retrying remnants below read quorum. Neither mode fences creation
|
||||
// writes still executing after a storage timeout.
|
||||
func (er erasureObjects) abortMultipartUpload(ctx context.Context, bucket, object, uploadID string, opts ObjectOptions, legacy bool) (bool, error) {
|
||||
if !opts.NoAuditLog {
|
||||
auditObjectErasureSet(ctx, "AbortMultipartUpload", object, &er)
|
||||
}
|
||||
|
||||
// Cleanup all uploaded parts.
|
||||
defer er.deleteAll(ctx, minioMetaMultipartBucket, er.getUploadIDDir(bucket, object, uploadID))
|
||||
|
||||
// Validates if upload ID exists.
|
||||
_, _, err = er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
||||
return toObjectErr(err, bucket, object, uploadID)
|
||||
b, err := base64.RawURLEncoding.DecodeString(uploadID)
|
||||
if err != nil {
|
||||
return false, MalformedUploadID{UploadID: uploadID}
|
||||
}
|
||||
_, internalID, ok := strings.Cut(string(b), ".")
|
||||
if !ok || internalID == "" || internalID == "." || internalID == ".." || strings.ContainsAny(internalID, "/\\") {
|
||||
return false, InvalidUploadID{Bucket: bucket, Object: object, UploadID: uploadID}
|
||||
}
|
||||
if legacy {
|
||||
// Keep the released read-quorum and best-effort cleanup behavior.
|
||||
// The upload ID safety check above applies to both modes.
|
||||
defer er.deleteAll(ctx, minioMetaMultipartBucket, er.getUploadIDDir(bucket, object, uploadID))
|
||||
_, _, err := er.checkUploadIDExists(ctx, bucket, object, uploadID, false)
|
||||
err = toObjectErr(err, bucket, object, uploadID)
|
||||
if _, absent := err.(InvalidUploadID); absent {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
disks := er.getDisks()
|
||||
uploadPath := er.getUploadIDDir(bucket, object, uploadID)
|
||||
_, errs := readAllFileInfo(ctx, disks, bucket, minioMetaMultipartBucket, uploadPath, "", false, false)
|
||||
quorum := er.setDriveCount/2 + 1
|
||||
found, absent := false, 0
|
||||
observed := make([]bool, len(disks))
|
||||
for i, err := range errs {
|
||||
switch {
|
||||
case err == nil, errors.Is(err, errFileCorrupt):
|
||||
found = true
|
||||
observed[i] = true
|
||||
case errors.Is(err, errFileNotFound), errors.Is(err, errFileVersionNotFound):
|
||||
absent++
|
||||
}
|
||||
}
|
||||
if absent >= quorum && !found {
|
||||
return false, nil
|
||||
}
|
||||
if !found {
|
||||
return false, toObjectErr(errErasureReadQuorum, bucket, object, uploadID)
|
||||
}
|
||||
g := errgroup.WithNErrs(len(disks))
|
||||
for i, disk := range disks {
|
||||
g.Go(func() error {
|
||||
if disk == nil {
|
||||
return errDiskNotFound
|
||||
}
|
||||
err := disk.Delete(ctx, minioMetaMultipartBucket, uploadPath, DeleteOptions{Recursive: true, Immediate: false})
|
||||
if errors.Is(err, errFileNotFound) || errors.Is(err, errFileVersionNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}, i)
|
||||
}
|
||||
deleteErrs := g.Wait()
|
||||
if err := reduceWriteQuorumErrs(ctx, deleteErrs, nil, quorum); err != nil {
|
||||
return true, toObjectErr(err, bucket, object, uploadID)
|
||||
}
|
||||
if absent >= quorum {
|
||||
// Missing disks must not mask a failed cleanup of known remnants.
|
||||
for i, found := range observed {
|
||||
if found && deleteErrs[i] != nil {
|
||||
return true, toObjectErr(errErasureWriteQuorum, bucket, object, uploadID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AbortMultipartUpload cancels an upload using the configured mode. Offline
|
||||
// part data may still need stale-upload cleanup after its drives return.
|
||||
func (er erasureObjects) AbortMultipartUpload(ctx context.Context, bucket, object, uploadID string, opts ObjectOptions) (err error) {
|
||||
found, err := er.abortMultipartUpload(ctx, bucket, object, uploadID, opts, globalAPIConfig.getMultipartListingLegacy())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return InvalidUploadID{Bucket: bucket, Object: object, UploadID: uploadID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2327,7 +2327,11 @@ func (er erasureObjects) PutObjectTags(ctx context.Context, bucket, object strin
|
||||
|
||||
fi.Metadata[xhttp.AmzObjectTagging] = tags
|
||||
fi.ReplicationState = opts.PutReplicationState()
|
||||
stamp := monotonicTaggingTimestamp(opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp], fi.Metadata[ReservedMetadataPrefixLower+TaggingTimestamp])
|
||||
maps.Copy(fi.Metadata, opts.UserDefined)
|
||||
if stamp != "" {
|
||||
fi.Metadata[ReservedMetadataPrefixLower+TaggingTimestamp] = stamp
|
||||
}
|
||||
|
||||
if err = er.updateObjectMeta(ctx, bucket, object, fi, onlineDisks); err != nil {
|
||||
return ObjectInfo{}, toObjectErr(err, bucket, object)
|
||||
|
||||
@@ -242,6 +242,21 @@ func reconcileStoredObjectTags(metadata map[string]string, storedTags, storedTim
|
||||
}
|
||||
}
|
||||
|
||||
// Local tagging mutations must advance the revision they overwrite, even when
|
||||
// a request's clock or lock acquisition order is behind the stored revision.
|
||||
// Replica writes use reconcileStoredObjectTags instead of minting a revision.
|
||||
func monotonicTaggingTimestamp(incoming, stored string) string {
|
||||
requested, err := time.Parse(time.RFC3339Nano, incoming)
|
||||
if err != nil {
|
||||
return incoming
|
||||
}
|
||||
current, err := time.Parse(time.RFC3339Nano, stored)
|
||||
if err != nil || requested.After(current) {
|
||||
return incoming
|
||||
}
|
||||
return current.Add(time.Nanosecond).UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// A restored version still owns its tier reference even while IsRemote is
|
||||
// false. Only the last copy of a reference may schedule its contents for GC.
|
||||
func sharesTierObject(oi ObjectInfo, copies []PoolObjInfo) bool {
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
// 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"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
)
|
||||
|
||||
// Change allocation capacity only; metadata and data use real fixture disks.
|
||||
// Restoring the adapters also lets encrypted fixtures move the write target
|
||||
// between requests without changing the production allocation policy.
|
||||
type conditionalPutCapacityDisk struct {
|
||||
StorageAPI
|
||||
full bool
|
||||
}
|
||||
|
||||
func (d conditionalPutCapacityDisk) DiskInfo(ctx context.Context, opts DiskInfoOptions) (DiskInfo, error) {
|
||||
info, err := d.StorageAPI.DiskInfo(ctx, opts)
|
||||
info.Total, info.Used = 1<<40, 0
|
||||
if d.full {
|
||||
info.Used = info.Total - (1 << 20)
|
||||
}
|
||||
info.Free = info.Total - info.Used
|
||||
return info, err
|
||||
}
|
||||
|
||||
// Keep getDisks immutable while background IAM and storage readers use it.
|
||||
// GetDisks takes this same mutex when it copies the backing disk list.
|
||||
func conditionalPutSwapDisks(pool *erasureSets, object string, wrap func(StorageAPI) StorageAPI) func() {
|
||||
setIndex := pool.getHashedSet(object).setIndex
|
||||
pool.erasureDisksMu.Lock()
|
||||
previous := pool.erasureDisks[setIndex]
|
||||
disks := append([]StorageAPI(nil), previous...)
|
||||
for i, disk := range disks {
|
||||
disks[i] = wrap(disk)
|
||||
}
|
||||
pool.erasureDisks[setIndex] = disks
|
||||
pool.erasureDisksMu.Unlock()
|
||||
return func() {
|
||||
pool.erasureDisksMu.Lock()
|
||||
pool.erasureDisks[setIndex] = previous
|
||||
pool.erasureDisksMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func conditionalPutPool(t *testing.T, z *erasureServerPools, object string, target int) func() {
|
||||
t.Helper()
|
||||
var restore []func()
|
||||
for i, pool := range z.serverPools {
|
||||
restore = append(restore, conditionalPutSwapDisks(pool, object, func(disk StorageAPI) StorageAPI {
|
||||
return conditionalPutCapacityDisk{StorageAPI: disk, full: i != target}
|
||||
}))
|
||||
}
|
||||
return func() {
|
||||
for _, fn := range restore {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func conditionalPutBucket(t *testing.T, z *erasureServerPools, mode string) (string, http.Handler) {
|
||||
t.Helper()
|
||||
bucket, router, err := initAPIHandlerTest(t.Context(), z, nil, MakeBucketOptions{VersioningEnabled: mode != "unversioned"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode == "suspended" {
|
||||
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig,
|
||||
[]byte(`<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return bucket, router
|
||||
}
|
||||
|
||||
func TestPoolsConditionalPutHTTP(t *testing.T) {
|
||||
for _, mode := range []string{"unversioned", "versioned", "suspended"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router := conditionalPutBucket(t, z, mode)
|
||||
for target := range 2 {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
oldCopy, localCurrent bool
|
||||
condition string
|
||||
status int
|
||||
}{
|
||||
{"stale-etag-accepted", true, false, "old", http.StatusPreconditionFailed},
|
||||
{"current-etag-rejected", true, false, "current", http.StatusOK},
|
||||
{"create-only-overwrites-other-pool", false, false, "none", http.StatusPreconditionFailed},
|
||||
{"current-etag-missing-in-write-pool", false, false, "current", http.StatusOK},
|
||||
{"control-current-in-write-pool", true, true, "current", http.StatusOK},
|
||||
{"control-stale-etag-rejected", true, true, "old", http.StatusPreconditionFailed},
|
||||
{"none-match-current-etag", true, false, "none-current", http.StatusPreconditionFailed},
|
||||
{"none-match-old-etag", true, false, "none-old", http.StatusOK},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("target=%d/%s", target, tc.name), func(t *testing.T) {
|
||||
object := fmt.Sprintf("%d-%s", target, tc.name)
|
||||
currentPool := 1 - target
|
||||
if tc.localCurrent {
|
||||
currentPool = target
|
||||
}
|
||||
opts := ObjectOptions{Versioned: mode == "versioned", VersionSuspended: mode == "suspended", MTime: UTCNow().Add(-time.Hour)}
|
||||
oldETag := "absent-old"
|
||||
if tc.oldCopy {
|
||||
oldETag = putConsistencyObject(t, z, bucket, object, 1-currentPool, "old", opts).ETag
|
||||
}
|
||||
opts.MTime = UTCNow().Add(-time.Minute)
|
||||
current := putConsistencyObject(t, z, bucket, object, currentPool, "current", opts)
|
||||
defer conditionalPutPool(t, z, object, target)()
|
||||
idx, err := z.getWritePoolIdx(t.Context(), bucket, object, 11, false)
|
||||
if err != nil || idx != target {
|
||||
t.Fatalf("allocation target=%d: idx=%d err=%v", target, idx, err)
|
||||
}
|
||||
headers := map[string]string{xhttp.IfMatch: fmt.Sprintf("%q", current.ETag)}
|
||||
if tc.condition == "old" {
|
||||
headers[xhttp.IfMatch] = fmt.Sprintf("%q", oldETag)
|
||||
}
|
||||
if tc.condition == "none" {
|
||||
headers = map[string]string{xhttp.IfNoneMatch: "*"}
|
||||
}
|
||||
if tc.condition == "none-current" {
|
||||
headers = map[string]string{xhttp.IfNoneMatch: fmt.Sprintf("%q", current.ETag)}
|
||||
}
|
||||
if tc.condition == "none-old" {
|
||||
headers = map[string]string{xhttp.IfNoneMatch: fmt.Sprintf("%q", oldETag)}
|
||||
}
|
||||
url := getPutObjectURL("", bucket, object)
|
||||
before := multipartConditionRequest(t, router, http.MethodGet, url, "", nil)
|
||||
if before.Code != http.StatusOK || before.Body.String() != "current" || multipartConditionResponseETag(before) != current.ETag {
|
||||
t.Fatalf("invalid current object: %d %q %v", before.Code, before.Body.String(), before.Header())
|
||||
}
|
||||
put := multipartConditionRequest(t, router, http.MethodPut, url, "replacement", headers)
|
||||
if put.Code != tc.status {
|
||||
t.Errorf("PUT status=%d want=%d: %s", put.Code, tc.status, put.Body.String())
|
||||
}
|
||||
if put.Code == http.StatusPreconditionFailed {
|
||||
multipartConditionError(t, put, "PreconditionFailed")
|
||||
if multipartConditionResponseETag(put) != current.ETag || put.Header().Get(xhttp.LastModified) != before.Header().Get(xhttp.LastModified) {
|
||||
t.Errorf("412 headers do not describe current object: %v", put.Header())
|
||||
}
|
||||
}
|
||||
get := multipartConditionRequest(t, router, http.MethodGet, url, "", nil)
|
||||
wantBody, wantETag := "current", current.ETag
|
||||
if tc.status == http.StatusOK {
|
||||
wantBody, wantETag = "replacement", fmt.Sprintf("%x", md5.Sum([]byte("replacement")))
|
||||
}
|
||||
t.Logf("PUT %d; GET %d bytes=%q ETag=%s", put.Code, get.Code, get.Body.String(), multipartConditionResponseETag(get))
|
||||
if get.Code != http.StatusOK || get.Body.String() != wantBody || multipartConditionResponseETag(get) != wantETag {
|
||||
t.Errorf("GET=%d bytes=%q ETag=%s; want %q %s", get.Code, get.Body.String(), multipartConditionResponseETag(get), wantBody, wantETag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsConditionalPutHTTPAbsence(t *testing.T) {
|
||||
for _, state := range []string{"missing", "uuid-marker", "null-marker"} {
|
||||
for _, match := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/match=%t", state, match), func(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
mode := "versioned"
|
||||
if state == "null-marker" {
|
||||
mode = "suspended"
|
||||
}
|
||||
bucket, router := conditionalPutBucket(t, z, mode)
|
||||
object := "absent-key"
|
||||
if state != "missing" {
|
||||
putConsistencyObject(t, z, bucket, object, 0, "old", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour)})
|
||||
vid := mustGetUUID()
|
||||
if state == "null-marker" {
|
||||
vid = nullVersionID
|
||||
}
|
||||
if _, err := z.serverPools[1].DeleteObject(t.Context(), bucket, object, ObjectOptions{Versioned: true, VersionID: vid, DeleteMarker: true, MTime: UTCNow().Add(-time.Minute)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
defer conditionalPutPool(t, z, object, 0)()
|
||||
headers := map[string]string{xhttp.IfNoneMatch: "*"}
|
||||
want := http.StatusOK
|
||||
if match {
|
||||
headers = map[string]string{xhttp.IfMatch: "*"}
|
||||
want = http.StatusNotFound
|
||||
}
|
||||
url := getPutObjectURL("", bucket, object)
|
||||
put := multipartConditionRequest(t, router, http.MethodPut, url, "new", headers)
|
||||
if put.Code != want {
|
||||
t.Fatalf("PUT %d want %d: %s", put.Code, want, put.Body.String())
|
||||
}
|
||||
get := multipartConditionRequest(t, router, http.MethodGet, url, "", nil)
|
||||
if match {
|
||||
multipartConditionError(t, put, "NoSuchKey")
|
||||
if get.Code != http.StatusNotFound {
|
||||
t.Fatalf("failed PUT exposed data: %d %s", get.Code, get.Body.String())
|
||||
}
|
||||
} else if get.Code != http.StatusOK || get.Body.String() != "new" || multipartConditionResponseETag(get) != multipartConditionResponseETag(put) {
|
||||
t.Fatalf("successful create GET: %d %q %v", get.Code, get.Body.String(), get.Header())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsConditionalPutUnreadable(t *testing.T) {
|
||||
for faultPool := range 2 {
|
||||
for _, present := range []bool{false, true} {
|
||||
for _, match := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("fault-pool=%d/present=%t/match=%t", faultPool, present, match), func(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router := conditionalPutBucket(t, z, "unversioned")
|
||||
object := "unreadable-key"
|
||||
if present {
|
||||
putConsistencyObject(t, z, bucket, object, 0, "old", ObjectOptions{MTime: UTCNow().Add(-time.Hour)})
|
||||
putConsistencyObject(t, z, bucket, object, 1, "current", ObjectOptions{MTime: UTCNow().Add(-time.Minute)})
|
||||
}
|
||||
defer conditionalPutPool(t, z, object, 0)()
|
||||
restoreFault := conditionalPutSwapDisks(z.serverPools[faultPool], object, func(disk StorageAPI) StorageAPI {
|
||||
return consistencyReadFaultDisk{StorageAPI: disk, bucket: bucket, object: object}
|
||||
})
|
||||
defer restoreFault()
|
||||
headers := map[string]string{xhttp.IfNoneMatch: "*"}
|
||||
if match {
|
||||
headers = map[string]string{xhttp.IfMatch: "*"}
|
||||
}
|
||||
url := getPutObjectURL("", bucket, object)
|
||||
put := multipartConditionRequest(t, router, http.MethodPut, url, "replacement", headers)
|
||||
if put.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("unverified PUT must fail: %d %s", put.Code, put.Body.String())
|
||||
}
|
||||
called := 0
|
||||
_, err := z.PutObject(t.Context(), bucket, object, mustGetPutObjReader(t, strings.NewReader("replacement"), 11, "", ""), ObjectOptions{HasIfMatch: match, CheckPrecondFn: func(ObjectInfo) bool { called++; return false }})
|
||||
if !isErrReadQuorum(err) || called != 0 {
|
||||
t.Errorf("lookup error=%v callback calls=%d", err, called)
|
||||
}
|
||||
restoreFault()
|
||||
get := multipartConditionRequest(t, router, http.MethodGet, url, "", nil)
|
||||
if present {
|
||||
if get.Code != http.StatusOK || get.Body.String() != "current" || multipartConditionResponseETag(get) != fmt.Sprintf("%x", md5.Sum([]byte("current"))) {
|
||||
t.Fatalf("failed PUT changed object: %d %q", get.Code, get.Body.String())
|
||||
}
|
||||
} else if get.Code != http.StatusNotFound {
|
||||
t.Fatalf("failed PUT created object: %d %q", get.Code, get.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsConditionalPutEncryptedETag(t *testing.T) {
|
||||
for _, kind := range []string{"SSE-C", "SSE-S3", "SSE-KMS"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router := conditionalPutBucket(t, z, "unversioned")
|
||||
oldKMS, oldTLS := GlobalKMS, globalIsTLS
|
||||
GlobalKMS, globalIsTLS = kms.NewStub("conditional-put-key"), true
|
||||
defer func() { GlobalKMS, globalIsTLS = oldKMS, oldTLS }()
|
||||
headers := map[string]string{xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionAES}
|
||||
readHeaders := map[string]string{}
|
||||
if kind == "SSE-C" {
|
||||
key := bytes.Repeat([]byte{0x42}, 32)
|
||||
digest := md5.Sum(key)
|
||||
headers = map[string]string{
|
||||
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
|
||||
xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key),
|
||||
xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(digest[:]),
|
||||
}
|
||||
readHeaders = maps.Clone(headers)
|
||||
}
|
||||
if kind == "SSE-KMS" {
|
||||
headers[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionKMS
|
||||
headers[xhttp.AmzServerSideEncryptionKmsID] = "conditional-put-key"
|
||||
}
|
||||
object := "encrypted-key"
|
||||
url := getPutObjectURL("", bucket, object)
|
||||
restore := conditionalPutPool(t, z, object, 0)
|
||||
old := multipartConditionRequest(t, router, http.MethodPut, url, "old", headers)
|
||||
restore()
|
||||
restore = conditionalPutPool(t, z, object, 1)
|
||||
current := multipartConditionRequest(t, router, http.MethodPut, url, "current", headers)
|
||||
restore()
|
||||
if old.Code != http.StatusOK || current.Code != http.StatusOK {
|
||||
t.Fatalf("encrypted setup: %d %s / %d %s", old.Code, old.Body.String(), current.Code, current.Body.String())
|
||||
}
|
||||
defer conditionalPutPool(t, z, object, 0)()
|
||||
for _, stale := range []bool{true, false} {
|
||||
h := maps.Clone(headers)
|
||||
h[xhttp.IfMatch] = fmt.Sprintf("%q", multipartConditionResponseETag(current))
|
||||
wantStatus, wantBody, wantETag := http.StatusOK, "replacement", ""
|
||||
if stale {
|
||||
h[xhttp.IfMatch] = fmt.Sprintf("%q", multipartConditionResponseETag(old))
|
||||
wantStatus, wantBody, wantETag = http.StatusPreconditionFailed, "current", multipartConditionResponseETag(current)
|
||||
}
|
||||
put := multipartConditionRequest(t, router, http.MethodPut, url, "replacement", h)
|
||||
if put.Code != wantStatus {
|
||||
t.Fatalf("encrypted condition: %d want %d: %s", put.Code, wantStatus, put.Body.String())
|
||||
}
|
||||
if !stale {
|
||||
wantETag = multipartConditionResponseETag(put)
|
||||
}
|
||||
get := multipartConditionRequest(t, router, http.MethodGet, url, "", readHeaders)
|
||||
if get.Code != http.StatusOK || get.Body.String() != wantBody || multipartConditionResponseETag(get) != wantETag {
|
||||
t.Fatalf("encrypted GET: %d %q %v", get.Code, get.Body.String(), get.Header())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsConditionalPutVersionSelection(t *testing.T) {
|
||||
for _, kind := range []string{"public-version", "replica", "replica-preserve-etag", "movement", "no-lock", "tie", "draining"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
object := "version-selection"
|
||||
addressed := putConsistencyObject(t, z, bucket, object, 1, "addressed", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour)})
|
||||
current := putConsistencyObject(t, z, bucket, object, 1, "current", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Minute)})
|
||||
opts := ObjectOptions{Versioned: true, VersionID: addressed.VersionID, HasIfMatch: true}
|
||||
want := current
|
||||
switch kind {
|
||||
case "replica":
|
||||
opts.ReplicaLockReconcile, opts.ReplicationRequest = true, true
|
||||
want = addressed
|
||||
case "replica-preserve-etag":
|
||||
opts.PreserveETag = addressed.ETag
|
||||
opts.ReplicaLockReconcile, opts.ReplicationRequest = true, true
|
||||
want = addressed
|
||||
case "movement":
|
||||
opts.DataMovement, opts.SrcPoolIdx = true, 1
|
||||
want = addressed
|
||||
case "tie":
|
||||
want = putConsistencyObject(t, z, bucket, object, 0, "tie-winner", ObjectOptions{Versioned: true, MTime: current.ModTime})
|
||||
case "draining":
|
||||
z.poolMetaMutex.Lock()
|
||||
z.poolMeta.Pools[1].Decommission = &PoolDecommissionInfo{}
|
||||
z.poolMetaMutex.Unlock()
|
||||
}
|
||||
defer conditionalPutPool(t, z, object, 0)()
|
||||
ctx := t.Context()
|
||||
if kind == "no-lock" {
|
||||
lk := z.NewNSLock(bucket, object)
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer lk.Unlock(lkctx)
|
||||
ctx, opts.NoLock = lkctx.Context(), true
|
||||
}
|
||||
called := 0
|
||||
opts.UserDefined = make(map[string]string)
|
||||
opts.CheckPrecondFn = func(oi ObjectInfo) bool {
|
||||
called++
|
||||
if oi.ETag != want.ETag || oi.VersionID != want.VersionID {
|
||||
t.Errorf("comparison ETag/version=%s/%s want %s/%s", oi.ETag, oi.VersionID, want.ETag, want.VersionID)
|
||||
}
|
||||
return oi.ETag != want.ETag
|
||||
}
|
||||
oi, err := z.PutObject(ctx, bucket, object, mustGetPutObjReader(t, strings.NewReader("replacement"), 11, "", ""), opts)
|
||||
if err != nil || called != 1 {
|
||||
t.Fatalf("PUT err=%v callback calls=%d", err, called)
|
||||
}
|
||||
if oi.VersionID != addressed.VersionID {
|
||||
t.Fatalf("destination version changed: %s", oi.VersionID)
|
||||
}
|
||||
if opts.PreserveETag != "" && oi.ETag != opts.PreserveETag {
|
||||
t.Fatalf("PreserveETag changed: %s", oi.ETag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsConditionalPutReplicaDuplicateHTTP(t *testing.T) {
|
||||
for _, null := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("null=%t", null), func(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router := conditionalPutBucket(t, z, "versioned")
|
||||
object := "replica-duplicate"
|
||||
vid := mustGetUUID()
|
||||
if null {
|
||||
vid = nullVersionID
|
||||
}
|
||||
addressed := putConsistencyObject(t, z, bucket, object, 1, "addressed", ObjectOptions{Versioned: true, VersionID: vid, MTime: UTCNow().Add(-time.Hour)})
|
||||
current := putConsistencyObject(t, z, bucket, object, 1, "current", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Minute)})
|
||||
defer conditionalPutPool(t, z, object, 0)()
|
||||
headers := map[string]string{
|
||||
xhttp.MinIOSourceReplicationRequest: "true",
|
||||
xhttp.AmzBucketReplicationStatus: "REPLICA",
|
||||
xhttp.MinIOSourceETag: addressed.ETag,
|
||||
xhttp.MinIOSourceMTime: addressed.ModTime.Format(time.RFC3339Nano),
|
||||
}
|
||||
url := getPutObjectURL("", bucket, object)
|
||||
put := multipartConditionRequest(t, router, http.MethodPut, url+"?versionId="+vid, "addressed", headers)
|
||||
if put.Code != http.StatusPreconditionFailed {
|
||||
t.Fatalf("replica duplicate: %d %s", put.Code, put.Body.String())
|
||||
}
|
||||
multipartConditionError(t, put, "PreconditionFailed")
|
||||
get := multipartConditionRequest(t, router, http.MethodGet, url, "", nil)
|
||||
if get.Code != http.StatusOK || get.Body.String() != "current" || multipartConditionResponseETag(get) != current.ETag {
|
||||
t.Fatalf("duplicate changed current object: %d %q", get.Code, get.Body.String())
|
||||
}
|
||||
get = multipartConditionRequest(t, router, http.MethodGet, url+"?versionId="+vid, "", nil)
|
||||
if get.Code != http.StatusOK || get.Body.String() != "addressed" || multipartConditionResponseETag(get) != addressed.ETag {
|
||||
t.Fatalf("duplicate changed addressed version: %d %q", get.Code, get.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsConditionalPutConcurrentHTTP(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router := conditionalPutBucket(t, z, "unversioned")
|
||||
for _, match := range []bool{false, true} {
|
||||
for iteration := range 5 {
|
||||
t.Run(fmt.Sprintf("match=%t/iteration=%d", match, iteration), func(t *testing.T) {
|
||||
object := fmt.Sprintf("concurrent-%t-%d", match, iteration)
|
||||
headers := map[string]string{xhttp.IfNoneMatch: "*"}
|
||||
if match {
|
||||
oi := putConsistencyObject(t, z, bucket, object, 1, "old", ObjectOptions{MTime: UTCNow().Add(-time.Minute)})
|
||||
headers = map[string]string{xhttp.IfMatch: fmt.Sprintf("%q", oi.ETag)}
|
||||
}
|
||||
defer conditionalPutPool(t, z, object, 0)()
|
||||
start := make(chan struct{})
|
||||
results := make(chan *httptest.ResponseRecorder, 2)
|
||||
url := getPutObjectURL("", bucket, object)
|
||||
for i := range 2 {
|
||||
body := fmt.Sprintf("writer-%d", i)
|
||||
req, err := newTestSignedRequestV4(http.MethodPut, url, int64(len(body)), strings.NewReader(body), globalActiveCred.AccessKey, globalActiveCred.SecretKey, headers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go func() {
|
||||
<-start
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
results <- rec
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
success, failed, winnerETag := 0, 0, ""
|
||||
for range 2 {
|
||||
result := <-results
|
||||
switch result.Code {
|
||||
case http.StatusOK:
|
||||
success++
|
||||
winnerETag = multipartConditionResponseETag(result)
|
||||
case http.StatusPreconditionFailed:
|
||||
failed++
|
||||
multipartConditionError(t, result, "PreconditionFailed")
|
||||
default:
|
||||
t.Errorf("unexpected PUT %d: %s", result.Code, result.Body.String())
|
||||
}
|
||||
}
|
||||
if success != 1 || failed != 1 {
|
||||
t.Fatalf("success=%d precondition failures=%d", success, failed)
|
||||
}
|
||||
get := multipartConditionRequest(t, router, http.MethodGet, url, "", nil)
|
||||
if get.Code != http.StatusOK || multipartConditionResponseETag(get) != winnerETag || fmt.Sprintf("%x", md5.Sum(get.Body.Bytes())) != winnerETag {
|
||||
t.Fatalf("winner lost: %d %q %v", get.Code, get.Body.String(), get.Header())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsConditionalPutSerializesMutation(t *testing.T) {
|
||||
for _, deletion := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("delete=%t", deletion), func(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
object := "conditional-mutation"
|
||||
current := putConsistencyObject(t, z, bucket, object, 1, "current", ObjectOptions{MTime: UTCNow().Add(-time.Minute)})
|
||||
defer conditionalPutPool(t, z, object, 0)()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
gate := &consistencyGateReader{Reader: strings.NewReader("replacement"), entered: make(chan struct{}), resume: make(chan struct{})}
|
||||
release := func() { gate.release.Do(func() { close(gate.resume) }) }
|
||||
defer release()
|
||||
reader := mustGetPutObjReader(t, gate, 11, "", "")
|
||||
written := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := z.PutObject(ctx, bucket, object, reader, ObjectOptions{HasIfMatch: true, CheckPrecondFn: func(oi ObjectInfo) bool { return oi.ETag != current.ETag }})
|
||||
written <- err
|
||||
}()
|
||||
select {
|
||||
case <-gate.entered:
|
||||
case err := <-written:
|
||||
t.Fatalf("PUT failed before body read: %v", err)
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
mutated := make(chan error, 1)
|
||||
go func() {
|
||||
var err error
|
||||
if deletion {
|
||||
_, err = z.DeleteObject(ctx, bucket, object, ObjectOptions{})
|
||||
} else {
|
||||
_, err = z.PutObjectMetadata(ctx, bucket, object, ObjectOptions{EvalMetadataFn: func(oi *ObjectInfo, _ error) (ReplicateDecision, error) {
|
||||
oi.UserDefined["x-amz-meta-after-put"] = "present"
|
||||
return ReplicateDecision{}, nil
|
||||
}})
|
||||
}
|
||||
mutated <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-mutated:
|
||||
t.Fatalf("mutation escaped PUT lock: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
if err := <-written; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-mutated; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oi, err := z.GetObjectInfo(ctx, bucket, object, ObjectOptions{})
|
||||
if deletion {
|
||||
if !isErrObjectNotFound(err) {
|
||||
t.Fatalf("delete lost: %v", err)
|
||||
}
|
||||
} else if err != nil || oi.UserDefined["x-amz-meta-after-put"] != "present" || oi.ETag != fmt.Sprintf("%x", md5.Sum([]byte("replacement"))) {
|
||||
t.Fatalf("metadata/PUT lost: %+v %v", oi, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSinglePoolConditionalPutHTTP(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
obj, dirs, err := prepareErasure16(ctx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
t.Cleanup(func() { cancel(); z.Shutdown(context.Background()); removeRoots(dirs) })
|
||||
if !z.SinglePool() {
|
||||
t.Fatal("fixture is not a single pool")
|
||||
}
|
||||
bucket, router := conditionalPutBucket(t, z, "unversioned")
|
||||
object := "single-pool-condition"
|
||||
defer conditionalPutPool(t, z, object, 0)()
|
||||
url := getPutObjectURL("", bucket, object)
|
||||
for _, tc := range []struct {
|
||||
body, match, none string
|
||||
status int
|
||||
}{
|
||||
{"missing", "*", "", http.StatusNotFound},
|
||||
{"first", "", "*", http.StatusOK},
|
||||
{"blocked", "", "*", http.StatusPreconditionFailed},
|
||||
{"blocked", "stale", "", http.StatusPreconditionFailed},
|
||||
{"second", fmt.Sprintf("%x", md5.Sum([]byte("first"))), "", http.StatusOK},
|
||||
} {
|
||||
rec := multipartConditionRequest(t, router, http.MethodPut, url, tc.body, map[string]string{xhttp.IfMatch: tc.match, xhttp.IfNoneMatch: tc.none})
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("PUT %d want %d: %s", rec.Code, tc.status, rec.Body.String())
|
||||
}
|
||||
}
|
||||
get := multipartConditionRequest(t, router, http.MethodGet, url, "", nil)
|
||||
if get.Code != http.StatusOK || get.Body.String() != "second" {
|
||||
t.Fatalf("single-pool GET: %d %q", get.Code, get.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// An internal replica callback without an addressed version is not a public
|
||||
// condition. Preserve its availability when another pool is unreadable. An
|
||||
// addressed replica already requires all pools for lock/tag reconciliation.
|
||||
func TestPoolsConditionalPutReplicaAvailability(t *testing.T) {
|
||||
for _, addressed := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("addressed=%t", addressed), func(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
mode := "unversioned"
|
||||
if addressed {
|
||||
mode = "versioned"
|
||||
}
|
||||
bucket, router := conditionalPutBucket(t, z, mode)
|
||||
object := "replica-availability"
|
||||
oi := putConsistencyObject(t, z, bucket, object, 0, "old", ObjectOptions{Versioned: addressed, MTime: UTCNow().Add(-time.Minute)})
|
||||
defer conditionalPutPool(t, z, object, 0)()
|
||||
restoreFault := conditionalPutSwapDisks(z.serverPools[1], object, func(disk StorageAPI) StorageAPI {
|
||||
return consistencyReadFaultDisk{StorageAPI: disk, bucket: bucket, object: object}
|
||||
})
|
||||
defer restoreFault()
|
||||
headers := map[string]string{
|
||||
xhttp.MinIOSourceReplicationRequest: "true",
|
||||
xhttp.AmzBucketReplicationStatus: "REPLICA",
|
||||
xhttp.MinIOSourceETag: fmt.Sprintf("%x", md5.Sum([]byte("replacement"))),
|
||||
}
|
||||
url := getPutObjectURL("", bucket, object)
|
||||
wantStatus, wantBody := http.StatusOK, "replacement"
|
||||
if addressed {
|
||||
url += "?versionId=" + oi.VersionID
|
||||
wantStatus, wantBody = http.StatusServiceUnavailable, "old"
|
||||
}
|
||||
put := multipartConditionRequest(t, router, http.MethodPut, url, "replacement", headers)
|
||||
if put.Code != wantStatus {
|
||||
t.Fatalf("replica PUT: %d want %d: %s", put.Code, wantStatus, put.Body.String())
|
||||
}
|
||||
restoreFault()
|
||||
get := multipartConditionRequest(t, router, http.MethodGet, url, "", nil)
|
||||
if get.Code != http.StatusOK || get.Body.String() != wantBody {
|
||||
t.Fatalf("replica GET: %d %q", get.Code, get.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsConditionalPutDestinationVersionHTTP(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router := conditionalPutBucket(t, z, "versioned")
|
||||
object := "client-version"
|
||||
old := putConsistencyObject(t, z, bucket, object, 0, "old", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Hour)})
|
||||
current := putConsistencyObject(t, z, bucket, object, 1, "current", ObjectOptions{Versioned: true, MTime: UTCNow().Add(-time.Minute)})
|
||||
defer conditionalPutPool(t, z, object, 0)()
|
||||
url := getPutObjectURL("", bucket, object) + "?versionId=" + old.VersionID
|
||||
for _, stale := range []bool{true, false} {
|
||||
tag, status := current.ETag, http.StatusOK
|
||||
if stale {
|
||||
tag, status = old.ETag, http.StatusPreconditionFailed
|
||||
}
|
||||
put := multipartConditionRequest(t, router, http.MethodPut, url, "replacement", map[string]string{xhttp.IfMatch: fmt.Sprintf("%q", tag)})
|
||||
if put.Code != status {
|
||||
t.Fatalf("version-addressed public PUT: %d want %d: %s", put.Code, status, put.Body.String())
|
||||
}
|
||||
if got := put.Header()[xhttp.AmzVersionID]; !stale && (len(got) != 1 || got[0] != old.VersionID) {
|
||||
t.Fatalf("write version changed: %v", put.Header())
|
||||
}
|
||||
}
|
||||
get := multipartConditionRequest(t, router, http.MethodGet, url, "", nil)
|
||||
if get.Code != http.StatusOK || get.Body.String() != "replacement" {
|
||||
t.Fatalf("addressed GET: %d %q", get.Code, get.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsConditionalPutDeleteMarkerTie(t *testing.T) {
|
||||
for markerPool := range 2 {
|
||||
t.Run(fmt.Sprintf("marker-pool=%d", markerPool), func(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
bucket, router := conditionalPutBucket(t, z, "versioned")
|
||||
object := "marker-tie"
|
||||
mtime := UTCNow().Add(-time.Minute)
|
||||
putConsistencyObject(t, z, bucket, object, 1-markerPool, "live", ObjectOptions{Versioned: true, MTime: mtime})
|
||||
if _, err := z.serverPools[markerPool].DeleteObject(t.Context(), bucket, object, ObjectOptions{Versioned: true, VersionID: mustGetUUID(), DeleteMarker: true, MTime: mtime}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conditionalPutPool(t, z, object, 0)()
|
||||
url := getPutObjectURL("", bucket, object)
|
||||
before := multipartConditionRequest(t, router, http.MethodGet, url, "", nil)
|
||||
want := http.StatusPreconditionFailed
|
||||
if markerPool == 0 {
|
||||
want = http.StatusOK
|
||||
if before.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET tie: %d", before.Code)
|
||||
}
|
||||
} else if before.Code != http.StatusOK {
|
||||
t.Fatalf("GET tie: %d", before.Code)
|
||||
}
|
||||
put := multipartConditionRequest(t, router, http.MethodPut, url, "replacement", map[string]string{xhttp.IfNoneMatch: "*"})
|
||||
if put.Code != want {
|
||||
t.Fatalf("PUT tie: %d want %d: %s", put.Code, want, put.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Overlap fixture changes with the real IAM Walk reader instead of relying on
|
||||
// its periodic refresh timer to expose an unsynchronized disk-adapter swap.
|
||||
func TestPoolsConditionalPutFixtureConcurrentIAM(t *testing.T) {
|
||||
z, _ := consistencyPools(t)
|
||||
conditionalPutBucket(t, z, "unversioned")
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
started, finished := make(chan struct{}), make(chan error, 1)
|
||||
iam := globalIAMSys
|
||||
go func() {
|
||||
close(started)
|
||||
for range 20 {
|
||||
if err := iam.Load(ctx, false); err != nil {
|
||||
finished <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
finished <- nil
|
||||
}()
|
||||
<-started
|
||||
for i := range 5000 {
|
||||
restore := conditionalPutPool(t, z, "fixture-concurrent-iam", i%2)
|
||||
restore()
|
||||
}
|
||||
if err := <-finished; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
+103
-17
@@ -1149,6 +1149,40 @@ func (z *erasureServerPools) PutObject(ctx context.Context, bucket string, objec
|
||||
}
|
||||
opts.NoLock = true
|
||||
|
||||
// Public write conditions compare the logical current object while the
|
||||
// pools-layer write lock is held. The destination selected by capacity may
|
||||
// be empty or stale, and draining pools can still hold the current object.
|
||||
// Replica callbacks retain their existing addressed-version semantics and
|
||||
// metadata reconciliation at the set layer.
|
||||
if opts.CheckPrecondFn != nil && !opts.ReplicationRequest &&
|
||||
!opts.ReplicaLockReconcile && !opts.DataMovement {
|
||||
copies, lerr := z.objectPoolInfos(ctx, bucket, object, ObjectOptions{
|
||||
VersionID: "", // Compare the current object, not the write's version.
|
||||
Versioned: opts.Versioned,
|
||||
VersionSuspended: opts.VersionSuspended,
|
||||
NoAuditLog: true,
|
||||
})
|
||||
var latest ObjectInfo
|
||||
if lerr == nil {
|
||||
latest = copies[0].ObjInfo
|
||||
if latest.DeleteMarker {
|
||||
lerr = toObjectErr(errFileNotFound, bucket, object)
|
||||
}
|
||||
}
|
||||
// An unreadable pool may hold the newest object; it is not absence.
|
||||
if lerr != nil && !isErrObjectNotFound(lerr) && !isErrVersionNotFound(lerr) {
|
||||
return ObjectInfo{}, lerr
|
||||
}
|
||||
if lerr == nil && opts.CheckPrecondFn(latest) {
|
||||
return ObjectInfo{}, PreConditionFailed{}
|
||||
}
|
||||
if lerr != nil && opts.HasIfMatch {
|
||||
return ObjectInfo{}, lerr
|
||||
}
|
||||
// Do not repeat an accepted condition against the destination's copy.
|
||||
opts.CheckPrecondFn = nil
|
||||
}
|
||||
|
||||
idx, err := z.getWritePoolIdx(ctx, bucket, object, data.Size(), true)
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
@@ -1857,7 +1891,41 @@ func (z *erasureServerPools) ListMultipartUploads(ctx context.Context, bucket, p
|
||||
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)
|
||||
}
|
||||
if globalAPIConfig.getMultipartListingLegacy() {
|
||||
return z.listMultipartUploadsLegacy(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
||||
}
|
||||
scan, err := startMultipartScan(ctx, false)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
defer scan.close()
|
||||
|
||||
var uploads []MultipartInfo
|
||||
var keyless bool
|
||||
for idx, pool := range z.serverPools {
|
||||
if z.IsSuspended(idx) {
|
||||
continue
|
||||
}
|
||||
poolUploads, poolKeyless, err := pool.scanMultipartUploads(scan, bucket, idx)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
uploads = append(uploads, poolUploads...)
|
||||
keyless = keyless || poolKeyless
|
||||
}
|
||||
|
||||
// The old format cannot be enumerated authoritatively. Migration mode is
|
||||
// explicit: another bucket must never silently change this API's semantics.
|
||||
if keyless {
|
||||
return ListMultipartsInfo{}, errMultipartListingLegacy
|
||||
}
|
||||
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
|
||||
}
|
||||
|
||||
func (z *erasureServerPools) listMultipartUploadsLegacy(ctx context.Context, bucket, prefix, keyMarker, uploadIDMarker, delimiter string, maxUploads int) (ListMultipartsInfo, error) {
|
||||
poolResult := ListMultipartsInfo{}
|
||||
poolResult.MaxUploads = maxUploads
|
||||
poolResult.KeyMarker = keyMarker
|
||||
@@ -1883,15 +1951,14 @@ func (z *erasureServerPools) ListMultipartUploads(ctx context.Context, bucket, p
|
||||
}
|
||||
|
||||
if z.SinglePool() {
|
||||
return z.serverPools[0].ListMultipartUploads(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
||||
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
|
||||
}
|
||||
result, err := pool.ListMultipartUploads(ctx, bucket, prefix, keyMarker, uploadIDMarker,
|
||||
delimiter, maxUploads)
|
||||
result, err := pool.getHashedSet(prefix).listMultipartUploadsExact(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -1927,7 +1994,7 @@ func (z *erasureServerPools) NewMultipartUpload(ctx context.Context, bucket, obj
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := pool.ListMultipartUploads(ctx, bucket, object, "", "", "", maxUploadsList)
|
||||
result, err := pool.listMultipartUploadsExact(ctx, bucket, object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2089,13 +2156,18 @@ func (z *erasureServerPools) AbortMultipartUpload(ctx context.Context, bucket, o
|
||||
if err := checkAbortMultipartArgs(ctx, bucket, object, uploadID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := z.GetBucketInfo(ctx, bucket, BucketOptions{}); err != nil {
|
||||
return toObjectErr(err, bucket)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Unlock cancels the derived lock context before this notification runs.
|
||||
// Keep the request context so successful cancellation reaches peer caches.
|
||||
defer func(ctx context.Context) {
|
||||
if err == nil {
|
||||
z.mpCache.Delete(uploadID)
|
||||
globalNotificationSys.DeleteUploadID(ctx, uploadID)
|
||||
}
|
||||
}()
|
||||
}(ctx)
|
||||
|
||||
lk := z.NewNSLock(bucket, pathJoin(object, uploadID))
|
||||
lkctx, err := lk.GetLock(ctx, globalOperationTimeout)
|
||||
@@ -2105,23 +2177,28 @@ func (z *erasureServerPools) AbortMultipartUpload(ctx context.Context, bucket, o
|
||||
ctx = lkctx.Context()
|
||||
defer lk.Unlock(lkctx)
|
||||
|
||||
if z.SinglePool() {
|
||||
return z.serverPools[0].AbortMultipartUpload(ctx, bucket, object, uploadID, opts)
|
||||
}
|
||||
|
||||
legacy := globalAPIConfig.getMultipartListingLegacy()
|
||||
found := false
|
||||
var firstErr error
|
||||
for idx, pool := range z.serverPools {
|
||||
if z.IsSuspended(idx) {
|
||||
continue
|
||||
}
|
||||
err := pool.AbortMultipartUpload(ctx, bucket, object, uploadID, opts)
|
||||
if err == nil {
|
||||
return nil
|
||||
poolFound, err := pool.getHashedSet(object).abortMultipartUpload(ctx, bucket, object, uploadID, opts, legacy)
|
||||
if legacy && (poolFound || err != nil) {
|
||||
// Match the released first-matching-pool behavior.
|
||||
return err
|
||||
}
|
||||
if _, ok := err.(InvalidUploadID); ok {
|
||||
// upload id not found move to next pool
|
||||
continue
|
||||
found = found || poolFound
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
return err
|
||||
}
|
||||
if firstErr != nil {
|
||||
return firstErr
|
||||
}
|
||||
if found {
|
||||
return nil
|
||||
}
|
||||
return InvalidUploadID{
|
||||
Bucket: bucket,
|
||||
@@ -3051,6 +3128,15 @@ func (z *erasureServerPools) PutObjectTags(ctx context.Context, bucket, object s
|
||||
if err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
// Ordinary reads and replication can return any owning pool. Persist one
|
||||
// revision beyond all copies, so the returned value and every pool agree.
|
||||
if stamp := opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp]; stamp != "" {
|
||||
for _, copy := range copies {
|
||||
stamp = monotonicTaggingTimestamp(stamp, copy.ObjInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp])
|
||||
}
|
||||
opts.UserDefined = cloneMSS(opts.UserDefined)
|
||||
opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = stamp
|
||||
}
|
||||
opts.NoLock = true
|
||||
opts.VersionID = copies[0].ObjInfo.VersionID
|
||||
if opts.VersionID == "" {
|
||||
|
||||
+34
-4
@@ -880,10 +880,40 @@ 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) {
|
||||
// In list multipart uploads we are going to treat input prefix as the object,
|
||||
// this means that we are not supporting directory navigation.
|
||||
set := s.getHashedSet(prefix)
|
||||
return set.ListMultipartUploads(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads)
|
||||
if err := checkListMultipartArgs(ctx, bucket, prefix, keyMarker, uploadIDMarker, delimiter); err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
scan, err := startMultipartScan(ctx, false)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
defer scan.close()
|
||||
uploads, legacy, err := s.scanMultipartUploads(scan, bucket, 0)
|
||||
if err != nil {
|
||||
return ListMultipartsInfo{}, err
|
||||
}
|
||||
if legacy {
|
||||
return ListMultipartsInfo{}, errMultipartListingLegacy
|
||||
}
|
||||
return paginateMultipartUploads(uploads, prefix, keyMarker, uploadIDMarker, delimiter, maxUploads), nil
|
||||
}
|
||||
|
||||
func (s *erasureSets) scanMultipartUploads(scan *multipartScan, bucket string, poolIdx int) ([]MultipartInfo, bool, error) {
|
||||
var uploads []MultipartInfo
|
||||
var keyless bool
|
||||
for i, set := range s.sets {
|
||||
setUploads, setKeyless, err := set.scanMultipartUploads(scan, bucket, poolIdx, i)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
uploads = append(uploads, setUploads...)
|
||||
keyless = keyless || setKeyless
|
||||
}
|
||||
return uploads, keyless, nil
|
||||
}
|
||||
|
||||
func (s *erasureSets) listMultipartUploadsExact(ctx context.Context, bucket, object string) (ListMultipartsInfo, error) {
|
||||
return s.getHashedSet(object).listMultipartUploadsExact(ctx, bucket, object, "", "", "", maxUploadsList)
|
||||
}
|
||||
|
||||
// Initiate a new multipart upload on a hashedSet based on object name.
|
||||
|
||||
@@ -50,6 +50,7 @@ type apiConfig struct {
|
||||
transitionWorkers int
|
||||
|
||||
staleUploadsExpiry time.Duration
|
||||
multipartListingStrict bool
|
||||
staleUploadsCleanupInterval time.Duration
|
||||
deleteCleanupInterval time.Duration
|
||||
enableODirect bool
|
||||
@@ -181,6 +182,7 @@ func (t *apiConfig) init(cfg api.Config, setDriveCounts []int, legacy bool) {
|
||||
t.transitionWorkers = cfg.TransitionWorkers
|
||||
|
||||
t.staleUploadsExpiry = cfg.StaleUploadsExpiry
|
||||
t.multipartListingStrict = cfg.MultipartListing == "strict"
|
||||
t.deleteCleanupInterval = cfg.DeleteCleanupInterval
|
||||
t.enableODirect = cfg.EnableODirect
|
||||
t.gzipObjects = cfg.GzipObjects
|
||||
@@ -206,6 +208,12 @@ func (t *apiConfig) odirectEnabled() bool {
|
||||
return t.enableODirect
|
||||
}
|
||||
|
||||
func (t *apiConfig) getMultipartListingLegacy() bool {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return !t.multipartListingStrict
|
||||
}
|
||||
|
||||
func (t *apiConfig) shouldGzipObjects() bool {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
+33
-23
@@ -246,16 +246,6 @@ func extractMetadata(ctx context.Context, mimesHeader ...textproto.MIMEHeader) (
|
||||
|
||||
// extractMetadata extracts metadata from map values.
|
||||
func extractMetadataFromMime(ctx context.Context, v textproto.MIMEHeader, m map[string]string) error {
|
||||
return extractMetadataFromMimeWithReplication(ctx, v, m, false)
|
||||
}
|
||||
|
||||
// extractReplicationMetadataFromMime restores replication-only metadata after the
|
||||
// caller has validated that the request is a trusted replication write.
|
||||
func extractReplicationMetadataFromMime(ctx context.Context, v textproto.MIMEHeader, m map[string]string) error {
|
||||
return extractMetadataFromMimeWithReplication(ctx, v, m, true)
|
||||
}
|
||||
|
||||
func extractMetadataFromMimeWithReplication(ctx context.Context, v textproto.MIMEHeader, m map[string]string, allowReplication bool) error {
|
||||
if v == nil {
|
||||
bugLogIf(ctx, errInvalidArgument)
|
||||
return errInvalidArgument
|
||||
@@ -267,18 +257,14 @@ func extractMetadataFromMimeWithReplication(ctx context.Context, v textproto.MIM
|
||||
nv[http.CanonicalHeaderKey(k)] = kv
|
||||
}
|
||||
|
||||
// Save all supported headers.
|
||||
// Save ordinary object metadata. Replication-only headers are restored only
|
||||
// after the request has been validated as a trusted replication write.
|
||||
for _, supportedHeader := range supportedHeaders {
|
||||
value, ok := nv[http.CanonicalHeaderKey(supportedHeader)]
|
||||
if ok {
|
||||
if v, ok := replicationToInternalHeaders[supportedHeader]; ok {
|
||||
if !allowReplication {
|
||||
continue
|
||||
}
|
||||
m[v] = strings.Join(value, ",")
|
||||
} else {
|
||||
m[supportedHeader] = strings.Join(value, ",")
|
||||
}
|
||||
if _, ok := replicationToInternalHeaders[supportedHeader]; ok {
|
||||
continue
|
||||
}
|
||||
if value, ok := nv[http.CanonicalHeaderKey(supportedHeader)]; ok {
|
||||
m[supportedHeader] = strings.Join(value, ",")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,8 +273,7 @@ func extractMetadataFromMimeWithReplication(ctx context.Context, v textproto.MIM
|
||||
if !stringsHasPrefixFold(key, prefix) {
|
||||
continue
|
||||
}
|
||||
value, ok := nv[http.CanonicalHeaderKey(key)]
|
||||
if ok {
|
||||
if value, ok := nv[http.CanonicalHeaderKey(key)]; ok {
|
||||
m[key] = strings.Join(value, ",")
|
||||
break
|
||||
}
|
||||
@@ -297,6 +282,31 @@ func extractMetadataFromMimeWithReplication(ctx context.Context, v textproto.MIM
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractReplicationMetadataFromMime restores replication-only metadata after the
|
||||
// caller has validated that the request is a trusted replication write.
|
||||
func extractReplicationMetadataFromMime(ctx context.Context, v textproto.MIMEHeader, m map[string]string) error {
|
||||
if v == nil {
|
||||
bugLogIf(ctx, errInvalidArgument)
|
||||
return errInvalidArgument
|
||||
}
|
||||
|
||||
nv := make(textproto.MIMEHeader, len(v))
|
||||
for k, kv := range v {
|
||||
// Canonicalize all headers, to remove any duplicates.
|
||||
nv[http.CanonicalHeaderKey(k)] = kv
|
||||
}
|
||||
|
||||
// Ordinary object metadata belongs to the caller. Re-extracting it would
|
||||
// undo normalization (such as removing aws-chunked) or copy an outer
|
||||
// Snowball archive's metadata onto its individual entries.
|
||||
for header, internalHeader := range replicationToInternalHeaders {
|
||||
if value, ok := nv[http.CanonicalHeaderKey(header)]; ok {
|
||||
m[internalHeader] = strings.Join(value, ",")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns access credentials in the request Authorization header.
|
||||
func getReqAccessCred(r *http.Request, region string) (cred auth.Credentials) {
|
||||
cred, _, _ = getReqAccessKeyV4(r, region, serviceS3)
|
||||
|
||||
@@ -254,6 +254,9 @@ func TestExtractMetadataFromRequestKeepsQueryCompatibility(t *testing.T) {
|
||||
|
||||
func TestExtractReplicationMetadataHeaders(t *testing.T) {
|
||||
header := http.Header{
|
||||
"Content-Type": []string{"application/wasm"},
|
||||
"Content-Encoding": []string{"aws-chunked"},
|
||||
"X-Amz-Meta-Source": []string{"client"},
|
||||
"X-Minio-Replication-Server-Side-Encryption-Sealed-Key": []string{"sealed-key"},
|
||||
"X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm": []string{"DAREv2-HMAC-SHA256"},
|
||||
"X-Minio-Replication-Server-Side-Encryption-Iv": []string{"iv"},
|
||||
@@ -262,12 +265,17 @@ func TestExtractReplicationMetadataHeaders(t *testing.T) {
|
||||
ReplicationSsecChecksumHeader: []string{"checksum"},
|
||||
}
|
||||
|
||||
metadata := make(map[string]string)
|
||||
metadata := map[string]string{
|
||||
"content-type": "application/wasm",
|
||||
"x-amz-meta-source": "client",
|
||||
}
|
||||
if err := extractReplicationMetadataFromMime(t.Context(), textproto.MIMEHeader(header), metadata); err != nil {
|
||||
t.Fatalf("failed to extract replication metadata: %v", err)
|
||||
}
|
||||
|
||||
expected := map[string]string{
|
||||
"content-type": "application/wasm",
|
||||
"x-amz-meta-source": "client",
|
||||
"X-Minio-Internal-Server-Side-Encryption-Sealed-Key": "sealed-key",
|
||||
"X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm": "DAREv2-HMAC-SHA256",
|
||||
"X-Minio-Internal-Server-Side-Encryption-Iv": "iv",
|
||||
@@ -279,6 +287,9 @@ func TestExtractReplicationMetadataHeaders(t *testing.T) {
|
||||
if !reflect.DeepEqual(metadata, expected) {
|
||||
t.Fatalf("unexpected replication metadata: expected %#v, got %#v", expected, metadata)
|
||||
}
|
||||
if _, ok := metadata["content-encoding"]; ok {
|
||||
t.Fatalf("replication metadata restored transport content-encoding: %#v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCopyObjectMetadataFromHeaderReplication(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// Copyright (c) 2026 mr javad seydi
|
||||
//
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func multipartUploadKeys(uploads []MultipartInfo) []string {
|
||||
keys := make([]string, len(uploads))
|
||||
for i := range uploads {
|
||||
keys[i] = uploads[i].Object
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func requireMultipartUploadKeys(t *testing.T, got ListMultipartsInfo, want ...string) {
|
||||
t.Helper()
|
||||
if keys := multipartUploadKeys(got.Uploads); !slices.Equal(keys, want) {
|
||||
t.Fatalf("uploads = %v, want %v", keys, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMultipartUploadsS3Compatibility(t *testing.T) {
|
||||
obj, dirs, err := prepareErasureSets32(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z := obj.(*erasureServerPools)
|
||||
setMultipartListingTestMode(t, false)
|
||||
t.Cleanup(func() {
|
||||
z.Shutdown(t.Context())
|
||||
removeRoots(dirs)
|
||||
})
|
||||
|
||||
const bucket = "multipart-list-compat"
|
||||
if err = z.MakeBucket(t.Context(), bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
objects := []string{"t/a_b/p1", "t/a_b/p2", "t/c_d/p1", "u/x"}
|
||||
sets := z.serverPools[0]
|
||||
firstSet := sets.getHashedSetIndex(objects[0])
|
||||
if !slices.ContainsFunc(objects[1:], func(object string) bool {
|
||||
return sets.getHashedSetIndex(object) != firstSet
|
||||
}) {
|
||||
for n := 0; ; n++ {
|
||||
object := fmt.Sprintf("v/cross-set-%d", n)
|
||||
if sets.getHashedSetIndex(object) != firstSet {
|
||||
objects = append(objects, object)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uploadIDs := make(map[string]string, len(objects))
|
||||
for _, object := range objects {
|
||||
mp, err := z.NewMultipartUpload(t.Context(), bucket, object, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMultipartUpload(%q): %v", object, err)
|
||||
}
|
||||
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.
|
||||
z.mpCache.Range(func(uploadID string, _ MultipartInfo) bool {
|
||||
z.mpCache.Delete(uploadID)
|
||||
return true
|
||||
})
|
||||
|
||||
all, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, all, objects...)
|
||||
if all.IsTruncated || all.NextKeyMarker != "" || all.NextUploadIDMarker != "" {
|
||||
t.Fatalf("complete listing has truncation state: %+v", all)
|
||||
}
|
||||
|
||||
first, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, first, objects[0])
|
||||
if !first.IsTruncated || first.NextKeyMarker != objects[0] || first.NextUploadIDMarker != uploadIDs[objects[0]] {
|
||||
t.Fatalf("first page markers = (%q, %q, %t), want (%q, %q, true)",
|
||||
first.NextKeyMarker, first.NextUploadIDMarker, first.IsTruncated,
|
||||
objects[0], uploadIDs[objects[0]])
|
||||
}
|
||||
|
||||
rest, err := z.ListMultipartUploads(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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, afterKey, objects[2:]...)
|
||||
|
||||
prefixed, err := z.ListMultipartUploads(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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, nested, objects[:2]...)
|
||||
|
||||
grouped, err := z.ListMultipartUploads(t.Context(), bucket, "t/", "", "", SlashSeparator, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, grouped)
|
||||
if want := []string{"t/a_b/", "t/c_d/"}; !slices.Equal(grouped.CommonPrefixes, want) {
|
||||
t.Fatalf("common prefixes = %v, want %v", grouped.CommonPrefixes, want)
|
||||
}
|
||||
|
||||
groupPage, err := z.ListMultipartUploads(t.Context(), bucket, "t/", "", "", SlashSeparator, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"t/a_b/"}; !slices.Equal(groupPage.CommonPrefixes, want) {
|
||||
t.Fatalf("first common-prefix page = %v, want %v", groupPage.CommonPrefixes, want)
|
||||
}
|
||||
if !groupPage.IsTruncated || groupPage.NextKeyMarker != "t/a_b/" || groupPage.NextUploadIDMarker != "" {
|
||||
t.Fatalf("common-prefix page markers = (%q, %q, %t)",
|
||||
groupPage.NextKeyMarker, groupPage.NextUploadIDMarker, groupPage.IsTruncated)
|
||||
}
|
||||
|
||||
groupRest, err := z.ListMultipartUploads(t.Context(), bucket, "t/", groupPage.NextKeyMarker, "", SlashSeparator, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"t/c_d/"}; !slices.Equal(groupRest.CommonPrefixes, want) {
|
||||
t.Fatalf("second common-prefix page = %v, want %v", groupRest.CommonPrefixes, want)
|
||||
}
|
||||
if groupRest.IsTruncated {
|
||||
t.Fatalf("last common-prefix page is truncated: %+v", groupRest)
|
||||
}
|
||||
|
||||
part, err := z.PutObjectPart(t.Context(), bucket, objects[0], uploadIDs[objects[0]], 1,
|
||||
mustGetPutObjReader(t, bytes.NewBufferString("part"), 4, "", ""), ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
completed, err := z.CompleteMultipartUpload(t.Context(), bucket, objects[0], uploadIDs[objects[0]],
|
||||
[]CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{multipartMetaBucket, multipartMetaObject} {
|
||||
if _, ok := completed.UserDefined[key]; ok {
|
||||
t.Errorf("completed object retained upload-only metadata %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate an upload written by a pre-upgrade server. Its key cannot be
|
||||
// recovered by scanning the hashed namespace. Strict listing must fail;
|
||||
// the old path is available only through an explicit migration setting.
|
||||
legacyObject := objects[1]
|
||||
er := sets.getHashedSet(legacyObject)
|
||||
fi, metadata, err := er.checkUploadIDExists(t.Context(), bucket, legacyObject, uploadIDs[legacyObject], true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range metadata {
|
||||
delete(metadata[i].Metadata, multipartMetaBucket)
|
||||
delete(metadata[i].Metadata, multipartMetaObject)
|
||||
}
|
||||
if _, err = writeAllMetadata(t.Context(), er.getDisks(), bucket, minioMetaMultipartBucket,
|
||||
er.getUploadIDDir(bucket, legacyObject, uploadIDs[legacyObject]), metadata, fi.WriteQuorum(er.defaultWQuorum())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = z.ListMultipartUploads(t.Context(), bucket, legacyObject, "", "", "", 100)
|
||||
if !errors.Is(err, errMultipartListingLegacy) {
|
||||
t.Fatalf("legacy strict listing: %v", err)
|
||||
}
|
||||
setMultipartListingTestMode(t, true)
|
||||
legacy, err := z.ListMultipartUploads(t.Context(), bucket, legacyObject, "", "", "", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, legacy, legacyObject)
|
||||
}
|
||||
|
||||
func TestPaginateMultipartUploads(t *testing.T) {
|
||||
base := time.Unix(100, 0)
|
||||
id1, id2, id3 := multipartListingTestID(base, 1), multipartListingTestID(base.Add(time.Second), 2), multipartListingTestID(base, 3)
|
||||
uploads := []MultipartInfo{
|
||||
{Bucket: "bucket", Object: "b", UploadID: id3, Initiated: base},
|
||||
{Bucket: "bucket", Object: "a", UploadID: id2, Initiated: base.Add(time.Second)},
|
||||
{Bucket: "bucket", Object: "a", UploadID: id1, Initiated: base},
|
||||
{Bucket: "bucket", Object: "a", UploadID: id1, Initiated: base}, // duplicate discovery
|
||||
}
|
||||
|
||||
first := paginateMultipartUploads(uploads, "", "", "", "", 1)
|
||||
requireMultipartUploadKeys(t, first, "a")
|
||||
if !first.IsTruncated || first.NextKeyMarker != "a" || first.NextUploadIDMarker != id1 {
|
||||
t.Fatalf("first page = %+v", first)
|
||||
}
|
||||
|
||||
second := paginateMultipartUploads(uploads, "", first.NextKeyMarker, first.NextUploadIDMarker, "", 1)
|
||||
if len(second.Uploads) != 1 || second.Uploads[0].Object != "a" || second.Uploads[0].UploadID != id2 {
|
||||
t.Fatalf("second page uploads = %+v", second.Uploads)
|
||||
}
|
||||
if !second.IsTruncated || second.NextKeyMarker != "a" || second.NextUploadIDMarker != id2 {
|
||||
t.Fatalf("second page = %+v", second)
|
||||
}
|
||||
|
||||
last := paginateMultipartUploads(uploads, "", second.NextKeyMarker, second.NextUploadIDMarker, "", 1)
|
||||
requireMultipartUploadKeys(t, last, "b")
|
||||
if last.IsTruncated || last.NextKeyMarker != "" || last.NextUploadIDMarker != "" {
|
||||
t.Fatalf("last page = %+v", last)
|
||||
}
|
||||
|
||||
missingUploadMarker := paginateMultipartUploads(uploads, "", "a", multipartListingTestID(base.Add(2*time.Second), 4), "", 10)
|
||||
requireMultipartUploadKeys(t, missingUploadMarker, "b")
|
||||
|
||||
if err := checkListMultipartArgs(t.Context(), "bucket", "", "", "not-base64=", ""); err != nil {
|
||||
t.Fatalf("upload-id-marker without key-marker must be ignored: %v", err)
|
||||
}
|
||||
|
||||
overLimit := make([]MultipartInfo, maxUploadsList+1)
|
||||
for i := range overLimit {
|
||||
overLimit[i] = MultipartInfo{Bucket: "bucket", Object: fmt.Sprintf("%04d", i), UploadID: fmt.Sprint(i)}
|
||||
}
|
||||
capped := paginateMultipartUploads(overLimit, "", "", "", "", maxUploadsList+1)
|
||||
if capped.MaxUploads != maxUploadsList || len(capped.Uploads) != maxUploadsList || !capped.IsTruncated {
|
||||
t.Fatalf("over-limit page = MaxUploads %d, uploads %d, truncated %t",
|
||||
capped.MaxUploads, len(capped.Uploads), capped.IsTruncated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMultipartUploadsGlobalPageAcrossPools(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
setMultipartListingTestMode(t, false)
|
||||
objects := []string{"a/one", "b/two", "c/three", "d/four"}
|
||||
for i, object := range objects {
|
||||
if _, err := z.serverPools[i%len(z.serverPools)].NewMultipartUpload(t.Context(), bucket, object, ObjectOptions{}); err != nil {
|
||||
t.Fatalf("NewMultipartUpload(%q): %v", object, err)
|
||||
}
|
||||
}
|
||||
z.mpCache.Range(func(uploadID string, _ MultipartInfo) bool {
|
||||
z.mpCache.Delete(uploadID)
|
||||
return true
|
||||
})
|
||||
|
||||
page, err := z.ListMultipartUploads(t.Context(), bucket, "", "", "", "", 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, page, objects[:2]...)
|
||||
if !page.IsTruncated || page.NextKeyMarker != objects[1] {
|
||||
t.Fatalf("first global page = %+v", page)
|
||||
}
|
||||
|
||||
rest, err := z.ListMultipartUploads(t.Context(), bucket, "", page.NextKeyMarker, page.NextUploadIDMarker, "", 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireMultipartUploadKeys(t, rest, objects[2:]...)
|
||||
if rest.IsTruncated {
|
||||
t.Fatalf("last global page is truncated: %+v", rest)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
@@ -83,7 +84,8 @@ func checkListMultipartArgs(ctx context.Context, bucket, prefix, keyMarker, uplo
|
||||
if err := checkListObjsArgs(ctx, bucket, prefix, keyMarker); err != nil {
|
||||
return err
|
||||
}
|
||||
if uploadIDMarker != "" {
|
||||
// S3 ignores upload-id-marker when key-marker is absent.
|
||||
if uploadIDMarker != "" && keyMarker != "" {
|
||||
if HasSuffix(keyMarker, SlashSeparator) {
|
||||
return InvalidUploadIDKeyCombination{
|
||||
UploadIDMarker: uploadIDMarker,
|
||||
@@ -96,6 +98,9 @@ func checkListMultipartArgs(ctx context.Context, bucket, prefix, keyMarker, uplo
|
||||
UploadID: uploadIDMarker,
|
||||
}
|
||||
}
|
||||
if _, ok := multipartMarkerTime(uploadIDMarker); !ok {
|
||||
return InvalidArgument{Bucket: bucket, Object: keyMarker, Err: errors.New("upload-id-marker must contain a native multipart upload ID")}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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 (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
func TestPutOptsFromHeadersReplicationTimestamps(t *testing.T) {
|
||||
stamp := time.Date(2026, 9, 15, 1, 2, 3, 123456789, time.UTC)
|
||||
context := base64.StdEncoding.EncodeToString([]byte(`{"purpose":"tag-replication"}`))
|
||||
for _, encryption := range []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
}{
|
||||
{name: "none"},
|
||||
{name: "SSE-S3", headers: map[string]string{xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionAES}},
|
||||
{name: "SSE-KMS", headers: map[string]string{xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionKMS}},
|
||||
{name: "SSE-KMS-context", headers: map[string]string{
|
||||
xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionKMS, xhttp.AmzServerSideEncryptionKmsID: "tag-replication-key",
|
||||
xhttp.AmzServerSideEncryptionKmsContext: context,
|
||||
}},
|
||||
{name: "SSE-C", headers: ssecKeyHeaders([]byte("01234567890123456789012345678901"), false)},
|
||||
} {
|
||||
t.Run(encryption.name, func(t *testing.T) {
|
||||
for _, trusted := range []bool{false, true} {
|
||||
t.Run("trusted="+strconv.FormatBool(trusted), func(t *testing.T) {
|
||||
for _, tagging := range []struct {
|
||||
name, header string
|
||||
want time.Time
|
||||
invalid bool
|
||||
}{
|
||||
{name: "absent"},
|
||||
{name: "nanoseconds", header: stamp.Format(time.RFC3339Nano), want: stamp},
|
||||
{name: "offset-whitespace", header: " " + stamp.In(time.FixedZone("UTC+8", 8*60*60)).Format(time.RFC3339Nano) + " ", want: stamp},
|
||||
{name: "invalid", header: "not-a-timestamp", invalid: true},
|
||||
} {
|
||||
t.Run(tagging.name, func(t *testing.T) {
|
||||
for _, metadata := range []map[string]string{nil, {"x-amz-meta-test": "kept"}} {
|
||||
hdr := make(http.Header)
|
||||
wantEncryption := make(http.Header)
|
||||
for key, value := range encryption.headers {
|
||||
hdr.Set(key, value)
|
||||
wantEncryption.Set(key, value)
|
||||
}
|
||||
hdr.Set(xhttp.MinIOSourceTaggingTimestamp, tagging.header)
|
||||
hdr.Set(xhttp.MinIOSourceMTime, stamp.Add(-time.Hour).Format(time.RFC3339Nano))
|
||||
hdr.Set(xhttp.MinIOSourceObjectRetentionTimestamp, stamp.Add(-time.Minute).Format(time.RFC3339Nano))
|
||||
hdr.Set(xhttp.MinIOSourceObjectLegalHoldTimestamp, stamp.Add(-time.Second).Format(time.RFC3339Nano))
|
||||
hdr.Set(xhttp.MinIOSourceETag, "source-etag")
|
||||
opts, err := putOptsFromHeaders(t.Context(), hdr, metadata, trusted)
|
||||
if trusted && tagging.invalid {
|
||||
if err == nil || !strings.Contains(err.Error(), xhttp.MinIOSourceTaggingTimestamp) {
|
||||
t.Fatalf("malformed trusted timestamp: got %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTag, wantMTime, wantRetention, wantLegalhold, wantETag := time.Time{}, time.Time{}, time.Time{}, time.Time{}, ""
|
||||
if trusted {
|
||||
wantTag, wantMTime = tagging.want, stamp.Add(-time.Hour)
|
||||
wantRetention, wantLegalhold, wantETag = stamp.Add(-time.Minute), stamp.Add(-time.Second), "source-etag"
|
||||
}
|
||||
if !opts.ReplicationSourceTaggingTimestamp.Equal(wantTag) {
|
||||
t.Errorf("tag timestamp=%s, want %s", opts.ReplicationSourceTaggingTimestamp, wantTag)
|
||||
}
|
||||
if !opts.MTime.Equal(wantMTime) || !opts.ReplicationSourceRetentionTimestamp.Equal(wantRetention) ||
|
||||
!opts.ReplicationSourceLegalholdTimestamp.Equal(wantLegalhold) || opts.PreserveETag != wantETag || opts.ReplicationRequest != trusted {
|
||||
t.Error("other source fields did not preserve the replication trust boundary")
|
||||
}
|
||||
if opts.UserDefined == nil || (metadata != nil && !reflect.DeepEqual(opts.UserDefined, metadata)) {
|
||||
t.Errorf("metadata=%v, want nonnil map preserving %v", opts.UserDefined, metadata)
|
||||
}
|
||||
gotEncryption := make(http.Header)
|
||||
if opts.ServerSideEncryption != nil {
|
||||
opts.ServerSideEncryption.Marshal(gotEncryption)
|
||||
}
|
||||
if !reflect.DeepEqual(gotEncryption, wantEncryption) {
|
||||
t.Errorf("SSE headers=%v, want %v", gotEncryption, wantEncryption)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -452,11 +452,11 @@ func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[strin
|
||||
MTime: mtime,
|
||||
PreserveETag: etag,
|
||||
ReplicationRequest: trustedReplication,
|
||||
// The Object Lock timestamps order replicated retention and legal
|
||||
// hold updates. Dropping them here would leave every update on an
|
||||
// SSE-KMS destination unordered.
|
||||
// These timestamps order replicated retention, legal hold and tagging
|
||||
// updates on an SSE-KMS destination.
|
||||
ReplicationSourceLegalholdTimestamp: lholdtimestmp,
|
||||
ReplicationSourceRetentionTimestamp: retaintimestmp,
|
||||
ReplicationSourceTaggingTimestamp: taggingtimestmp,
|
||||
}
|
||||
return op, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/crypto"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
)
|
||||
|
||||
// TestAPICopyObjectReplicaTaggingTimestampUnderKMS covers signed replica COPY
|
||||
// requests through encryption, metadata replacement and disk persistence. Both
|
||||
// the single-disk and 16-disk fixtures are single-pool backends.
|
||||
func TestAPICopyObjectReplicaTaggingTimestampUnderKMS(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testAPICopyObjectReplicaTaggingTimestampUnderKMS})
|
||||
}
|
||||
|
||||
func testAPICopyObjectReplicaTaggingTimestampUnderKMS(obj ObjectLayer, instance, bucket string, router http.Handler, creds auth.Credentials, t *testing.T) {
|
||||
// Ignore the host free-space percentage while retaining real disk I/O.
|
||||
for _, pool := range obj.(*erasureServerPools).serverPools {
|
||||
for _, set := range pool.sets {
|
||||
original := set.getDisks
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i := range disks {
|
||||
disks[i] = tagTestCapacityDisk{StorageAPI: disks[i]}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return disks }
|
||||
defer func() { set.getDisks = original }()
|
||||
}
|
||||
}
|
||||
oldKMS, oldAuto := GlobalKMS, globalAutoEncryption
|
||||
GlobalKMS = kms.NewStub("replica-tags-key")
|
||||
globalAutoEncryption = false
|
||||
defer func() { GlobalKMS, globalAutoEncryption = oldKMS, oldAuto }()
|
||||
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const tsKey = ReservedMetadataPrefixLower + TaggingTimestamp
|
||||
stamp := time.Date(2026, 9, 15, 1, 0, 0, 123456789, time.UTC)
|
||||
for _, mode := range []string{"none", "explicit-sse-s3", "explicit-kms", "auto-kms", "bucket-kms"} {
|
||||
t.Run(instance+"/"+mode, func(t *testing.T) {
|
||||
globalAutoEncryption = mode == "auto-kms"
|
||||
if mode == "bucket-kms" {
|
||||
sseXML := []byte(`<ServerSideEncryptionConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>aws:kms</SSEAlgorithm><KMSMasterKeyID>replica-tags-key</KMSMasterKeyID></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`)
|
||||
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketSSEConfig, sseXML); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const data = "encrypted replica copy remains readable"
|
||||
oi, err := obj.PutObject(t.Context(), bucket, mode, mustGetPutObjReader(t, bytes.NewReader([]byte(data)), int64(len(data)), "", ""), ObjectOptions{
|
||||
Versioned: true, UserDefined: map[string]string{xhttp.AmzObjectTagging: "key=old", tsKey: stamp.Format(time.RFC3339Nano)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, event := range []struct {
|
||||
name, tags, wantTags string
|
||||
delta, wantDelta time.Duration
|
||||
missingTimestamp bool
|
||||
}{
|
||||
{"newer", "key=new", "key=new", 2, 2, false},
|
||||
{"stale", "key=stale", "key=new", 1, 2, false},
|
||||
{"duplicate", "key=new", "key=new", 2, 2, false},
|
||||
{"newer-again", "key=latest", "key=latest", 3, 3, false},
|
||||
{"missing-timestamp", "key=unordered", "key=latest", 0, 3, true},
|
||||
} {
|
||||
headers := map[string]string{
|
||||
xhttp.AmzCopySource: "/" + bucket + "/" + mode + "?versionId=" + oi.VersionID,
|
||||
xhttp.AmzMetadataDirective: "REPLACE", xhttp.AmzTagDirective: "REPLACE",
|
||||
xhttp.AmzObjectTagging: event.tags, xhttp.MinIOSourceReplicationRequest: "true",
|
||||
xhttp.AmzBucketReplicationStatus: "REPLICA", xhttp.MinIOSourceTaggingTimestamp: stamp.Add(event.delta).Format(time.RFC3339Nano),
|
||||
xhttp.MinIOSourceMTime: oi.ModTime.Format(time.RFC3339Nano), xhttp.MinIOSourceETag: oi.ETag,
|
||||
}
|
||||
if event.missingTimestamp {
|
||||
delete(headers, xhttp.MinIOSourceTaggingTimestamp)
|
||||
}
|
||||
if mode == "explicit-sse-s3" {
|
||||
headers[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
|
||||
}
|
||||
if mode == "explicit-kms" {
|
||||
headers[xhttp.AmzServerSideEncryption] = "aws:kms"
|
||||
headers[xhttp.AmzServerSideEncryptionKmsID] = "replica-tags-key"
|
||||
}
|
||||
req, err := newTestSignedRequestV4(http.MethodPut, "/"+bucket+"/"+mode+"?versionId="+oi.VersionID, 0, nil, creds.AccessKey, creds.SecretKey, headers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s: COPY %d %s", event.name, w.Code, w.Body.String())
|
||||
}
|
||||
got, err := obj.GetObjectInfo(t.Context(), bucket, mode, ObjectOptions{VersionID: oi.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("%s: tags=%q timestamp=%q kms=%v", event.name, got.UserTags, got.UserDefined[tsKey], crypto.S3KMS.IsEncrypted(got.UserDefined))
|
||||
if got.UserTags != event.wantTags || got.UserDefined[tsKey] != stamp.Add(event.wantDelta).Format(time.RFC3339Nano) {
|
||||
t.Errorf("%s: incorrect persisted tags/timestamp", event.name)
|
||||
}
|
||||
wantKMS := mode == "explicit-kms" || mode == "auto-kms" || mode == "bucket-kms"
|
||||
if crypto.S3KMS.IsEncrypted(got.UserDefined) != wantKMS || crypto.S3.IsEncrypted(got.UserDefined) != (mode == "explicit-sse-s3") {
|
||||
t.Errorf("%s: unexpected destination encryption", event.name)
|
||||
}
|
||||
if got.VersionID != oi.VersionID {
|
||||
t.Errorf("%s: version=%q, want %q", event.name, got.VersionID, oi.VersionID)
|
||||
}
|
||||
req, err = newTestSignedRequestV4(http.MethodGet, "/"+bucket+"/"+mode+"?versionId="+oi.VersionID, 0, nil, creds.AccessKey, creds.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK || w.Body.String() != data {
|
||||
t.Fatalf("%s: GET %d %q", event.name, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,10 @@ func checkPreconditionsPUT(ctx context.Context, w http.ResponseWriter, r *http.R
|
||||
// updated. The predicate is the incoming request's restored SSE-C metadata,
|
||||
// not what the destination happens to hold.
|
||||
ssecReplica := isReplicaTrusted(r.Context()) && crypto.SSEC.IsEncrypted(opts.UserDefined)
|
||||
if etagMatch && vidMatch && !ssecReplica {
|
||||
// Matching content does not imply that its tag revision was delivered.
|
||||
// Keep client preconditions above; relax only the internal duplicate check.
|
||||
newerTags := isReplicaTrusted(r.Context()) && olderThan(objInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp], opts.ReplicationSourceTaggingTimestamp)
|
||||
if etagMatch && vidMatch && !ssecReplica && !newerTags {
|
||||
writeHeaders()
|
||||
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrPreconditionFailed), r.URL)
|
||||
return true
|
||||
|
||||
+30
-19
@@ -1797,6 +1797,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
// source timestamp is newer than the stored one, and a stale update must
|
||||
// leave the stored state in place instead of erasing it.
|
||||
storedLock := storedObjectLockState(srcInfo.UserDefined)
|
||||
storedTagTimestamp := srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp]
|
||||
|
||||
srcInfo.UserDefined, err = getCpObjMetadataFromHeader(ctx, r, srcInfo.UserDefined, allowReplicationMetadata)
|
||||
if err != nil {
|
||||
@@ -1814,23 +1815,29 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
}
|
||||
|
||||
if objTags != "" {
|
||||
lastTaggingTimestamp := srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp]
|
||||
if dstOpts.ReplicationRequest {
|
||||
srcTimestamp := dstOpts.ReplicationSourceTaggingTimestamp
|
||||
if !srcTimestamp.IsZero() {
|
||||
ondiskTimestamp, err := time.Parse(time.RFC3339Nano, lastTaggingTimestamp)
|
||||
// update tagging metadata only if replica timestamp is newer than what's on disk
|
||||
if err != nil || (err == nil && !ondiskTimestamp.After(srcTimestamp)) {
|
||||
srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = srcTimestamp.UTC().Format(time.RFC3339Nano)
|
||||
srcInfo.UserDefined[xhttp.AmzObjectTagging] = objTags
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if dstOpts.ReplicationRequest {
|
||||
srcTimestamp := dstOpts.ReplicationSourceTaggingTimestamp
|
||||
if !srcTimestamp.IsZero() {
|
||||
// An empty value with a timestamp is an ordered deletion. Recheck
|
||||
// the captured state even if metadata REPLACE rebuilt the map.
|
||||
srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = srcTimestamp.UTC().Format(time.RFC3339Nano)
|
||||
srcInfo.UserDefined[xhttp.AmzObjectTagging] = objTags
|
||||
srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
reconcileStoredObjectTags(srcInfo.UserDefined, srcInfo.UserTags, storedTagTimestamp)
|
||||
} else {
|
||||
srcInfo.UserDefined[xhttp.AmzObjectTagging] = srcInfo.UserTags
|
||||
if storedTagTimestamp != "" {
|
||||
srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = storedTagTimestamp
|
||||
} else {
|
||||
delete(srcInfo.UserDefined, ReservedMetadataPrefixLower+TaggingTimestamp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
srcInfo.UserDefined[xhttp.AmzObjectTagging] = objTags
|
||||
srcInfo.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
}
|
||||
// SSE-C rotation snapshots reserved metadata before the tag decision. Its
|
||||
// later merge must not put the old timestamp back over the accepted state.
|
||||
delete(encMetadata, ReservedMetadataPrefixLower+TaggingTimestamp)
|
||||
|
||||
srcInfo.UserDefined = filterReplicationStatusMetadata(srcInfo.UserDefined)
|
||||
srcInfo.UserDefined = objectlock.FilterObjectLockMetadata(srcInfo.UserDefined, true, true)
|
||||
@@ -2313,6 +2320,9 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
if opts.ReplicationRequest && !opts.ReplicationSourceTaggingTimestamp.IsZero() {
|
||||
metadata[ReservedMetadataPrefixLower+TaggingTimestamp] = opts.ReplicationSourceTaggingTimestamp.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
actualSize := size
|
||||
var idxCb func() []byte
|
||||
@@ -3760,11 +3770,11 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
|
||||
}
|
||||
|
||||
dsc := mustReplicate(ctx, bucket, object, getMustReplicateOptions(objInfo.UserDefined, tagsStr, objInfo.ReplicationStatus, replication.MetadataReplicationType, opts))
|
||||
stamp := UTCNow().Format(time.RFC3339Nano)
|
||||
opts.UserDefined = map[string]string{ReservedMetadataPrefixLower + TaggingTimestamp: stamp}
|
||||
if dsc.ReplicateAny() {
|
||||
opts.UserDefined = make(map[string]string)
|
||||
opts.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
opts.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] = stamp
|
||||
opts.UserDefined[ReservedMetadataPrefixLower+ReplicationStatus] = dsc.PendingStatus()
|
||||
opts.UserDefined[ReservedMetadataPrefixLower+TaggingTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// Put object tags
|
||||
@@ -3863,9 +3873,10 @@ func (api objectAPIHandlers) DeleteObjectTaggingHandler(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
dsc := mustReplicate(ctx, bucket, object, oi.getMustReplicateOptions(replication.MetadataReplicationType, opts))
|
||||
stamp := UTCNow().Format(time.RFC3339Nano)
|
||||
opts.UserDefined = map[string]string{ReservedMetadataPrefixLower + TaggingTimestamp: stamp}
|
||||
if dsc.ReplicateAny() {
|
||||
opts.UserDefined = make(map[string]string)
|
||||
opts.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
||||
opts.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] = stamp
|
||||
opts.UserDefined[ReservedMetadataPrefixLower+ReplicationStatus] = dsc.PendingStatus()
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,10 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r
|
||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||
return
|
||||
}
|
||||
// Completion orders the upload's persisted tag state under the object lock.
|
||||
if opts.ReplicationRequest && !opts.ReplicationSourceTaggingTimestamp.IsZero() {
|
||||
metadata[ReservedMetadataPrefixLower+TaggingTimestamp] = opts.ReplicationSourceTaggingTimestamp.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
if r.Header.Get(xhttp.IfMatch) != "" {
|
||||
opts.HasIfMatch = true
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/xml"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// Exercise authenticated handlers and actual disk metadata, including the
|
||||
// response headers consumers see after replication has completed.
|
||||
func TestAPIReplicaContentEncoding(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testAPIReplicaContentEncoding})
|
||||
}
|
||||
|
||||
func testAPIReplicaContentEncoding(obj ObjectLayer, instance, bucket string, router http.Handler, owner auth.Credentials, t *testing.T) {
|
||||
ordinary := newObjectAttributesAuthzUser(t, instance, bucket, `"s3:PutObject","s3:GetObject"`)
|
||||
replicator := newObjectAttributesAuthzUser(t, instance, bucket, `"s3:PutObject","s3:GetObject","s3:ReplicateObject"`)
|
||||
for _, mode := range []string{"ordinary", "untrusted-marker", "replica"} {
|
||||
for _, tc := range []struct{ name, wire, want string }{
|
||||
{"bare", "aws-chunked", ""}, {"mixed", "aws-chunked,gzip", "gzip"}, {"gzip", "gzip", "gzip"},
|
||||
} {
|
||||
for _, operation := range []string{"put", "copy-replace", "multipart"} {
|
||||
t.Run(instance+"/"+mode+"/"+tc.name+"/"+operation, func(t *testing.T) {
|
||||
object := mode + "/" + tc.name + "/" + operation
|
||||
payload := replicaEncodingPayload(t, tc.want)
|
||||
creds := ordinary
|
||||
headers := map[string]string{xhttp.ContentEncoding: tc.wire, xhttp.ContentType: "application/octet-stream", "X-Amz-Meta-Source": "encoding-test"}
|
||||
if mode != "ordinary" {
|
||||
headers[xhttp.MinIOSourceReplicationRequest] = "true"
|
||||
}
|
||||
if mode == "replica" {
|
||||
creds = replicator
|
||||
headers[xhttp.AmzBucketReplicationStatus] = "REPLICA"
|
||||
}
|
||||
send := func(method, target string, data []byte, hdrs map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req, err := newTestSignedRequestV4(method, target, int64(len(data)), bytes.NewReader(data), creds.AccessKey, creds.SecretKey, hdrs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return replicaEncodingServe(t, router, req, http.StatusOK)
|
||||
}
|
||||
switch operation {
|
||||
case "put":
|
||||
if strings.Contains(tc.wire, "aws-chunked") {
|
||||
req := replicaEncodingStream(t, getPutObjectURL("", bucket, object), payload, creds, headers)
|
||||
replicaEncodingServe(t, router, req, http.StatusOK)
|
||||
} else {
|
||||
send(http.MethodPut, getPutObjectURL("", bucket, object), payload, headers)
|
||||
}
|
||||
case "copy-replace":
|
||||
source := object + "-source"
|
||||
if _, err := obj.PutObject(t.Context(), bucket, source, mustGetPutObjReader(t, bytes.NewReader(payload), int64(len(payload)), "", ""), ObjectOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers[xhttp.AmzCopySource] = url.QueryEscape("/" + bucket + "/" + source)
|
||||
headers[xhttp.AmzMetadataDirective] = replaceDirective
|
||||
send(http.MethodPut, getCopyObjectURL("", bucket, object), nil, headers)
|
||||
case "multipart":
|
||||
rec := send(http.MethodPost, getNewMultipartURL("", bucket, object), nil, headers)
|
||||
var init InitiateMultipartUploadResponse
|
||||
if err := xml.Unmarshal(rec.Body.Bytes(), &init); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Part/completion metadata must not replace the encoding saved at initiation.
|
||||
partHeaders := map[string]string{xhttp.ContentEncoding: "br"}
|
||||
if mode == "replica" {
|
||||
partHeaders[xhttp.MinIOSourceReplicationRequest] = "true"
|
||||
partHeaders[xhttp.AmzBucketReplicationStatus] = "REPLICA"
|
||||
}
|
||||
part := send(http.MethodPut, getPutObjectPartURL("", bucket, object, init.UploadID, "1"), payload, partHeaders)
|
||||
partETags := part.Header()[xhttp.ETag]
|
||||
if len(partETags) != 1 {
|
||||
t.Fatalf("missing part ETag: %#v", part.Header())
|
||||
}
|
||||
complete, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{{PartNumber: 1, ETag: canonicalizeETag(partETags[0])}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
send(http.MethodPost, getCompleteMultipartUploadURL("", bucket, object, init.UploadID), complete, partHeaders)
|
||||
}
|
||||
assertReplicaEncodingObject(t, obj, router, owner, bucket, object, tc.want, payload)
|
||||
info, err := obj.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.UserDefined[xhttp.AmzBucketReplicationStatus]; (got == "REPLICA") != (mode == "replica") {
|
||||
t.Errorf("replica status %q for mode %s", got, mode)
|
||||
}
|
||||
if info.ContentType != "application/octet-stream" {
|
||||
t.Errorf("content-type=%q", info.ContentType)
|
||||
}
|
||||
if value, ok := caseInsensitiveMap(info.UserDefined).Lookup("x-amz-meta-source"); !ok || value != "encoding-test" {
|
||||
t.Errorf("user metadata lost: %#v", info.UserDefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Run(instance+"/unauthorized-replica", func(t *testing.T) {
|
||||
object := "denied-replica"
|
||||
req := replicaEncodingStream(t, getPutObjectURL("", bucket, object), []byte("denied"), ordinary, map[string]string{xhttp.ContentEncoding: "aws-chunked", xhttp.MinIOSourceReplicationRequest: "true", xhttp.AmzBucketReplicationStatus: "REPLICA"})
|
||||
rec := replicaEncodingServe(t, router, req, http.StatusForbidden)
|
||||
var response APIErrorResponse
|
||||
if err := xml.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Code != "AccessDenied" {
|
||||
t.Fatalf("expected permission denial, got %s", response.Code)
|
||||
}
|
||||
if _, err := obj.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{}); err == nil {
|
||||
t.Error("denied replica created an object")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func replicaEncodingPayload(t *testing.T, encoding string) []byte {
|
||||
t.Helper()
|
||||
data := bytes.Repeat([]byte("replica encoding payload\n"), 128)
|
||||
if encoding != "gzip" {
|
||||
return data
|
||||
}
|
||||
var b bytes.Buffer
|
||||
w := gzip.NewWriter(&b)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func replicaEncodingStream(t *testing.T, target string, data []byte, creds auth.Credentials, headers map[string]string) *http.Request {
|
||||
t.Helper()
|
||||
const chunkSize = 64
|
||||
body := bytes.NewReader(data)
|
||||
req, err := newTestStreamingRequest(http.MethodPut, target, int64(len(data)), chunkSize, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
now := UTCNow()
|
||||
signature, err := signStreamingRequest(req, creds.AccessKey, creds.SecretKey, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, err = assembleStreamingChunks(req, body, chunkSize, creds.SecretKey, signature, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func replicaEncodingServe(t *testing.T, router http.Handler, req *http.Request, want int) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != want {
|
||||
t.Fatalf("%s %s: status=%d want=%d body=%s", req.Method, req.URL, rec.Code, want, rec.Body.String())
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func assertReplicaEncodingObject(t *testing.T, obj ObjectLayer, router http.Handler, creds auth.Credentials, bucket, object, encoding string, data []byte) {
|
||||
t.Helper()
|
||||
info, err := obj.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.ContentEncoding != encoding {
|
||||
t.Errorf("persisted content-encoding=%q want=%q", info.ContentEncoding, encoding)
|
||||
}
|
||||
if encoding == "" {
|
||||
if _, present := info.UserDefined["content-encoding"]; present {
|
||||
t.Error("transport-only content-encoding key persisted")
|
||||
}
|
||||
}
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
req, err := newTestSignedRequestV4(method, getPutObjectURL("", bucket, object), 0, nil, creds.AccessKey, creds.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := replicaEncodingServe(t, router, req, http.StatusOK)
|
||||
if got := rec.Header().Get(xhttp.ContentEncoding); got != encoding {
|
||||
t.Errorf("%s content-encoding=%q want=%q", method, got, encoding)
|
||||
}
|
||||
if encoding == "" {
|
||||
if _, present := rec.Header()[xhttp.ContentEncoding]; present {
|
||||
t.Errorf("%s sent an empty/transport encoding header", method)
|
||||
}
|
||||
}
|
||||
if method == http.MethodGet && !bytes.Equal(rec.Body.Bytes(), data) {
|
||||
t.Errorf("GET body differs: got %d bytes want %d", rec.Body.Len(), len(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISnowballReplicaContentEncoding(t *testing.T) {
|
||||
defer DetectTestLeak(t)()
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, creds auth.Credentials, t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
pax map[string]string
|
||||
want string
|
||||
}{
|
||||
{name: "no-pax"},
|
||||
{name: "pax-without-encoding", pax: map[string]string{"minio.metadata.Content-Type": "application/octet-stream"}},
|
||||
{name: "pax-bare", pax: map[string]string{"minio.metadata.Content-Encoding": "aws-chunked"}},
|
||||
{name: "pax-mixed", pax: map[string]string{"minio.metadata.Content-Encoding": "aws-chunked,gzip"}, want: "gzip"},
|
||||
} {
|
||||
t.Run(instance+"/"+tc.name, func(t *testing.T) {
|
||||
object := "snowball/" + tc.name
|
||||
data := replicaEncodingPayload(t, tc.want)
|
||||
var archive bytes.Buffer
|
||||
tw := tar.NewWriter(&archive)
|
||||
if err := tw.WriteHeader(&tar.Header{Name: object, Mode: 0o600, Size: int64(len(data)), PAXRecords: tc.pax}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ordinaryMetadata map[string]string
|
||||
// An unauthorized entry in a REPLICA request is rejected. Compare the
|
||||
// same archive across ordinary and authorized replica requests instead.
|
||||
for _, replica := range []bool{false, true} {
|
||||
headers := map[string]string{
|
||||
xhttp.ContentEncoding: "aws-chunked", xhttp.AmzSnowballExtract: "true",
|
||||
xhttp.ContentType: "application/x-tar", xhttp.CacheControl: "max-age=123",
|
||||
"X-Amz-Meta-Archive": "outer-request",
|
||||
}
|
||||
if replica {
|
||||
headers[xhttp.MinIOSourceReplicationRequest] = "true"
|
||||
headers[xhttp.AmzBucketReplicationStatus] = "REPLICA"
|
||||
}
|
||||
req := replicaEncodingStream(t, getPutObjectURL("", bucket, "archive.tar"), archive.Bytes(), creds, headers)
|
||||
replicaEncodingServe(t, router, req, http.StatusOK)
|
||||
assertReplicaEncodingObject(t, obj, router, creds, bucket, object, tc.want, data)
|
||||
info, err := obj.GetObjectInfo(t.Context(), bucket, object, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := maps.Clone(info.UserDefined)
|
||||
for _, key := range []string{xhttp.AmzBucketReplicationStatus, ReservedMetadataPrefixLower + ReplicaStatus, ReservedMetadataPrefixLower + ReplicaTimestamp, "etag"} {
|
||||
delete(metadata, key)
|
||||
}
|
||||
if !replica {
|
||||
ordinaryMetadata = metadata
|
||||
} else if !reflect.DeepEqual(metadata, ordinaryMetadata) {
|
||||
t.Errorf("replica inherited ordinary archive metadata: got %#v want %#v", metadata, ordinaryMetadata)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}})
|
||||
}
|
||||
@@ -106,6 +106,7 @@ func TestReplicateDeleteMarkerTargetSemantics(t *testing.T) {
|
||||
|
||||
func testReplicateDeleteMarkerPurge(obj ObjectLayer, instanceType, bucket string, router http.Handler, creds auth.Credentials, t *testing.T, legacy bool) {
|
||||
ctx := t.Context()
|
||||
defer replicationTestCapacity(obj)()
|
||||
const arn = "arn:minio:replication::af470089-d354-4473-934c-9e1f52f6da89:bucket"
|
||||
const name = "marker"
|
||||
version := mustGetUUID()
|
||||
@@ -208,27 +209,12 @@ func testReplicateDeleteMarkerPurge(obj ObjectLayer, instanceType, bucket string
|
||||
t.Errorf("purge scheduled as marker creation: version=%q marker=%q", deletion.VersionID, deletion.DeleteMarkerVersionID)
|
||||
}
|
||||
if legacy {
|
||||
// Reproduce the old producer's state and let the existing scanner/heal
|
||||
// path recover it. Upgrades must also finish purges already left pending.
|
||||
// Old task shapes must complete directly; scanner/MRF recovery is
|
||||
// covered separately by TestReplicationMRFMarkerRecovery.
|
||||
deletion.VersionID, deletion.DeleteMarkerVersionID = "", version
|
||||
replicateDelete(ctx, deletion, obj)
|
||||
oi, _ := obj.GetObjectInfo(ctx, bucket, name, ObjectOptions{VersionID: version, Versioned: true})
|
||||
if oi.VersionPurgeStatus != replication.VersionPurgePending {
|
||||
t.Fatalf("legacy source purge = %s, want PENDING", oi.VersionPurgeStatus)
|
||||
}
|
||||
targets, err := globalBucketTargetSys.ListBucketTargets(ctx, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queueReplicationHeal(ctx, bucket, oi, replicationConfig{Config: &cfg, remotes: targets}, 0)
|
||||
select {
|
||||
case op := <-worker:
|
||||
deletion = op.(DeletedObjectReplicationInfo)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("legacy pending purge was not scheduled for healing")
|
||||
}
|
||||
}
|
||||
result := replicateDelete(context.Background(), deletion, obj)
|
||||
|
||||
result := replicateDelete(context.Background(), deletion, markerPurgeUpdateLayer{ObjectLayer: obj, t: t})
|
||||
if result.VersionPurgeStatus() != replication.VersionPurgeComplete {
|
||||
t.Errorf("remote purge result = %s, want COMPLETE", result.VersionPurgeStatus())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/logger"
|
||||
loghttp "github.com/minio/minio/internal/logger/target/http"
|
||||
"github.com/minio/minio/internal/once"
|
||||
xnet "github.com/pgsty/silo-pkg/v3/net"
|
||||
)
|
||||
|
||||
type markerRecoveryCase struct {
|
||||
name string
|
||||
legacy, creation, lostReply, partial, exhaust, lockFirst, invalidMRF, replicaSource, offline, unrecordedPurge bool
|
||||
targets int
|
||||
}
|
||||
|
||||
func TestReplicationMRFMarkerRecovery(t *testing.T) {
|
||||
for _, tc := range []markerRecoveryCase{
|
||||
{name: "canonical", targets: 1},
|
||||
{name: "lock-failure", lockFirst: true, targets: 1},
|
||||
{name: "invalid-MRF-metadata", invalidMRF: true, targets: 1},
|
||||
{name: "legacy", legacy: true, targets: 1},
|
||||
{name: "unrecorded-purge", unrecordedPurge: true, targets: 2},
|
||||
{name: "two-targets", legacy: true, targets: 2},
|
||||
{name: "two-targets-offline", offline: true, targets: 2},
|
||||
{name: "replica-source", replicaSource: true, targets: 1},
|
||||
{name: "lost-reply-canonical", lostReply: true, targets: 1},
|
||||
{name: "lost-reply-legacy", legacy: true, lostReply: true, targets: 1},
|
||||
{name: "creation", creation: true, targets: 1},
|
||||
{name: "partial-creation-block", partial: true, targets: 2},
|
||||
{name: "retry-budget-and-scanner", exhaust: true, targets: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, endpoints: []string{"DeleteObject"}, objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, creds auth.Credentials, t *testing.T) {
|
||||
testReplicationMRFMarkerRecovery(t, obj, backend, bucket, router, creds, tc)
|
||||
}})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type markerRecoveryTarget struct {
|
||||
arn, bucket string
|
||||
client *minio.Client
|
||||
reject, loseReply atomic.Bool
|
||||
deletes atomic.Int32
|
||||
}
|
||||
|
||||
// Only adapt host capacity accounting, using the existing real-disk adapter.
|
||||
// Reads, object metadata, MRF files and writes all still use the fixture disks.
|
||||
func replicationTestCapacity(obj ObjectLayer) func() {
|
||||
var restore []func()
|
||||
for _, pool := range obj.(*erasureServerPools).serverPools {
|
||||
for _, set := range pool.sets {
|
||||
original := set.getDisks
|
||||
disks := append([]StorageAPI(nil), original()...)
|
||||
for i, disk := range disks {
|
||||
if disk != nil {
|
||||
disks[i] = tagTestCapacityDisk{StorageAPI: disk}
|
||||
}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return disks }
|
||||
restore = append(restore, func() { set.getDisks = original })
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
for _, fn := range restore {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testReplicationMRFMarkerRecovery(t *testing.T, obj ObjectLayer, backend, bucket string, router http.Handler, creds auth.Credentials, tc markerRecoveryCase) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
defer replicationTestCapacity(obj)()
|
||||
stats := NewReplicationStats(ctx, nil)
|
||||
oldStats := globalReplicationStats.Swap(stats)
|
||||
defer globalReplicationStats.Store(oldStats)
|
||||
oldPool := globalReplicationPool
|
||||
defer func() { globalReplicationPool = oldPool }()
|
||||
const name = "marker"
|
||||
version := mustGetUUID()
|
||||
creationTime := UTCNow().Add(-time.Hour).Truncate(time.Second)
|
||||
if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := obj.PutObject(ctx, bucket, name, mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""), ObjectOptions{Versioned: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var targets []*markerRecoveryTarget
|
||||
cfg := replication.Config{}
|
||||
creationStates := make(map[string]replication.StatusType)
|
||||
var creationInternal string
|
||||
for i := 0; i < tc.targets; i++ {
|
||||
target := &markerRecoveryTarget{arn: "arn:minio:replication::" + mustGetUUID() + ":bucket", bucket: getRandomBucketName()}
|
||||
if err := obj.MakeBucket(ctx, target.bucket, MakeBucketOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := obj.PutObject(ctx, target.bucket, name, mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""), ObjectOptions{Versioned: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tc.creation {
|
||||
opts := ObjectOptions{VersionID: version, Versioned: true, DeleteMarker: true, ReplicationRequest: true, MTime: creationTime}
|
||||
opts.SetReplicaStatus(replication.Replica)
|
||||
if _, err := obj.DeleteObject(ctx, target.bucket, name, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
opts := ObjectOptions{VersionID: r.URL.Query().Get("versionId"), Versioned: true}
|
||||
switch r.Method {
|
||||
case http.MethodHead:
|
||||
oi, err := obj.GetObjectInfo(r.Context(), target.bucket, name, opts)
|
||||
if oi.DeleteMarker {
|
||||
w.Header().Set(xhttp.AmzDeleteMarker, "true")
|
||||
w.Header().Set(xhttp.AmzVersionID, oi.VersionID)
|
||||
}
|
||||
if err != nil {
|
||||
writeErrorResponseHeadersOnly(w, toAPIError(r.Context(), err))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case http.MethodDelete:
|
||||
target.deletes.Add(1)
|
||||
if got := r.Header.Get(xhttp.MinIOSourceDeleteMarker) == "true"; got != tc.creation {
|
||||
t.Errorf("purge/creation wire flag=%v creation=%v", got, tc.creation)
|
||||
}
|
||||
if opts.VersionID == "" {
|
||||
t.Error("missing remote versionId")
|
||||
}
|
||||
if target.reject.Load() {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprint(w, `<Error><Code>AccessDenied</Code></Error>`)
|
||||
return
|
||||
}
|
||||
opts.DeleteMarker = r.Header.Get(xhttp.MinIOSourceDeleteMarker) == "true"
|
||||
opts.ReplicationRequest = true
|
||||
opts.SetReplicaStatus(replication.Replica)
|
||||
_, err := obj.DeleteObject(r.Context(), target.bucket, name, opts)
|
||||
if err != nil && !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
||||
writeErrorResponse(r.Context(), w, toAPIError(r.Context(), err), r.URL)
|
||||
return
|
||||
}
|
||||
if target.loseReply.Swap(false) {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Errorf("unexpected remote method %s", r.Method)
|
||||
}
|
||||
}))
|
||||
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)
|
||||
}
|
||||
target.client = client
|
||||
globalBucketTargetSys.Lock()
|
||||
globalBucketTargetSys.arnRemotesMap[target.arn] = arnTarget{Client: &TargetClient{Client: client, ARN: target.arn, Bucket: target.bucket}, lastRefresh: UTCNow()}
|
||||
globalBucketTargetSys.targetsMap[bucket] = append(globalBucketTargetSys.targetsMap[bucket], madmin.BucketTarget{Arn: target.arn, TargetBucket: target.bucket})
|
||||
globalBucketTargetSys.Unlock()
|
||||
globalBucketTargetSys.hMutex.Lock()
|
||||
globalBucketTargetSys.hc[client.EndpointURL().Host] = epHealth{Online: true}
|
||||
globalBucketTargetSys.hMutex.Unlock()
|
||||
rule := configs[0].Rules[0]
|
||||
rule.Priority = i + 1
|
||||
rule.Destination = replication.Destination{ARN: target.arn, Bucket: target.bucket}
|
||||
cfg.Rules = append(cfg.Rules, rule)
|
||||
creationStates[target.arn] = replication.Completed
|
||||
creationInternal += target.arn + "=COMPLETED;"
|
||||
targets = append(targets, target)
|
||||
}
|
||||
if tc.targets == 1 {
|
||||
cfg.RoleArn = targets[0].arn
|
||||
}
|
||||
if !tc.creation {
|
||||
opts := ObjectOptions{VersionID: version, Versioned: true, DeleteMarker: true, MTime: creationTime, DeleteReplication: ReplicationState{Targets: creationStates, ReplicationStatusInternal: creationInternal, ReplicationTimeStamp: creationTime}}
|
||||
if tc.replicaSource {
|
||||
opts.DeleteReplication = ReplicationState{}
|
||||
opts.SetReplicaStatus(replication.Replica)
|
||||
}
|
||||
if _, err := obj.DeleteObject(ctx, bucket, name, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
meta, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.replicationConfig = &cfg
|
||||
globalBucketMetadataSys.Set(bucket, meta)
|
||||
newPool := func() *ReplicationPool {
|
||||
p := &ReplicationPool{ctx: ctx, objLayer: obj, workers: []chan ReplicationWorkerOperation{make(chan ReplicationWorkerOperation, 8)}, stats: stats, mrfSaveCh: make(chan MRFReplicateEntry, 8)}
|
||||
globalReplicationPool = once.NewSingleton[ReplicationPool]()
|
||||
globalReplicationPool.Set(p)
|
||||
return p
|
||||
}
|
||||
p := newPool()
|
||||
receive := func() DeletedObjectReplicationInfo {
|
||||
select {
|
||||
case op := <-p.workers[0]:
|
||||
d, ok := op.(DeletedObjectReplicationInfo)
|
||||
if !ok {
|
||||
t.Fatalf("wrong queued operation %T", op)
|
||||
}
|
||||
return d
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("no marker task from actual MRF/handler/scanner queue")
|
||||
return DeletedObjectReplicationInfo{}
|
||||
}
|
||||
}
|
||||
uri := "/" + bucket + "/" + name
|
||||
if !tc.creation {
|
||||
uri += "?versionId=" + version
|
||||
}
|
||||
req, err := newTestSignedRequestV4(http.MethodDelete, uri, 0, nil, creds.AccessKey, creds.SecretKey, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("source DELETE status %d: %s", w.Code, w.Body)
|
||||
}
|
||||
deletion := receive()
|
||||
if tc.creation {
|
||||
version = deletion.DeleteMarkerVersionID
|
||||
} else if deletion.VersionID != version || deletion.DeleteMarkerVersionID != "" {
|
||||
t.Fatalf("current producer emitted noncanonical purge: %+v", deletion)
|
||||
}
|
||||
if tc.unrecordedPurge {
|
||||
// Exercise a task carrying purge state while the disk marker still has
|
||||
// only creation metadata. Recreate only this isolated fixture marker.
|
||||
if _, err := obj.DeleteObject(ctx, bucket, name, ObjectOptions{VersionID: version, Versioned: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := ObjectOptions{VersionID: version, Versioned: true, DeleteMarker: true, MTime: creationTime, DeleteReplication: ReplicationState{Targets: creationStates, ReplicationStatusInternal: creationInternal, ReplicationTimeStamp: creationTime}}
|
||||
if _, err := obj.DeleteObject(ctx, bucket, name, opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if tc.legacy {
|
||||
deletion.VersionID, deletion.DeleteMarkerVersionID = "", version
|
||||
}
|
||||
if tc.partial {
|
||||
deletion.TargetArn = targets[len(targets)-1].arn
|
||||
}
|
||||
if tc.invalidMRF {
|
||||
testReplicationMRFInvalidLookups(ctx, t, obj, p, newPool, deletion)
|
||||
return
|
||||
}
|
||||
var auditStatuses <-chan string
|
||||
if tc.name == "canonical" {
|
||||
var stopAudit func()
|
||||
auditStatuses, stopAudit = replicationTestAudit(ctx, t, bucket)
|
||||
defer stopAudit()
|
||||
}
|
||||
assertAudit := func(want string) {
|
||||
if auditStatuses == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case got := <-auditStatuses:
|
||||
if got != want {
|
||||
t.Fatalf("replication audit status=%q want %q", got, want)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("missing replication audit event")
|
||||
}
|
||||
}
|
||||
deletion.OpType = replication.HealReplicationType // Exercise operation-status statistics too.
|
||||
failing := targets[len(targets)-1]
|
||||
failing.reject.Store(!tc.lostReply)
|
||||
failing.loseReply.Store(tc.lostReply)
|
||||
if tc.offline {
|
||||
globalBucketTargetSys.hMutex.Lock()
|
||||
globalBucketTargetSys.hc[failing.client.EndpointURL().Host] = epHealth{Online: false}
|
||||
globalBucketTargetSys.hMutex.Unlock()
|
||||
}
|
||||
before, _ := obj.GetObjectInfo(ctx, bucket, name, ObjectOptions{VersionID: version})
|
||||
beforeCreation := before.ReplicationStatusInternal
|
||||
beforeStamp := before.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp]
|
||||
beforeReplica := before.UserDefined[ReservedMetadataPrefixLower+ReplicaStatus]
|
||||
beforeReplicaStamp := before.UserDefined[ReservedMetadataPrefixLower+ReplicaTimestamp]
|
||||
if !tc.creation && !tc.replicaSource && (len(replicationStatusesMap(beforeCreation)) != tc.targets || beforeStamp == "") {
|
||||
t.Fatalf("missing seeded creation block: %+v", before)
|
||||
}
|
||||
if tc.replicaSource && (beforeReplica != "REPLICA" || beforeReplicaStamp == "") {
|
||||
t.Fatalf("missing replica block: %+v", before)
|
||||
}
|
||||
updateObj := obj
|
||||
if !tc.creation {
|
||||
updateObj = markerPurgeUpdateLayer{ObjectLayer: obj, t: t}
|
||||
}
|
||||
rounds := 2
|
||||
if tc.lostReply {
|
||||
rounds = 1
|
||||
}
|
||||
if tc.exhaust {
|
||||
rounds = mrfRetryLimit + 1
|
||||
}
|
||||
for round := 1; round <= rounds; round++ {
|
||||
callObj := updateObj
|
||||
if tc.lockFirst && round == 1 {
|
||||
callObj = markerLockFailureLayer{ObjectLayer: updateObj}
|
||||
}
|
||||
result := replicateDelete(ctx, deletion, callObj)
|
||||
switch {
|
||||
case tc.lockFirst && round == 1:
|
||||
if len(result.Targets) != 0 {
|
||||
t.Fatal("lock failure attempted a target")
|
||||
}
|
||||
case tc.creation:
|
||||
if result.ReplicationStatus() != replication.Failed {
|
||||
t.Fatalf("creation result: %+v", result)
|
||||
}
|
||||
case result.VersionPurgeStatus() != replication.VersionPurgeFailed:
|
||||
t.Fatalf("purge failure result: %+v", result)
|
||||
}
|
||||
assertAudit("FAILED")
|
||||
if round == 1 && !tc.lockFirst && !tc.creation {
|
||||
stats.RLock()
|
||||
failed := stats.Cache[bucket].Stats[failing.arn].FailStats.SinceUptime
|
||||
stats.RUnlock()
|
||||
if failed.Count != 1 || failed.Bytes != 0 {
|
||||
t.Fatalf("purge failure stats=%+v, want count 1 and zero bytes", failed)
|
||||
}
|
||||
}
|
||||
oi, err := obj.GetObjectInfo(ctx, bucket, name, ObjectOptions{VersionID: version})
|
||||
if !isErrMethodNotAllowed(err) || !oi.DeleteMarker {
|
||||
t.Fatalf("source marker metadata/405 missing: %+v %v", oi, err)
|
||||
}
|
||||
if !tc.creation && (oi.ReplicationStatusInternal != beforeCreation || oi.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] != beforeStamp) {
|
||||
t.Fatalf("purge rewrote creation block: before=%q/%q after=%q/%q", beforeCreation, beforeStamp, oi.ReplicationStatusInternal, oi.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp])
|
||||
}
|
||||
if !tc.creation && (oi.UserDefined[ReservedMetadataPrefixLower+ReplicaStatus] != beforeReplica || oi.UserDefined[ReservedMetadataPrefixLower+ReplicaTimestamp] != beforeReplicaStamp) {
|
||||
t.Fatal("purge rewrote replica block")
|
||||
}
|
||||
if tc.targets == 2 && !tc.partial {
|
||||
purges := versionPurgeStatusesMap(oi.VersionPurgeStatusInternal)
|
||||
if purges[targets[0].arn] != replication.VersionPurgeComplete || purges[failing.arn] != replication.VersionPurgeFailed {
|
||||
t.Fatalf("incorrect persisted target states: %v", purges)
|
||||
}
|
||||
if targets[0].deletes.Load() != 1 {
|
||||
t.Fatalf("successful target resent %d times", targets[0].deletes.Load())
|
||||
}
|
||||
}
|
||||
if tc.partial {
|
||||
select {
|
||||
case <-p.mrfSaveCh:
|
||||
default:
|
||||
t.Fatal("partial failure missing MRF")
|
||||
}
|
||||
dsc := deletion.ReplicationState.ReplicateDecisionStr
|
||||
deletion.ReplicationState = oi.ReplicationState()
|
||||
deletion.ReplicationState.ReplicateDecisionStr = dsc
|
||||
continue
|
||||
}
|
||||
if tc.exhaust && round > mrfRetryLimit {
|
||||
if len(p.mrfSaveCh) != 0 || atomic.LoadUint64(&stats.mrfStats.TotalDroppedCount) != 1 {
|
||||
t.Fatalf("retry budget not applied: queued=%d drops=%d", len(p.mrfSaveCh), stats.mrfStats.TotalDroppedCount)
|
||||
}
|
||||
break
|
||||
}
|
||||
var entry MRFReplicateEntry
|
||||
select {
|
||||
case entry = <-p.mrfSaveCh:
|
||||
default:
|
||||
t.Fatal("failure did not enter MRF")
|
||||
}
|
||||
if entry.RetryCount != round || entry.versionID != version {
|
||||
t.Fatalf("MRF entry lost identity/budget: %+v, round=%d", entry, round)
|
||||
}
|
||||
p.saveMRFEntries(ctx, map[string]MRFReplicateEntry{entry.versionID: entry})
|
||||
record, err := p.loadMRF()
|
||||
if err != nil || len(record.Entries) != 1 {
|
||||
t.Fatalf("disk MRF missing: %+v %v", record, err)
|
||||
}
|
||||
if got := record.Entries[version]; got.RetryCount != round || got.Object != name || got.Bucket != bucket {
|
||||
t.Fatalf("disk MRF mismatch: %+v", got)
|
||||
}
|
||||
p.saveMRFEntries(ctx, record.Entries) // loadMRF consumes the file; each replay uses a fresh disk record.
|
||||
p = newPool() // no in-memory entries carried to the replacement pool.
|
||||
if err := p.queueMRFHeal(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deletion = receive()
|
||||
if deletion.RetryCount != round {
|
||||
t.Fatalf("MRF retry count=%d want %d", deletion.RetryCount, round)
|
||||
}
|
||||
if !tc.creation && (deletion.VersionID != version || deletion.DeleteMarkerVersionID != "") {
|
||||
t.Fatalf("MRF emitted wrong purge: %+v", deletion)
|
||||
}
|
||||
}
|
||||
if tc.partial {
|
||||
return
|
||||
}
|
||||
failing.reject.Store(false)
|
||||
globalBucketTargetSys.hMutex.Lock()
|
||||
globalBucketTargetSys.hc[failing.client.EndpointURL().Host] = epHealth{Online: true}
|
||||
globalBucketTargetSys.hMutex.Unlock()
|
||||
if tc.exhaust {
|
||||
oi, _ := obj.GetObjectInfo(ctx, bucket, name, ObjectOptions{VersionID: version})
|
||||
QueueReplicationHeal(ctx, bucket, oi, 0) // Separately prove the existing scanner fallback after MRF exhaustion.
|
||||
deletion = receive()
|
||||
if deletion.RetryCount != 0 {
|
||||
t.Fatal("scanner did not start a fresh retry budget")
|
||||
}
|
||||
}
|
||||
result := replicateDelete(ctx, deletion, updateObj)
|
||||
assertAudit("COMPLETED")
|
||||
if tc.creation {
|
||||
if result.ReplicationStatus() != replication.Completed {
|
||||
t.Fatalf("creation recovery failed: %+v", result)
|
||||
}
|
||||
} else if result.VersionPurgeStatus() != replication.VersionPurgeComplete {
|
||||
t.Fatalf("purge recovery failed: %+v", result)
|
||||
}
|
||||
for _, b := range append([]string{bucket}, func() []string {
|
||||
var b []string
|
||||
for _, target := range targets {
|
||||
b = append(b, target.bucket)
|
||||
}
|
||||
return b
|
||||
}()...) {
|
||||
oi, err := obj.GetObjectInfo(ctx, b, name, ObjectOptions{VersionID: version})
|
||||
if tc.creation {
|
||||
if !oi.DeleteMarker || !isErrMethodNotAllowed(err) {
|
||||
t.Fatalf("creation missing in %s: %+v %v", b, oi, err)
|
||||
}
|
||||
} else if !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
||||
t.Fatalf("purge left marker in %s: %+v %v", b, oi, err)
|
||||
}
|
||||
}
|
||||
if !tc.creation {
|
||||
// Re-deliver an ambiguous purge directly. An absent marker must stay absent.
|
||||
duplicate := deletion
|
||||
duplicate.VersionID, duplicate.DeleteMarkerVersionID = "", version
|
||||
duplicate.ReplicationState.PurgeTargets = map[string]VersionPurgeStatusType{failing.arn: replication.VersionPurgePending}
|
||||
duplicate.ReplicationState.VersionPurgeStatusInternal = ""
|
||||
got := replicateDeleteToTarget(ctx, duplicate, &TargetClient{Client: failing.client, ARN: failing.arn, Bucket: failing.bucket})
|
||||
if got.VersionPurgeStatus != replication.VersionPurgeComplete {
|
||||
t.Fatalf("duplicate purge: %+v", got)
|
||||
}
|
||||
if oi, err := obj.GetObjectInfo(ctx, failing.bucket, name, ObjectOptions{VersionID: version}); !isErrVersionNotFound(err) && !isErrObjectNotFound(err) {
|
||||
t.Fatalf("duplicate recreated marker: %+v %v", oi, err)
|
||||
}
|
||||
}
|
||||
if len(p.mrfSaveCh) != 0 || len(p.workers[0]) != 0 {
|
||||
t.Fatalf("completion left queued work: mrf=%d worker=%d", len(p.mrfSaveCh), len(p.workers[0]))
|
||||
}
|
||||
if !tc.creation && !tc.exhaust && !tc.lostReply {
|
||||
stats.RLock()
|
||||
got := stats.Cache[bucket].Stats[failing.arn].ReplicatedCount
|
||||
size := stats.Cache[bucket].Stats[failing.arn].ReplicatedSize
|
||||
stats.RUnlock()
|
||||
if got != 1 || size != 0 {
|
||||
t.Fatalf("purge completion statistic=%d want 1", got)
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %s recovered; %d target(s), persisted MRF, source/target metadata checked", backend, tc.name, tc.targets)
|
||||
}
|
||||
|
||||
func TestReplicationDeleteQueueFullRetryBudget(t *testing.T) {
|
||||
stats := NewReplicationStats(t.Context(), nil)
|
||||
p := &ReplicationPool{ctx: t.Context(), objLayer: &replicationMRFTestObjectLayer{}, workers: []chan ReplicationWorkerOperation{make(chan ReplicationWorkerOperation)}, stats: stats, mrfSaveCh: make(chan MRFReplicateEntry, 1), priority: "slow"}
|
||||
d := DeletedObjectReplicationInfo{Bucket: "bucket", DeletedObject: DeletedObject{ObjectName: "marker", VersionID: mustGetUUID()}, RetryCount: 2}
|
||||
p.queueReplicaDeleteTask(d)
|
||||
if got := <-p.mrfSaveCh; got.RetryCount != 3 {
|
||||
t.Fatalf("queue-full retry count=%d", got.RetryCount)
|
||||
}
|
||||
d.RetryCount = mrfRetryLimit
|
||||
p.queueReplicaDeleteTask(d)
|
||||
if len(p.mrfSaveCh) != 0 || atomic.LoadUint64(&stats.mrfStats.TotalDroppedCount) != 1 {
|
||||
t.Fatal("queue-full retry exceeded budget without a visible drop")
|
||||
}
|
||||
}
|
||||
|
||||
type markerLockFailureLayer struct{ ObjectLayer }
|
||||
|
||||
func (markerLockFailureLayer) NewNSLock(string, ...string) RWLocker { return markerFailedLock{} }
|
||||
|
||||
type markerFailedLock struct{ RWLocker }
|
||||
|
||||
func (markerFailedLock) GetLock(context.Context, *dynamicTimeout) (LockContext, error) {
|
||||
return LockContext{}, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
type markerLookupLayer struct {
|
||||
ObjectLayer
|
||||
mutate func(ObjectInfo, error) (ObjectInfo, error)
|
||||
lookedUp chan struct{}
|
||||
}
|
||||
|
||||
func (l markerLookupLayer) GetObjectInfo(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
oi, err := l.ObjectLayer.GetObjectInfo(ctx, bucket, object, opts)
|
||||
defer close(l.lookedUp)
|
||||
return l.mutate(oi, err)
|
||||
}
|
||||
|
||||
func testReplicationMRFInvalidLookups(ctx context.Context, t *testing.T, obj ObjectLayer, p *ReplicationPool, newPool func() *ReplicationPool, d DeletedObjectReplicationInfo) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mutate func(ObjectInfo, error) (ObjectInfo, error)
|
||||
}{
|
||||
{"empty-info", func(_ ObjectInfo, e error) (ObjectInfo, error) { return ObjectInfo{}, e }},
|
||||
{"not-a-marker", func(o ObjectInfo, e error) (ObjectInfo, error) { o.DeleteMarker = false; return o, e }},
|
||||
{"wrong-version", func(o ObjectInfo, e error) (ObjectInfo, error) { o.VersionID = mustGetUUID(); return o, e }},
|
||||
{"wrong-bucket", func(o ObjectInfo, e error) (ObjectInfo, error) { o.Bucket = "different-bucket"; return o, e }},
|
||||
{"wrong-object", func(o ObjectInfo, e error) (ObjectInfo, error) { o.Name = "different-object"; return o, e }},
|
||||
{"zero-modtime", func(o ObjectInfo, e error) (ObjectInfo, error) { o.ModTime = time.Time{}; return o, e }},
|
||||
{"missing", func(o ObjectInfo, _ error) (ObjectInfo, error) {
|
||||
return o, ObjectNotFound{Bucket: o.Bucket, Object: o.Name}
|
||||
}},
|
||||
{"read-error", func(o ObjectInfo, _ error) (ObjectInfo, error) { return o, InsufficientReadQuorum{} }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := d.ToMRFEntry()
|
||||
p.saveMRFEntries(ctx, map[string]MRFReplicateEntry{entry.versionID: entry})
|
||||
fresh := newPool()
|
||||
looked := make(chan struct{})
|
||||
fresh.objLayer = markerLookupLayer{ObjectLayer: obj, mutate: tc.mutate, lookedUp: looked}
|
||||
if err := fresh.queueMRFHeal(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-looked:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("disk MRF entry was not read")
|
||||
}
|
||||
select {
|
||||
case op := <-fresh.workers[0]:
|
||||
t.Fatalf("invalid lookup scheduled %T", op)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the actual internal audit event through the supported webhook sink.
|
||||
func replicationTestAudit(ctx context.Context, t *testing.T, bucket string) (<-chan string, func()) {
|
||||
t.Helper()
|
||||
if len(logger.AuditTargets()) != 0 {
|
||||
t.Fatal("unexpected pre-existing test audit targets")
|
||||
}
|
||||
statuses := make(chan string, 16)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var entry struct {
|
||||
API struct{ Name, Bucket, Status string }
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&entry); err != nil {
|
||||
t.Error(err)
|
||||
} else if entry.API.Name == ReplicateDeleteAPI && entry.API.Bucket == bucket {
|
||||
statuses <- entry.API.Status
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
endpoint, err := xnet.ParseHTTPURL(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if errs := logger.UpdateAuditWebhooks(ctx, map[string]loghttp.Config{"r6": {Enabled: true, Name: "r6", Endpoint: endpoint, BatchSize: 1, QueueSize: 128, MaxRetry: 1, RetryIntvl: time.Millisecond, HTTPTimeout: time.Second}}); len(errs) > 0 {
|
||||
t.Fatal(errs)
|
||||
}
|
||||
targets := logger.AuditTargets()
|
||||
return statuses, func() {
|
||||
logger.UpdateAuditWebhooks(ctx, nil)
|
||||
for _, target := range targets {
|
||||
target.Cancel()
|
||||
}
|
||||
server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type markerPurgeUpdateLayer struct {
|
||||
ObjectLayer
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (l markerPurgeUpdateLayer) DeleteObject(ctx context.Context, bucket, object string, opts ObjectOptions) (ObjectInfo, error) {
|
||||
l.t.Helper()
|
||||
rs := opts.DeleteReplication
|
||||
if rs.ReplicationStatusInternal != "" || rs.Targets != nil || rs.ReplicaStatus != "" || !rs.CompositeReplicationStatus().Empty() {
|
||||
l.t.Fatalf("purge has a nonempty creation update: %+v", rs)
|
||||
}
|
||||
if rs.CompositeVersionPurgeStatus().Empty() {
|
||||
l.t.Fatal("purge update lost its purge status")
|
||||
}
|
||||
return l.ObjectLayer.DeleteObject(ctx, bucket, object, opts)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// 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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
// Purge uses the version-delete wire form regardless of the in-memory shape.
|
||||
// Creation status, purge status and successful resync markers are independent.
|
||||
func TestReplicateDeleteOperationExits(t *testing.T) {
|
||||
for _, shape := range []string{"marker-creation", "legacy-marker-purge", "marker-purge", "object-purge"} {
|
||||
purge := shape != "marker-creation"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
creation replication.StatusType
|
||||
priorPurge VersionPurgeStatusType
|
||||
headCode, deleteCode int
|
||||
headError string
|
||||
offline, resync bool
|
||||
}{
|
||||
{name: "pending-success", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 204},
|
||||
{name: "completed-creation", creation: replication.Completed, priorPurge: replication.VersionPurgePending, headCode: 405, deleteCode: 204},
|
||||
{name: "existing-marker", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 405, deleteCode: 204},
|
||||
// Use the quorum S3 code without 503, which is classified as backend-down first.
|
||||
{name: "head-read-quorum", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 400, headError: "SlowDownRead", deleteCode: 204},
|
||||
{name: "replica-creation-status", creation: replication.Replica, priorPurge: replication.VersionPurgeFailed, headCode: 405, deleteCode: 204},
|
||||
{name: "head-unavailable", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 503, deleteCode: 204},
|
||||
{name: "head-forbidden", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 403, deleteCode: 204},
|
||||
{name: "delete-forbidden", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 403},
|
||||
{name: "delete-method-rejected", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 405},
|
||||
{name: "delete-unavailable", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 503},
|
||||
{name: "offline", creation: replication.Pending, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 204, offline: true},
|
||||
{name: "retry-failed", creation: replication.Completed, priorPurge: replication.VersionPurgeFailed, headCode: 404, deleteCode: 204},
|
||||
{name: "purge-complete", creation: replication.Pending, priorPurge: replication.VersionPurgeComplete, headCode: 404, deleteCode: 403},
|
||||
{name: "resync-success", creation: replication.Completed, priorPurge: replication.VersionPurgePending, headCode: 405, deleteCode: 204, resync: true},
|
||||
{name: "resync-already-purged", creation: replication.Pending, priorPurge: replication.VersionPurgeComplete, headCode: 404, deleteCode: 403, resync: true},
|
||||
{name: "resync-failure", creation: replication.Completed, priorPurge: replication.VersionPurgePending, headCode: 404, deleteCode: 403, resync: true},
|
||||
} {
|
||||
t.Run(shape+"/"+tc.name, func(t *testing.T) {
|
||||
version := mustGetUUID()
|
||||
var heads, deletes atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("versionId") != version {
|
||||
t.Errorf("wrong request version: %s", r.URL)
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodHead:
|
||||
heads.Add(1)
|
||||
w.Header().Set(xhttp.AmzVersionID, version)
|
||||
w.Header().Set(xhttp.LastModified, time.Now().UTC().Format(http.TimeFormat))
|
||||
if tc.headCode == 405 {
|
||||
w.Header().Set(xhttp.AmzDeleteMarker, "true")
|
||||
}
|
||||
if tc.headError != "" {
|
||||
w.Header().Set("x-minio-error-code", tc.headError)
|
||||
}
|
||||
w.WriteHeader(tc.headCode)
|
||||
case http.MethodDelete:
|
||||
deletes.Add(1)
|
||||
if got := r.Header.Get(xhttp.MinIOSourceDeleteMarker) == "true"; got == purge {
|
||||
t.Errorf("source delete-marker header=%v, purge=%v", got, purge)
|
||||
}
|
||||
w.WriteHeader(tc.deleteCode)
|
||||
if tc.deleteCode != 204 {
|
||||
fmt.Fprintf(w, `<Error><Code>%s</Code><Message>injected rejection</Message></Error>`, map[int]string{403: "AccessDenied", 405: "MethodNotAllowed", 503: "ServiceUnavailable"}[tc.deleteCode])
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected method %s", r.Method)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := minio.New(strings.TrimPrefix(server.URL, "http://"), &minio.Options{Region: "us-east-1", MaxRetries: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := globalBucketTargetSys
|
||||
globalBucketTargetSys = &BucketTargetSys{hc: map[string]epHealth{client.EndpointURL().Host: {Online: !tc.offline}}}
|
||||
defer func() { globalBucketTargetSys = old }()
|
||||
d := DeletedObjectReplicationInfo{Bucket: "source", DeletedObject: DeletedObject{ObjectName: "marker", DeleteMarker: shape != "object-purge"}}
|
||||
if shape == "marker-creation" || shape == "legacy-marker-purge" {
|
||||
d.DeleteMarkerVersionID = version
|
||||
} else {
|
||||
d.VersionID = version
|
||||
}
|
||||
d.ReplicationState.Targets = map[string]replication.StatusType{"arn1": tc.creation}
|
||||
d.ReplicationState.ResetStatusesMap = map[string]string{"arn1": "previous-reset"}
|
||||
if purge {
|
||||
d.ReplicationState.PurgeTargets = map[string]VersionPurgeStatusType{"arn1": tc.priorPurge}
|
||||
}
|
||||
if tc.resync {
|
||||
d.OpType = replication.ExistingObjectReplicationType
|
||||
}
|
||||
got := replicateDeleteToTarget(t.Context(), d, &TargetClient{Client: client, ARN: "arn1", Bucket: "target", ResetID: "current-reset"})
|
||||
var wantCreation replication.StatusType
|
||||
var wantPurge VersionPurgeStatusType
|
||||
var wantHeads, wantDeletes int32
|
||||
success := false
|
||||
if purge {
|
||||
wantCreation = ""
|
||||
wantPurge = replication.VersionPurgeComplete
|
||||
switch {
|
||||
case tc.priorPurge == replication.VersionPurgeComplete:
|
||||
success = true
|
||||
case tc.offline:
|
||||
wantPurge = replication.VersionPurgeFailed
|
||||
default:
|
||||
wantDeletes = 1
|
||||
success = tc.deleteCode == 204
|
||||
if !success {
|
||||
wantPurge = replication.VersionPurgeFailed
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case tc.creation == replication.Completed && !tc.resync:
|
||||
success = true
|
||||
case tc.offline:
|
||||
default:
|
||||
wantHeads = 1
|
||||
switch tc.headCode {
|
||||
case 405:
|
||||
success = true
|
||||
case 403, 503:
|
||||
default:
|
||||
wantDeletes = 1
|
||||
success = tc.deleteCode == 204
|
||||
}
|
||||
}
|
||||
if success {
|
||||
wantCreation = replication.Completed
|
||||
} else {
|
||||
wantCreation = replication.Failed
|
||||
}
|
||||
}
|
||||
if got.PrevReplicationStatus != tc.creation {
|
||||
t.Error("previous creation state changed")
|
||||
}
|
||||
if got.ReplicationStatus != wantCreation || got.VersionPurgeStatus != wantPurge {
|
||||
t.Errorf("status=%+v, want creation=%s purge=%s", got, wantCreation, wantPurge)
|
||||
}
|
||||
if heads.Load() != wantHeads || deletes.Load() != wantDeletes {
|
||||
t.Errorf("HEAD/DELETE=%d/%d, want %d/%d", heads.Load(), deletes.Load(), wantHeads, wantDeletes)
|
||||
}
|
||||
if (got.Err != nil) == success {
|
||||
t.Errorf("success=%v, error=%v", success, got.Err)
|
||||
}
|
||||
if tc.resync && success {
|
||||
if !strings.HasSuffix(got.ResyncTimestamp, ";current-reset") {
|
||||
t.Errorf("successful resync missing reset: %q", got.ResyncTimestamp)
|
||||
}
|
||||
} else if got.ResyncTimestamp != "previous-reset" {
|
||||
t.Errorf("unexpected reset: %q", got.ResyncTimestamp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplicateDeletePurgeMissingTargetState(t *testing.T) {
|
||||
// Classification belongs to the operation, even if this target has no
|
||||
// previous purge entry (another target supplies the tracked purge state).
|
||||
var deletes atomic.Int32
|
||||
version := mustGetUUID()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete || r.URL.Query().Get("versionId") != version || r.Header.Get(xhttp.MinIOSourceDeleteMarker) == "true" {
|
||||
t.Errorf("wrong purge request: %s %s %v", r.Method, r.URL, r.Header)
|
||||
}
|
||||
deletes.Add(1)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := minio.New(strings.TrimPrefix(server.URL, "http://"), &minio.Options{Region: "us-east-1", MaxRetries: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := globalBucketTargetSys
|
||||
globalBucketTargetSys = &BucketTargetSys{hc: map[string]epHealth{client.EndpointURL().Host: {Online: true}}}
|
||||
defer func() { globalBucketTargetSys = old }()
|
||||
d := DeletedObjectReplicationInfo{Bucket: "source", DeletedObject: DeletedObject{ObjectName: "marker", DeleteMarker: true, DeleteMarkerVersionID: version, ReplicationState: ReplicationState{Targets: map[string]replication.StatusType{"arn1": replication.Completed}, PurgeTargets: map[string]VersionPurgeStatusType{"arn2": replication.VersionPurgePending}}}}
|
||||
got := replicateDeleteToTarget(t.Context(), d, &TargetClient{Client: client, ARN: "arn1", Bucket: "target", ResetID: "reset"})
|
||||
if deletes.Load() != 1 || got.VersionPurgeStatus != replication.VersionPurgeComplete || got.ReplicationStatus != "" {
|
||||
t.Fatalf("operation misclassified: %+v, deletes=%d", got, deletes.Load())
|
||||
}
|
||||
got.ResyncTimestamp = "resync;reset"
|
||||
state := getReplicationState(replicatedInfos{Targets: []replicatedTargetInfo{got}}, ReplicationState{}, "")
|
||||
if state.ResetStatusesMap[targetResetHeader("arn1")] != got.ResyncTimestamp {
|
||||
t.Fatal("resync timestamp not preserved with nil reset map")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 PGSTY
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
func TestExtractReplicationMetadataPreservesNormalizedMetadata(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
wire []string
|
||||
want string
|
||||
}{
|
||||
{name: "absent"},
|
||||
{name: "transport-only", wire: []string{"aws-chunked"}},
|
||||
{name: "mixed", wire: []string{"aws-chunked,gzip"}, want: "gzip"},
|
||||
{name: "gzip", wire: []string{"gzip"}, want: "gzip"},
|
||||
{name: "transport-last", wire: []string{"gzip,aws-chunked"}, want: "gzip"},
|
||||
{name: "multiple-values", wire: []string{"aws-chunked", "gzip"}, want: "gzip"},
|
||||
// Preserve the existing exact-token grammar; whitespace is not normalized here.
|
||||
{name: "space-before-gzip", wire: []string{"aws-chunked, gzip"}, want: " gzip"},
|
||||
{name: "space-before-transport", wire: []string{"gzip, aws-chunked"}, want: "gzip, aws-chunked"},
|
||||
} {
|
||||
for _, lowercase := range []bool{false, true} {
|
||||
name := tc.name + "/canonical"
|
||||
if lowercase {
|
||||
name = tc.name + "/lowercase"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
header := http.Header{
|
||||
"Content-Type": []string{"application/octet-stream"},
|
||||
"X-Amz-Meta-Source": []string{"raw"},
|
||||
"X-Minio-Replication-Server-Side-Encryption-Sealed-Key": []string{"sealed-key"},
|
||||
"X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm": []string{"DAREv2-HMAC-SHA256"},
|
||||
"X-Minio-Replication-Server-Side-Encryption-Iv": []string{"iv"},
|
||||
"X-Minio-Replication-Encrypted-Multipart": []string{""},
|
||||
"X-Minio-Replication-Actual-Object-Size": []string{"1"},
|
||||
ReplicationSsecChecksumHeader: []string{"checksum"},
|
||||
xhttp.AmzMetaUnencryptedContentLength: []string{"injected-length"},
|
||||
xhttp.AmzMetaUnencryptedContentMD5: []string{"injected-md5"},
|
||||
}
|
||||
if tc.wire != nil {
|
||||
header[xhttp.ContentEncoding] = tc.wire
|
||||
}
|
||||
if lowercase {
|
||||
h := make(http.Header, len(header))
|
||||
for k, v := range header {
|
||||
h[strings.ToLower(k)] = v
|
||||
}
|
||||
header = h
|
||||
}
|
||||
metadata, err := extractMetadata(t.Context(), textproto.MIMEHeader(header))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata["content-encoding"] != tc.want {
|
||||
t.Fatalf("ordinary encoding=%q want=%q", metadata["content-encoding"], tc.want)
|
||||
}
|
||||
for _, internal := range replicationToInternalHeaders {
|
||||
if _, ok := metadata[internal]; ok {
|
||||
t.Fatalf("ordinary request accepted internal field %s", internal)
|
||||
}
|
||||
}
|
||||
// Callers own ordinary metadata and may transform it after extraction.
|
||||
metadata["content-type"] = "application/wasm"
|
||||
for k := range metadata {
|
||||
if strings.EqualFold(k, "x-amz-meta-source") {
|
||||
metadata[k] = "caller"
|
||||
}
|
||||
}
|
||||
want := maps.Clone(metadata)
|
||||
maps.Copy(want, map[string]string{
|
||||
"X-Minio-Internal-Server-Side-Encryption-Sealed-Key": "sealed-key",
|
||||
"X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm": "DAREv2-HMAC-SHA256",
|
||||
"X-Minio-Internal-Server-Side-Encryption-Iv": "iv",
|
||||
"X-Minio-Internal-Encrypted-Multipart": "",
|
||||
"X-Minio-Internal-Actual-Object-Size": "1",
|
||||
ReplicationSsecChecksumHeader: "checksum",
|
||||
})
|
||||
for range 2 {
|
||||
if err := extractReplicationMetadataFromMime(t.Context(), textproto.MIMEHeader(header), metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(metadata, want) {
|
||||
t.Errorf("restoration changed normalized metadata: got %#v want %#v", metadata, want)
|
||||
}
|
||||
}
|
||||
if tc.want == "" {
|
||||
if _, present := metadata["content-encoding"]; present {
|
||||
t.Error("transport-only content-encoding key restored")
|
||||
}
|
||||
}
|
||||
for _, key := range []string{xhttp.AmzMetaUnencryptedContentLength, xhttp.AmzMetaUnencryptedContentMD5} {
|
||||
if _, present := caseInsensitiveMap(metadata).Lookup(key); present {
|
||||
t.Errorf("redacted metadata restored: %s", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractReplicationMetadataNilHeader(t *testing.T) {
|
||||
metadata := map[string]string{"content-type": "application/wasm"}
|
||||
want := maps.Clone(metadata)
|
||||
if err := extractReplicationMetadataFromMime(t.Context(), nil, metadata); err != errInvalidArgument {
|
||||
t.Fatalf("nil header: got %v want %v", err, errInvalidArgument)
|
||||
}
|
||||
if !reflect.DeepEqual(metadata, want) {
|
||||
t.Fatalf("nil input changed metadata: %#v", metadata)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
// Copyright (c) 2026 Feng Ruohang
|
||||
//
|
||||
// This file is part of Silo Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/kms"
|
||||
)
|
||||
|
||||
const r5TagStamp = ReservedMetadataPrefixLower + TaggingTimestamp
|
||||
|
||||
func r5Capacity(z *erasureServerPools) func() {
|
||||
var restores []func()
|
||||
for _, pool := range z.serverPools {
|
||||
for _, set := range pool.sets {
|
||||
old := set.getDisks
|
||||
disks := append([]StorageAPI(nil), old()...)
|
||||
for i := range disks {
|
||||
disks[i] = tagTestCapacityDisk{StorageAPI: disks[i]}
|
||||
}
|
||||
set.getDisks = func() []StorageAPI { return disks }
|
||||
restores = append(restores, func() { set.getDisks = old })
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
for _, restore := range restores {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func r5Request(t *testing.T, router http.Handler, cred auth.Credentials, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r, err := newTestSignedRequestV4(method, path, int64(len(body)), strings.NewReader(body), cred.AccessKey, cred.SecretKey, headers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func r5Stored(t *testing.T, obj interface {
|
||||
GetObjectInfo(context.Context, string, string, ObjectOptions) (ObjectInfo, error)
|
||||
}, bucket, name, vid, wantTags, wantStamp string,
|
||||
) ObjectInfo {
|
||||
t.Helper()
|
||||
oi, err := obj.GetObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: vid})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oi.UserTags != wantTags || oi.UserDefined[r5TagStamp] != wantStamp {
|
||||
t.Fatalf("%s(%s): tags=%q stamp=%q, want %q %q", name, vid, oi.UserTags, oi.UserDefined[r5TagStamp], wantTags, wantStamp)
|
||||
}
|
||||
return oi
|
||||
}
|
||||
|
||||
// The request bytes are signed and enter the real API and storage implementation.
|
||||
// Equal/stale retransmits may retain the existing 412 duplicate response.
|
||||
func r5Receive(t *testing.T, obj ObjectLayer, router http.Handler, cred auth.Credentials, bucket, operation string, source ObjectInfo, stamp string, afterInit func()) {
|
||||
t.Helper()
|
||||
opts, _, err := putReplicationOpts(t.Context(), "", source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if operation == "multipart" {
|
||||
opts.Internal.SourceMTime = time.Time{}
|
||||
}
|
||||
headers := make(map[string]string)
|
||||
for k, vs := range opts.Header() {
|
||||
if len(vs) > 0 {
|
||||
headers[k] = vs[0]
|
||||
}
|
||||
}
|
||||
if stamp == "" {
|
||||
delete(headers, http.CanonicalHeaderKey(xhttp.MinIOSourceTaggingTimestamp))
|
||||
delete(headers, xhttp.MinIOSourceTaggingTimestamp)
|
||||
} else {
|
||||
headers[http.CanonicalHeaderKey(xhttp.MinIOSourceTaggingTimestamp)] = stamp
|
||||
}
|
||||
path := "/" + bucket + "/" + source.Name + "?versionId=" + source.VersionID
|
||||
var w *httptest.ResponseRecorder
|
||||
switch operation {
|
||||
case "copy", "copy-default":
|
||||
maps.Copy(headers, getCopyObjMetadata(source, ""))
|
||||
headers[xhttp.AmzCopySource] = "/" + bucket + "/" + source.Name + "?versionId=" + source.VersionID
|
||||
if operation == "copy" {
|
||||
headers[xhttp.AmzMetadataDirective] = "REPLACE"
|
||||
}
|
||||
w = r5Request(t, router, cred, http.MethodPut, path, "", headers)
|
||||
case "put":
|
||||
w = r5Request(t, router, cred, http.MethodPut, path, "data", headers)
|
||||
case "multipart":
|
||||
w = r5Request(t, router, cred, http.MethodPost, path+"&uploads", "", headers)
|
||||
if w.Code == http.StatusPreconditionFailed {
|
||||
return
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("init: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var init struct {
|
||||
UploadID string `xml:"UploadId"`
|
||||
}
|
||||
if err := xml.Unmarshal(w.Body.Bytes(), &init); err != nil || init.UploadID == "" {
|
||||
t.Fatalf("init XML: %v %s", err, w.Body.String())
|
||||
}
|
||||
mi, err := obj.GetMultipartInfo(t.Context(), bucket, source.Name, init.UploadID, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stamp != "" && mi.UserDefined[r5TagStamp] != stamp {
|
||||
t.Fatalf("upload persisted stamp=%q, want %q", mi.UserDefined[r5TagStamp], stamp)
|
||||
}
|
||||
partPath := "/" + bucket + "/" + source.Name + "?uploadId=" + url.QueryEscape(init.UploadID)
|
||||
ph := map[string]string{xhttp.MinIOSourceReplicationRequest: "true"}
|
||||
part := r5Request(t, router, cred, http.MethodPut, partPath+"&partNumber=1", "data", ph)
|
||||
if part.Code != http.StatusOK {
|
||||
t.Fatalf("part: %d %s", part.Code, part.Body.String())
|
||||
}
|
||||
if afterInit != nil {
|
||||
afterInit()
|
||||
}
|
||||
body := "<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>" + canonicalizeETag(part.Header()[xhttp.ETag][0]) + "</ETag></Part></CompleteMultipartUpload>"
|
||||
ph[xhttp.MinIOSourceMTime] = source.ModTime.Format(time.RFC3339Nano)
|
||||
ph[xhttp.MinIOSourceETag] = source.ETag
|
||||
w = r5Request(t, router, cred, http.MethodPost, partPath, body, ph)
|
||||
}
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusPreconditionFailed {
|
||||
t.Fatalf("%s: %d %s", operation, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITaggingReplicationOrdering(t *testing.T) { r5APIOrdering(t, false) }
|
||||
func TestAPITaggingReplicationOrderingKMS(t *testing.T) { r5APIOrdering(t, true) }
|
||||
func r5APIOrdering(t *testing.T, encrypted bool) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
defer r5Capacity(obj.(*erasureServerPools))()
|
||||
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if encrypted {
|
||||
prev := GlobalKMS
|
||||
GlobalKMS = kms.NewStub("r5-tag-order")
|
||||
defer func() { GlobalKMS = prev }()
|
||||
sse := []byte(`<ServerSideEncryptionConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>aws:kms</SSEAlgorithm><KMSMasterKeyID>r5-tag-order</KMSMasterKeyID></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`)
|
||||
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketSSEConfig, sse); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
base := time.Now().UTC().Add(-5 * time.Hour)
|
||||
for _, op := range []string{"copy", "copy-default", "put", "multipart"} {
|
||||
for _, version := range []string{"uuid", "null"} {
|
||||
t.Run(instance+"/"+op+"/"+version, func(t *testing.T) {
|
||||
name := op + "-" + version
|
||||
vid := mustGetUUID()
|
||||
if version == "null" {
|
||||
vid = nullVersionID
|
||||
}
|
||||
original, err := obj.PutObject(t.Context(), bucket, name, mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{Versioned: true, VersionID: vid, UserDefined: map[string]string{xhttp.AmzObjectTagging: "key=original", r5TagStamp: base.Format(time.RFC3339Nano)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if op == "multipart" {
|
||||
// The production sender uses multipart only for multipart
|
||||
// sources. Preserve a real multipart ETag and part layout.
|
||||
metadata := maps.Clone(original.UserDefined)
|
||||
metadata[xhttp.AmzObjectTagging] = original.UserTags
|
||||
mp, err := obj.NewMultipartUpload(t.Context(), bucket, name, ObjectOptions{Versioned: true, VersionID: vid, UserDefined: metadata})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part, err := obj.PutObjectPart(t.Context(), bucket, name, mp.UploadID, 1, mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original, err = obj.CompleteMultipartUpload(t.Context(), bucket, name, mp.UploadID, []CompletePart{{PartNumber: 1, ETag: part.ETag}}, ObjectOptions{Versioned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A later unrelated version must not contribute tags to an explicitly addressed UUID/null version.
|
||||
latest, err := obj.PutObject(t.Context(), bucket, name, mustGetPutObjReader(t, strings.NewReader("other"), 5, "", ""), ObjectOptions{Versioned: true, UserDefined: map[string]string{xhttp.AmzObjectTagging: "key=latest", r5TagStamp: base.Add(10 * time.Hour).Format(time.RFC3339Nano)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, event := range []struct {
|
||||
name, tags string
|
||||
hours int
|
||||
wantTags string
|
||||
wantHours int
|
||||
}{
|
||||
{"delete", "", 3, "", 3}, {"stale", "key=stale", 2, "", 3}, {"equal-conflict", "key=conflict", 3, "", 3}, {"newer", "key=new", 4, "key=new", 4},
|
||||
} {
|
||||
t.Run(event.name, func(t *testing.T) {
|
||||
source := original
|
||||
source.VersionID = vid
|
||||
source.UserDefined = maps.Clone(original.UserDefined)
|
||||
source.UserTags = event.tags
|
||||
stamp := base.Add(time.Duration(event.hours) * time.Hour).Format(time.RFC3339Nano)
|
||||
source.UserDefined[r5TagStamp] = stamp
|
||||
r5Receive(t, obj, router, cred, bucket, op, source, stamp, nil)
|
||||
r5Stored(t, obj, bucket, name, vid, event.wantTags, base.Add(time.Duration(event.wantHours)*time.Hour).Format(time.RFC3339Nano))
|
||||
r5Stored(t, obj, bucket, name, latest.VersionID, "key=latest", base.Add(10*time.Hour).Format(time.RFC3339Nano))
|
||||
})
|
||||
}
|
||||
source := original
|
||||
source.VersionID = vid
|
||||
source.UserTags = "key=unversioned-event"
|
||||
r5Receive(t, obj, router, cred, bucket, op, source, "", nil)
|
||||
r5Stored(t, obj, bucket, name, vid, "key=new", base.Add(4*time.Hour).Format(time.RFC3339Nano))
|
||||
get := r5Request(t, router, cred, http.MethodGet, "/"+bucket+"/"+name+"?versionId="+vid, "", nil)
|
||||
if get.Code != http.StatusOK || get.Body.String() != "data" {
|
||||
t.Fatalf("plaintext GET: %d %q", get.Code, get.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}})
|
||||
}
|
||||
|
||||
func TestAPITaggingMultipartCommitRechecksRevision(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
defer r5Capacity(obj.(*erasureServerPools))()
|
||||
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Now().UTC().Add(-time.Hour)
|
||||
source := ObjectInfo{Name: "commit-recheck", VersionID: mustGetUUID(), ModTime: base, UserTags: "key=incoming", UserDefined: map[string]string{r5TagStamp: base.Format(time.RFC3339Nano)}}
|
||||
later := base.Add(time.Minute).Format(time.RFC3339Nano)
|
||||
r5Receive(t, obj, router, cred, bucket, "multipart", source, base.Format(time.RFC3339Nano), func() {
|
||||
_, err := obj.PutObject(t.Context(), bucket, source.Name, mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{Versioned: true, VersionID: source.VersionID, MTime: base, UserDefined: map[string]string{r5TagStamp: later}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
r5Stored(t, obj, bucket, source.Name, source.VersionID, "", later)
|
||||
t.Logf("%s: deletion committed between initiation and completion survived", instance)
|
||||
}})
|
||||
}
|
||||
|
||||
func TestAPILocalTaggingAlwaysAdvancesRevision(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
defer r5Capacity(obj.(*erasureServerPools))()
|
||||
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oi, err := obj.PutObject(t.Context(), bucket, "local-tags", mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{Versioned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := "/" + bucket + "/local-tags?tagging&versionId=" + oi.VersionID
|
||||
for n, method := range []string{http.MethodPut, http.MethodDelete, http.MethodDelete, http.MethodPut} {
|
||||
body := ""
|
||||
want := ""
|
||||
status := http.StatusNoContent
|
||||
if method == http.MethodPut {
|
||||
status = http.StatusOK
|
||||
body = "<Tagging><TagSet/></Tagging>"
|
||||
if n == 0 {
|
||||
want = "key=local"
|
||||
body = "<Tagging><TagSet><Tag><Key>key</Key><Value>local</Value></Tag></TagSet></Tagging>"
|
||||
}
|
||||
}
|
||||
before := time.Now().UTC()
|
||||
w := r5Request(t, router, cred, method, path, body, nil)
|
||||
if w.Code != status {
|
||||
t.Fatalf("%s: %d %s", method, w.Code, w.Body.String())
|
||||
}
|
||||
now, err := obj.GetObjectInfo(t.Context(), bucket, "local-tags", ObjectOptions{VersionID: oi.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stamp, err := time.Parse(time.RFC3339Nano, now.UserDefined[r5TagStamp])
|
||||
if err != nil || stamp.Before(before) || now.UserTags != want {
|
||||
t.Fatalf("local %s: tags=%q timestamp=%q err=%v", method, now.UserTags, now.UserDefined[r5TagStamp], err)
|
||||
}
|
||||
if !now.ModTime.Equal(oi.ModTime) {
|
||||
t.Fatal("tagging changed the object's data modification time")
|
||||
}
|
||||
t.Logf("%s mutation %d persisted tags=%q timestamp=%s", instance, n, now.UserTags, stamp)
|
||||
}
|
||||
// Ordinary COPY with empty REPLACE has the same local-deletion semantics.
|
||||
before := time.Now().UTC()
|
||||
headers := map[string]string{xhttp.AmzCopySource: "/" + bucket + "/local-tags?versionId=" + oi.VersionID, xhttp.AmzMetadataDirective: "REPLACE", xhttp.AmzTagDirective: "REPLACE"}
|
||||
w := r5Request(t, router, cred, http.MethodPut, "/"+bucket+"/copied-empty", "", headers)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("copy: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
copied, err := obj.GetObjectInfo(t.Context(), bucket, "copied-empty", ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stamp, err := time.Parse(time.RFC3339Nano, copied.UserDefined[r5TagStamp])
|
||||
if err != nil || stamp.Before(before) || copied.UserTags != "" {
|
||||
t.Fatalf("local COPY: %v %+v", err, copied)
|
||||
}
|
||||
}})
|
||||
}
|
||||
|
||||
func TestTaggingTimestampWire(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
for _, tag := range []string{"", "key=value"} {
|
||||
for _, stamp := range []string{"", now.Format(time.RFC3339Nano), "invalid"} {
|
||||
t.Run(fmt.Sprintf("%s/%s", tag, stamp), func(t *testing.T) {
|
||||
source := ObjectInfo{ModTime: now.Add(-time.Hour), UserTags: tag, UserDefined: map[string]string{}}
|
||||
if stamp != "" {
|
||||
source.UserDefined[r5TagStamp] = stamp
|
||||
}
|
||||
opts, _, err := putReplicationOpts(t.Context(), "", source)
|
||||
if stamp == "invalid" {
|
||||
if err == nil {
|
||||
t.Fatal("invalid timestamp accepted")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := stamp
|
||||
if want == "" && tag != "" {
|
||||
want = source.ModTime.Format(time.RFC3339Nano)
|
||||
}
|
||||
if got := opts.Header().Get(xhttp.MinIOSourceTaggingTimestamp); got != want {
|
||||
t.Fatalf("wire timestamp=%q want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPoolsTaggingReplicaDeletion(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
defer r5Capacity(z)()
|
||||
if err := newTestConfig(globalMinioDefaultRegion, z); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := initTestAPIEndPoints(z, nil)
|
||||
base := time.Now().UTC().Add(-5 * time.Hour)
|
||||
for _, op := range []string{"copy", "copy-default", "put", "multipart"} {
|
||||
for _, kind := range []string{"uuid", "null"} {
|
||||
t.Run(op+"/"+kind, func(t *testing.T) {
|
||||
vid := mustGetUUID()
|
||||
if kind == "null" {
|
||||
vid = nullVersionID
|
||||
}
|
||||
name := "pool-tags-" + op + "-" + kind
|
||||
var source ObjectInfo
|
||||
for pool := range 2 {
|
||||
tag := "key=stale-pool"
|
||||
ts := base
|
||||
if pool == 1 {
|
||||
tag = ""
|
||||
ts = base.Add(3 * time.Hour)
|
||||
}
|
||||
source = putConsistencyObject(t, z, bucket, name, pool, "data", ObjectOptions{Versioned: true, VersionID: vid, MTime: base, UserDefined: map[string]string{xhttp.AmzObjectTagging: tag, r5TagStamp: ts.Format(time.RFC3339Nano)}})
|
||||
}
|
||||
// Clear all copies through the signed local handler, then replay a stale incoming state.
|
||||
req := r5Request(t, router, globalActiveCred, http.MethodDelete, "/"+bucket+"/"+name+"?tagging&versionId="+vid, "", nil)
|
||||
if req.Code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE: %d %s", req.Code, req.Body.String())
|
||||
}
|
||||
current, err := z.GetObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: vid})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deletedAt := current.UserDefined[r5TagStamp]
|
||||
for pool := range 2 {
|
||||
r5Stored(t, z.serverPools[pool], bucket, name, vid, "", deletedAt)
|
||||
}
|
||||
source.VersionID = vid
|
||||
source.UserTags = "key=delayed"
|
||||
source.UserDefined = map[string]string{r5TagStamp: base.Add(time.Hour).Format(time.RFC3339Nano)}
|
||||
r5Receive(t, z, router, globalActiveCred, bucket, op, source, source.UserDefined[r5TagStamp], nil)
|
||||
// The addressed version must remain readable through normal routing.
|
||||
r5Stored(t, z, bucket, name, vid, "", deletedAt)
|
||||
// Existing duplicate suppression may leave both identical tombstones; any retained copy must be correct.
|
||||
for pool := range 2 {
|
||||
got, err := z.serverPools[pool].GetObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: vid})
|
||||
if isErrVersionNotFound(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.UserTags != "" || got.UserDefined[r5TagStamp] != deletedAt {
|
||||
t.Fatalf("pool %d: tags=%q stamp=%q want deletion %q", pool, got.UserTags, got.UserDefined[r5TagStamp], deletedAt)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITaggingSSECRotationPreservesDeletionRevision(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
defer r5Capacity(obj.(*erasureServerPools))()
|
||||
oldTLS := globalIsTLS
|
||||
globalIsTLS = true
|
||||
defer func() { globalIsTLS = oldTLS }()
|
||||
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys := [][]byte{[]byte(strings.Repeat("a", 32)), []byte(strings.Repeat("b", 32)), []byte(strings.Repeat("c", 32)), []byte(strings.Repeat("d", 32))}
|
||||
const name = "tag-rotation"
|
||||
putCopyChecksumSource(t, router, cred, bucket, name, []byte("data"), ssecKeyHeaders(keys[0], false))
|
||||
oi, err := obj.GetObjectInfo(t.Context(), bucket, name, ObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Now().UTC().Add(-time.Hour)
|
||||
for i, event := range []struct {
|
||||
tags string
|
||||
delta int
|
||||
wantTags string
|
||||
wantDelta int
|
||||
}{
|
||||
{"key=live", 1, "key=live", 1}, {"", 3, "", 3}, {"key=stale", 2, "", 3},
|
||||
} {
|
||||
h := ssecKeyHeaders(keys[i], true)
|
||||
maps.Copy(h, ssecKeyHeaders(keys[i+1], false))
|
||||
h[xhttp.AmzObjectTagging] = event.tags
|
||||
h[xhttp.AmzTagDirective] = "REPLACE"
|
||||
h[xhttp.MinIOSourceTaggingTimestamp] = base.Add(time.Duration(event.delta) * time.Minute).Format(time.RFC3339Nano)
|
||||
sendReplicaLockCopy(t, router, cred, bucket, name, oi.VersionID, h)
|
||||
r5Stored(t, obj, bucket, name, oi.VersionID, event.wantTags, base.Add(time.Duration(event.wantDelta)*time.Minute).Format(time.RFC3339Nano))
|
||||
}
|
||||
get := r5Request(t, router, cred, http.MethodGet, "/"+bucket+"/"+name+"?versionId="+oi.VersionID, "", ssecKeyHeaders(keys[3], false))
|
||||
if get.Code != http.StatusOK || get.Body.String() != "data" {
|
||||
t.Fatalf("%s GET after rotations: %d %q", instance, get.Code, get.Body.String())
|
||||
}
|
||||
}})
|
||||
}
|
||||
|
||||
func TestTaggingRepeatedValueNeedsRevisionDelivery(t *testing.T) {
|
||||
for _, tag := range []string{"", "key=same"} {
|
||||
now := time.Now().UTC()
|
||||
source := ObjectInfo{ModTime: now, UserTags: tag, UserDefined: map[string]string{r5TagStamp: now.Add(time.Hour).Format(time.RFC3339Nano)}}
|
||||
target := minio.ObjectInfo{LastModified: now}
|
||||
if tag != "" {
|
||||
target.UserTags = map[string]string{"key": "same"}
|
||||
target.UserTagCount = 1
|
||||
}
|
||||
if got := getReplicationAction(source, target, replication.MetadataReplicationType); got != replicateMetadata {
|
||||
t.Errorf("equal tags %q suppress a newer revision: got %s; an intervening delayed deletion can win", tag, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaggingProductionCopyWireShape(t *testing.T) {
|
||||
got := make(chan http.Header, 1)
|
||||
peer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got <- r.Header.Clone()
|
||||
w.Header().Set(xhttp.ContentType, "application/xml")
|
||||
w.Write([]byte(`<CopyObjectResult><LastModified>2026-09-15T01:00:00Z</LastModified><ETag>"abc"</ETag></CopyObjectResult>`))
|
||||
}))
|
||||
defer peer.Close()
|
||||
c, err := minio.New(strings.TrimPrefix(peer.URL, "http://"), &minio.Options{Region: "us-east-1", MaxRetries: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
oi := ObjectInfo{Name: "object", ModTime: now, ETag: "abc", VersionID: mustGetUUID()}
|
||||
core := minio.Core{Client: c}
|
||||
_, err = core.CopyObject(t.Context(), "bucket", "object", "bucket", "object", getCopyObjMetadata(oi, ""), minio.CopySrcOptions{VersionID: oi.VersionID}, minio.PutObjectOptions{Internal: minio.AdvancedPutOptions{SourceVersionID: oi.VersionID, ReplicationRequest: true, TaggingTimestamp: now}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := <-got
|
||||
t.Logf("SDK wire: metadata-directive=%q tagging-directive=%q tagging=%q time=%q", h.Get(xhttp.AmzMetadataDirective), h.Get(xhttp.AmzTagDirective), h.Get(xhttp.AmzObjectTagging), h.Get(xhttp.MinIOSourceTaggingTimestamp))
|
||||
if h.Get(xhttp.AmzMetadataDirective) != "" || h.Get(xhttp.AmzTagDirective) != "REPLACE" || h.Get(xhttp.MinIOSourceTaggingTimestamp) != now.Format(time.RFC3339Nano) {
|
||||
t.Fatalf("unexpected SDK wire: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTaggingCommitCannotRegressRevision(t *testing.T) {
|
||||
z, bucket := consistencyPools(t)
|
||||
incoming := "2026-09-15T01:00:00Z"
|
||||
newer := "2026-09-15T02:00:00Z"
|
||||
newest := "2026-09-15T03:00:00Z"
|
||||
vid := mustGetUUID()
|
||||
name := "inverted-local-tags"
|
||||
for pool := range 2 {
|
||||
putConsistencyObject(t, z, bucket, name, pool, "data", ObjectOptions{Versioned: true, VersionID: vid, UserDefined: map[string]string{r5TagStamp: []string{newer, newest}[pool]}})
|
||||
}
|
||||
oi, err := z.PutObjectTags(t.Context(), bucket, name, "key=after-delete", ObjectOptions{VersionID: vid, UserDefined: map[string]string{r5TagStamp: incoming}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "2026-09-15T03:00:00.000000001Z"
|
||||
for pool := range 2 {
|
||||
r5Stored(t, z.serverPools[pool], bucket, name, vid, "key=after-delete", want)
|
||||
}
|
||||
if oi.UserDefined[r5TagStamp] != want {
|
||||
t.Fatalf("response stamp=%q want %q", oi.UserDefined[r5TagStamp], want)
|
||||
}
|
||||
// The single-set guard also applies when the pools dispatcher is bypassed.
|
||||
_, err = z.serverPools[0].PutObjectTags(t.Context(), bucket, name, "", ObjectOptions{VersionID: vid, UserDefined: map[string]string{r5TagStamp: incoming}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r5Stored(t, z.serverPools[0], bucket, name, vid, "", "2026-09-15T03:00:00.000000002Z")
|
||||
}
|
||||
|
||||
func TestTaggingReplicaContentDuplicateGuard(t *testing.T) {
|
||||
stamp := time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
delta int
|
||||
stored string
|
||||
trusted, replica bool
|
||||
ifMatch, ifNone string
|
||||
wantSkip bool
|
||||
}{
|
||||
{name: "newer", delta: 1, trusted: true, replica: true},
|
||||
{name: "equal", trusted: true, replica: true, wantSkip: true},
|
||||
{name: "older", delta: -1, trusted: true, replica: true, wantSkip: true},
|
||||
{name: "invalid-stored", stored: "invalid", delta: 1, trusted: true, replica: true},
|
||||
{name: "untrusted", delta: 1, wantSkip: true},
|
||||
{name: "marker-only", delta: 1, trusted: true, wantSkip: true},
|
||||
{name: "if-match-fails", delta: 1, trusted: true, replica: true, ifMatch: "other", wantSkip: true},
|
||||
{name: "if-none-match-fails", delta: 1, trusted: true, replica: true, ifNone: "etag", wantSkip: true},
|
||||
{name: "if-match-passes", delta: 1, trusted: true, replica: true, ifMatch: "etag"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := withReplicationTrust(t.Context(), tc.trusted, tc.replica)
|
||||
r := httptest.NewRequest(http.MethodPut, "/bucket/object", nil).WithContext(ctx)
|
||||
if tc.ifMatch != "" {
|
||||
r.Header.Set(xhttp.IfMatch, tc.ifMatch)
|
||||
}
|
||||
if tc.ifNone != "" {
|
||||
r.Header.Set(xhttp.IfNoneMatch, tc.ifNone)
|
||||
}
|
||||
stored := tc.stored
|
||||
if stored == "" {
|
||||
stored = stamp.Format(time.RFC3339Nano)
|
||||
}
|
||||
oi := ObjectInfo{ModTime: stamp, VersionID: mustGetUUID(), ETag: "etag", UserDefined: map[string]string{r5TagStamp: stored}}
|
||||
opts := ObjectOptions{VersionID: oi.VersionID, PreserveETag: oi.ETag, ReplicationRequest: tc.trusted, ReplicationSourceTaggingTimestamp: stamp.Add(time.Duration(tc.delta) * time.Second)}
|
||||
if skip := checkPreconditionsPUT(ctx, httptest.NewRecorder(), r, oi, opts); skip != tc.wantSkip {
|
||||
t.Fatalf("skip=%v, want %v", skip, tc.wantSkip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITaggingUnqualifiedCopyOrdering(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
defer r5Capacity(obj.(*erasureServerPools))()
|
||||
stamp := time.Now().UTC().Add(-time.Hour)
|
||||
oi, err := obj.PutObject(t.Context(), bucket, "unqualified-tags", mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{UserDefined: map[string]string{xhttp.AmzObjectTagging: "key=stored", r5TagStamp: stamp.Format(time.RFC3339Nano)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := oi
|
||||
source.UserTags = ""
|
||||
source.UserDefined = maps.Clone(oi.UserDefined)
|
||||
r5Receive(t, obj, router, cred, bucket, "copy", source, stamp.Format(time.RFC3339Nano), nil)
|
||||
r5Stored(t, obj, bucket, oi.Name, "", "key=stored", stamp.Format(time.RFC3339Nano))
|
||||
later := stamp.Add(time.Minute).Format(time.RFC3339Nano)
|
||||
r5Receive(t, obj, router, cred, bucket, "copy-default", source, later, nil)
|
||||
r5Stored(t, obj, bucket, oi.Name, "", "", later)
|
||||
source.UserTags = "key=delayed"
|
||||
r5Receive(t, obj, router, cred, bucket, "copy-default", source, stamp.Format(time.RFC3339Nano), nil)
|
||||
r5Stored(t, obj, bucket, oi.Name, "", "", later)
|
||||
t.Logf("%s: unqualified COPY keeps stored ties and ordered deletion", instance)
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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 (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/bucket/replication"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/minio/internal/once"
|
||||
)
|
||||
|
||||
func r5ReplicationFixture(t *testing.T, obj ObjectLayer, bucket string, client *minio.Client) (chan ReplicationWorkerOperation, func()) {
|
||||
t.Helper()
|
||||
const arn = "arn:minio:replication::af470089-d354-4473-934c-9e1f52f6da89:bucket"
|
||||
target := &TargetClient{Client: client, ARN: arn, Bucket: bucket}
|
||||
globalBucketTargetSys.arnRemotesMap[arn] = arnTarget{Client: target, lastRefresh: UTCNow()}
|
||||
globalBucketTargetSys.targetsMap[bucket] = []madmin.BucketTarget{{Arn: arn, TargetBucket: bucket}}
|
||||
meta, err := globalBucketMetadataSys.Get(bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := configs[0]
|
||||
cfg.RoleArn = arn
|
||||
meta.replicationConfig = &cfg
|
||||
globalBucketMetadataSys.Set(bucket, meta)
|
||||
worker := make(chan ReplicationWorkerOperation, 10)
|
||||
previous := globalReplicationPool
|
||||
globalReplicationPool = once.NewSingleton[ReplicationPool]()
|
||||
globalReplicationPool.Set(&ReplicationPool{ctx: t.Context(), objLayer: obj, workers: []chan ReplicationWorkerOperation{worker}, stats: globalReplicationStats.Load(), mrfSaveCh: make(chan MRFReplicateEntry, 10)})
|
||||
return worker, func() { globalReplicationPool = previous }
|
||||
}
|
||||
|
||||
func TestTaggingReplicationSenderRetryAndAcknowledgment(t *testing.T) {
|
||||
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, instance, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||
defer r5Capacity(obj.(*erasureServerPools))()
|
||||
if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const name = "tagging-old-queue"
|
||||
oi, err := obj.PutObject(t.Context(), bucket, name, mustGetPutObjReader(t, strings.NewReader("data"), 4, "", ""), ObjectOptions{Versioned: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requests := make(chan http.Header, 4)
|
||||
var attempts atomic.Int32
|
||||
peer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(xhttp.AmzVersionID, oi.VersionID)
|
||||
w.Header().Set(xhttp.ETag, "\""+oi.ETag+"\"")
|
||||
w.Header().Set(xhttp.LastModified, oi.ModTime.Format(http.TimeFormat))
|
||||
w.Header().Set(xhttp.ContentType, oi.ContentType)
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set(xhttp.ContentLength, "4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
requests <- r.Header.Clone()
|
||||
w.Header().Set(xhttp.ContentType, "application/xml")
|
||||
if attempts.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte(`<Error><Code>SlowDown</Code><Message>retry fixture</Message></Error>`))
|
||||
return
|
||||
}
|
||||
w.Write([]byte("<CopyObjectResult><LastModified>" + oi.ModTime.Format(time.RFC3339Nano) + "</LastModified><ETag>\"" + oi.ETag + "\"</ETag></CopyObjectResult>"))
|
||||
}))
|
||||
defer peer.Close()
|
||||
client, err := minio.New(strings.TrimPrefix(peer.URL, "http://"), &minio.Options{Region: "us-east-1", MaxRetries: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker, cleanup := r5ReplicationFixture(t, obj, bucket, client)
|
||||
defer cleanup()
|
||||
body := `<Tagging><TagSet><Tag><Key>key</Key><Value>queued</Value></Tag></TagSet></Tagging>`
|
||||
w := r5Request(t, router, cred, http.MethodPut, "/"+bucket+"/"+name+"?tagging&versionId="+oi.VersionID, body, nil)
|
||||
if w.Code != http.StatusOK || len(worker) != 1 {
|
||||
t.Fatalf("tagging PUT: %d %s queued=%d", w.Code, w.Body.String(), len(worker))
|
||||
}
|
||||
old := (<-worker).(ReplicateObjectInfo)
|
||||
// Delete through the actual handler before processing the old task.
|
||||
w = r5Request(t, router, cred, http.MethodDelete, "/"+bucket+"/"+name+"?tagging&versionId="+oi.VersionID, "", nil)
|
||||
if w.Code != http.StatusNoContent || len(worker) != 1 {
|
||||
t.Fatalf("tagging DELETE: %d %s queued=%d", w.Code, w.Body.String(), len(worker))
|
||||
}
|
||||
deleted, err := obj.GetObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: oi.VersionID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stamp := deleted.UserDefined[r5TagStamp]
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
result := replicateObject(t.Context(), old, obj)
|
||||
want := replication.Failed
|
||||
if attempt == 1 {
|
||||
want = replication.Completed
|
||||
}
|
||||
if result.ReplicationStatus() != want {
|
||||
t.Fatalf("attempt %d result=%+v want %s", attempt, result, want)
|
||||
}
|
||||
if len(result.Targets) != 1 || result.Targets[0].ReplicationAction != replicateMetadata || (result.Targets[0].Err != nil) != (attempt == 0) {
|
||||
t.Fatalf("attempt %d reported wrong action/error: %+v", attempt, result)
|
||||
}
|
||||
r5Stored(t, obj, bucket, name, oi.VersionID, "", stamp)
|
||||
select {
|
||||
case h := <-requests:
|
||||
if h.Get(xhttp.MinIOSourceTaggingTimestamp) != stamp || h.Get(xhttp.AmzObjectTagging) != "" || h.Get(xhttp.AmzTagDirective) != "REPLACE" || h.Get(xhttp.AmzMetadataDirective) != "" {
|
||||
t.Fatalf("sender did not carry current deletion: %v", h)
|
||||
}
|
||||
t.Logf("%s attempt %d sent tags=%q timestamp=%s status=%s", instance, attempt, h.Get(xhttp.AmzObjectTagging), stamp, want)
|
||||
default:
|
||||
t.Fatal("no metadata COPY sent for same-empty target")
|
||||
}
|
||||
}
|
||||
// With a real outgoing rule enabled, a signed incoming replica COPY
|
||||
// must not queue another outgoing event and create a feedback loop.
|
||||
before := len(worker)
|
||||
r5Receive(t, obj, router, cred, bucket, "copy", deleted, stamp, nil)
|
||||
if len(worker) != before {
|
||||
t.Fatal("incoming replica COPY scheduled another outgoing event")
|
||||
}
|
||||
// The metadata sender must fail malformed stored revisions before COPY,
|
||||
// just as the full retransmission option builder does.
|
||||
_, err = obj.PutObjectTags(t.Context(), bucket, name, "", ObjectOptions{VersionID: oi.VersionID, UserDefined: map[string]string{r5TagStamp: "invalid"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := globalBucketTargetSys.GetRemoteTargetClient(bucket, globalBucketTargetSys.targetsMap[bucket][0].Arn)
|
||||
invalid := old.replicateAll(t.Context(), obj, target)
|
||||
if invalid.ReplicationStatus != replication.Failed || invalid.Err == nil || len(requests) != 0 {
|
||||
t.Fatalf("invalid timestamp was not rejected before send: %+v", invalid)
|
||||
}
|
||||
}})
|
||||
}
|
||||
@@ -901,6 +901,8 @@ func serverMain(ctx *cli.Context) {
|
||||
close(globalGridStart)
|
||||
close(globalLockGridStart)
|
||||
|
||||
// The HTTP/1 listener preserves absolute header deadlines and renews the
|
||||
// body read/write idle limits, so transfers may outlast IdleTimeout.
|
||||
httpServer := xhttp.NewServer(getServerListenAddrs()).
|
||||
UseHandler(setCriticalErrorHandler(corsHandler(handler))).
|
||||
UseTLSConfig(newTLSConfig(getCert)).
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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 (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/cli"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
func TestServerReadHeaderTimeoutConfig(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, env, idle string
|
||||
args []string
|
||||
want time.Duration
|
||||
fmtgen bool
|
||||
}{
|
||||
{name: "default", want: xhttp.DefaultReadHeaderTimeout},
|
||||
{name: "flag", args: []string{"--read-header-timeout=100ms"}, want: 100 * time.Millisecond},
|
||||
{name: "environment", env: "170ms", want: 170 * time.Millisecond},
|
||||
{name: "flag-over-environment", env: "170ms", args: []string{"--read-header-timeout=80ms"}, want: 80 * time.Millisecond},
|
||||
{name: "yaml-retains-flag", args: []string{"--config=testdata/config/1.yaml", "--read-header-timeout=100ms"}, want: 100 * time.Millisecond},
|
||||
{name: "zero-fallback", args: []string{"--read-header-timeout=0s"}},
|
||||
{name: "zero-idle-default-header", idle: "0s", want: xhttp.DefaultReadHeaderTimeout},
|
||||
{name: "negative-idle-default-header", idle: "-1s", want: xhttp.DefaultReadHeaderTimeout},
|
||||
{name: "fmt-gen-unregistered-duration", env: "100ms", fmtgen: true},
|
||||
{name: "negative-disabled", args: []string{"--read-header-timeout=-1s"}, want: -time.Second},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, key := range []string{"MINIO_ARGS", "MINIO_VOLUMES", "MINIO_ENDPOINTS", "MINIO_CONFIG", "MINIO_ERASURE_SET_DRIVE_COUNT"} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
t.Setenv("MINIO_READ_HEADER_TIMEOUT", tc.env)
|
||||
if tc.env == "" {
|
||||
if err := os.Unsetenv("MINIO_READ_HEADER_TIMEOUT"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
idle := tc.idle
|
||||
if idle == "" {
|
||||
idle = "2s"
|
||||
}
|
||||
t.Setenv("MINIO_IDLE_TIMEOUT", idle)
|
||||
idleWant, err := time.ParseDuration(idle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commandName, flags := "server", serverCmd.Flags
|
||||
if tc.fmtgen {
|
||||
commandName, flags = "fmt-gen", fmtGenFlags
|
||||
idleWant = 0
|
||||
}
|
||||
var got serverCtxt
|
||||
called := false
|
||||
app := cli.NewApp()
|
||||
app.Commands = []cli.Command{{Name: commandName, Flags: flags, Action: func(ctx *cli.Context) error {
|
||||
called = true
|
||||
if parsed := ctx.Duration("read-header-timeout"); parsed != tc.want {
|
||||
t.Errorf("CLI read-header-timeout=%s, expected=%s", parsed, tc.want)
|
||||
}
|
||||
return buildServerCtxt(ctx, &got)
|
||||
}}}
|
||||
args := append([]string{"silo", commandName}, tc.args...)
|
||||
args = append(args, t.TempDir())
|
||||
if err := app.Run(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("server action did not run")
|
||||
}
|
||||
if got.ReadHeaderTimeout != tc.want {
|
||||
t.Errorf("parsed ReadHeaderTimeout = %s, want %s", got.ReadHeaderTimeout, tc.want)
|
||||
}
|
||||
if got.IdleTimeout != idleWant {
|
||||
t.Errorf("parsed IdleTimeout = %s, want %s", got.IdleTimeout, idleWant)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,15 @@ func testStorageAPIListDir(t *testing.T, storage StorageAPI) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"one", "two", "three"} {
|
||||
if err := storage.AppendFile(t.Context(), "foo", "bounded/"+name, []byte("x")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
entries, err := storage.ListDir(t.Context(), "", "foo", "bounded", 2)
|
||||
if err != nil || len(entries) != 2 {
|
||||
t.Fatalf("ListDir count was lost in storage/RPC path: %v %v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testStorageAPIReadAll(t *testing.T, storage StorageAPI) {
|
||||
|
||||
@@ -35,7 +35,7 @@ set -e
|
||||
export MINIO_CI_CD=1
|
||||
export MINIO_BROWSER=off
|
||||
export MINIO_ROOT_USER="minio"
|
||||
export MINIO_ROOT_PASSWORD="silo123"
|
||||
export MINIO_ROOT_PASSWORD="silo12345"
|
||||
export MINIO_KMS_AUTO_ENCRYPTION=off
|
||||
export MINIO_PROMETHEUS_AUTH_TYPE=public
|
||||
export MINIO_KMS_SECRET_KEY=my-minio-key:OSMM+vkKUTCvQs9YL/CVMIMt43HFhkUpqJxTmGl6rYw=
|
||||
@@ -58,8 +58,8 @@ silo server --address 127.0.0.1:9003 "http://127.0.0.1:9003/tmp/multisiteb/data/
|
||||
silo server --address 127.0.0.1:9004 "http://127.0.0.1:9003/tmp/multisiteb/data/disterasure/xl{1...4}" \
|
||||
"http://127.0.0.1:9004/tmp/multisiteb/data/disterasure/xl{5...8}" >/tmp/siteb_2.log 2>&1 &
|
||||
|
||||
export MC_HOST_sitea=http://minio:silo123@127.0.0.1:9001
|
||||
export MC_HOST_siteb=http://minio:silo123@127.0.0.1:9004
|
||||
export MC_HOST_sitea=http://minio:silo12345@127.0.0.1:9001
|
||||
export MC_HOST_siteb=http://minio:silo12345@127.0.0.1:9004
|
||||
|
||||
./mc ready sitea
|
||||
./mc ready siteb
|
||||
@@ -83,7 +83,7 @@ done
|
||||
|
||||
echo "adding replication rule for site a -> site b"
|
||||
./mc replicate add sitea/bucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9004/bucket
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9004/bucket
|
||||
|
||||
remote_arn=$(./mc replicate ls sitea/bucket --json | jq -r .rule.Destination.Bucket)
|
||||
sleep 1
|
||||
@@ -137,7 +137,7 @@ fi
|
||||
|
||||
echo "adding replication rule for site a -> site b"
|
||||
./mc replicate add sitea/bucket-version/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9004/bucket-version
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9004/bucket-version
|
||||
|
||||
./mc mb sitea/bucket-version/directory/
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ set -e
|
||||
export MINIO_CI_CD=1
|
||||
export MINIO_BROWSER=off
|
||||
export MINIO_ROOT_USER="minio"
|
||||
export MINIO_ROOT_PASSWORD="silo123"
|
||||
export MINIO_ROOT_PASSWORD="silo12345"
|
||||
export MINIO_KMS_AUTO_ENCRYPTION=off
|
||||
export MINIO_PROMETHEUS_AUTH_TYPE=public
|
||||
export MINIO_KMS_SECRET_KEY=my-minio-key:OSMM+vkKUTCvQs9YL/CVMIMt43HFhkUpqJxTmGl6rYw=
|
||||
@@ -70,9 +70,9 @@ silo server --address 127.0.0.1:9005 "http://127.0.0.1:9005/tmp/multisitec/data/
|
||||
silo server --address 127.0.0.1:9006 "http://127.0.0.1:9005/tmp/multisitec/data/disterasure/xl{1...4}" \
|
||||
"http://127.0.0.1:9006/tmp/multisitec/data/disterasure/xl{5...8}" >/tmp/sitec_2.log 2>&1 &
|
||||
|
||||
export MC_HOST_sitea=http://minio:silo123@127.0.0.1:9001
|
||||
export MC_HOST_siteb=http://minio:silo123@127.0.0.1:9004
|
||||
export MC_HOST_sitec=http://minio:silo123@127.0.0.1:9006
|
||||
export MC_HOST_sitea=http://minio:silo12345@127.0.0.1:9001
|
||||
export MC_HOST_siteb=http://minio:silo12345@127.0.0.1:9004
|
||||
export MC_HOST_sitec=http://minio:silo12345@127.0.0.1:9006
|
||||
|
||||
./mc ready sitea
|
||||
./mc ready siteb
|
||||
@@ -93,73 +93,73 @@ export MC_HOST_sitec=http://minio:silo123@127.0.0.1:9006
|
||||
echo "adding replication rule for a -> b : ${remote_arn}"
|
||||
sleep 1
|
||||
./mc replicate add sitea/bucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9004/bucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9004/bucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync"
|
||||
sleep 1
|
||||
|
||||
echo "adding replication rule for b -> a : ${remote_arn}"
|
||||
./mc replicate add siteb/bucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9001/bucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9001/bucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync"
|
||||
sleep 1
|
||||
|
||||
echo "adding replication rule for a -> c : ${remote_arn}"
|
||||
./mc replicate add sitea/bucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9006/bucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9006/bucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync" --priority 2
|
||||
sleep 1
|
||||
|
||||
echo "adding replication rule for c -> a : ${remote_arn}"
|
||||
./mc replicate add sitec/bucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9001/bucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9001/bucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync" --priority 2
|
||||
sleep 1
|
||||
|
||||
echo "adding replication rule for b -> c : ${remote_arn}"
|
||||
./mc replicate add siteb/bucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9006/bucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9006/bucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync" --priority 3
|
||||
sleep 1
|
||||
|
||||
echo "adding replication rule for c -> b : ${remote_arn}"
|
||||
./mc replicate add sitec/bucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9004/bucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9004/bucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync" --priority 3
|
||||
sleep 1
|
||||
|
||||
echo "adding replication rule for olockbucket a -> b : ${remote_arn}"
|
||||
./mc replicate add sitea/olockbucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9004/olockbucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9004/olockbucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync"
|
||||
sleep 1
|
||||
|
||||
echo "adding replication rule for olockbucket b -> a : ${remote_arn}"
|
||||
./mc replicate add siteb/olockbucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9001/olockbucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9001/olockbucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync"
|
||||
sleep 1
|
||||
|
||||
echo "adding replication rule for olockbucket a -> c : ${remote_arn}"
|
||||
./mc replicate add sitea/olockbucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9006/olockbucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9006/olockbucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync" --priority 2
|
||||
sleep 1
|
||||
|
||||
echo "adding replication rule for olockbucket c -> a : ${remote_arn}"
|
||||
./mc replicate add sitec/olockbucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9001/olockbucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9001/olockbucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync" --priority 2
|
||||
sleep 1
|
||||
|
||||
echo "adding replication rule for olockbucket b -> c : ${remote_arn}"
|
||||
./mc replicate add siteb/olockbucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9006/olockbucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9006/olockbucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync" --priority 3
|
||||
sleep 1
|
||||
|
||||
echo "adding replication rule for olockbucket c -> b : ${remote_arn}"
|
||||
./mc replicate add sitec/olockbucket/ \
|
||||
--remote-bucket http://minio:silo123@127.0.0.1:9004/olockbucket \
|
||||
--remote-bucket http://minio:silo12345@127.0.0.1:9004/olockbucket \
|
||||
--replicate "existing-objects,delete,delete-marker,replica-metadata-sync" --priority 3
|
||||
sleep 1
|
||||
|
||||
@@ -204,33 +204,33 @@ head -c 221227088 </dev/urandom >200M
|
||||
sleep 10
|
||||
|
||||
echo "Verifying ETag for all objects"
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9001/ -bucket bucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9002/ -bucket bucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9003/ -bucket bucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9004/ -bucket bucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9005/ -bucket bucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9006/ -bucket bucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9001/ -bucket bucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9002/ -bucket bucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9003/ -bucket bucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9004/ -bucket bucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9005/ -bucket bucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9006/ -bucket bucket
|
||||
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9001/ -bucket olockbucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9002/ -bucket olockbucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9003/ -bucket olockbucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9004/ -bucket olockbucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9005/ -bucket olockbucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo123 -endpoint http://127.0.0.1:9006/ -bucket olockbucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9001/ -bucket olockbucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9002/ -bucket olockbucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9003/ -bucket olockbucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9004/ -bucket olockbucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9005/ -bucket olockbucket
|
||||
./s3-check-md5 -versions -access-key minio -secret-key silo12345 -endpoint http://127.0.0.1:9006/ -bucket olockbucket
|
||||
|
||||
# additional tests for encryption object alignment
|
||||
go install -v github.com/minio/multipart-debug@latest
|
||||
|
||||
upload_id=$(multipart-debug --endpoint 127.0.0.1:9001 --accesskey minio --secretkey silo123 multipart new --bucket bucket --object new-test-encrypted-object --encrypt)
|
||||
upload_id=$(multipart-debug --endpoint 127.0.0.1:9001 --accesskey minio --secretkey silo12345 multipart new --bucket bucket --object new-test-encrypted-object --encrypt)
|
||||
|
||||
dd if=/dev/urandom bs=1 count=7048531 of=/tmp/7048531.txt
|
||||
dd if=/dev/urandom bs=1 count=2847391 of=/tmp/2847391.txt
|
||||
|
||||
sudo apt install jq -y
|
||||
|
||||
etag_1=$(multipart-debug --endpoint 127.0.0.1:9002 --accesskey minio --secretkey silo123 multipart upload --bucket bucket --object new-test-encrypted-object --uploadid ${upload_id} --file /tmp/7048531.txt --number 1 | jq -r .ETag)
|
||||
etag_2=$(multipart-debug --endpoint 127.0.0.1:9001 --accesskey minio --secretkey silo123 multipart upload --bucket bucket --object new-test-encrypted-object --uploadid ${upload_id} --file /tmp/2847391.txt --number 2 | jq -r .ETag)
|
||||
multipart-debug --endpoint 127.0.0.1:9002 --accesskey minio --secretkey silo123 multipart complete --bucket bucket --object new-test-encrypted-object --uploadid ${upload_id} 1.${etag_1} 2.${etag_2}
|
||||
etag_1=$(multipart-debug --endpoint 127.0.0.1:9002 --accesskey minio --secretkey silo12345 multipart upload --bucket bucket --object new-test-encrypted-object --uploadid ${upload_id} --file /tmp/7048531.txt --number 1 | jq -r .ETag)
|
||||
etag_2=$(multipart-debug --endpoint 127.0.0.1:9001 --accesskey minio --secretkey silo12345 multipart upload --bucket bucket --object new-test-encrypted-object --uploadid ${upload_id} --file /tmp/2847391.txt --number 2 | jq -r .ETag)
|
||||
multipart-debug --endpoint 127.0.0.1:9002 --accesskey minio --secretkey silo12345 multipart complete --bucket bucket --object new-test-encrypted-object --uploadid ${upload_id} 1.${etag_1} 2.${etag_2}
|
||||
|
||||
sleep 10
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ set -e
|
||||
export MINIO_CI_CD=1
|
||||
export MINIO_BROWSER=off
|
||||
export MINIO_ROOT_USER="minio"
|
||||
export MINIO_ROOT_PASSWORD="silo123"
|
||||
export MINIO_ROOT_PASSWORD="silo12345"
|
||||
export MINIO_KMS_AUTO_ENCRYPTION=off
|
||||
export MINIO_PROMETHEUS_AUTH_TYPE=public
|
||||
export MINIO_KMS_SECRET_KEY=my-minio-key:OSMM+vkKUTCvQs9YL/CVMIMt43HFhkUpqJxTmGl6rYw=
|
||||
@@ -70,10 +70,10 @@ silo server --address 127.0.0.1:9008 "http://127.0.0.1:9007/tmp/multisited/data/
|
||||
|
||||
# Wait to make sure all Silo instances are up
|
||||
|
||||
export MC_HOST_sitea=http://minio:silo123@127.0.0.1:9001
|
||||
export MC_HOST_siteb=http://minio:silo123@127.0.0.1:9004
|
||||
export MC_HOST_sitec=http://minio:silo123@127.0.0.1:9006
|
||||
export MC_HOST_sited=http://minio:silo123@127.0.0.1:9008
|
||||
export MC_HOST_sitea=http://minio:silo12345@127.0.0.1:9001
|
||||
export MC_HOST_siteb=http://minio:silo12345@127.0.0.1:9004
|
||||
export MC_HOST_sitec=http://minio:silo12345@127.0.0.1:9006
|
||||
export MC_HOST_sited=http://minio:silo12345@127.0.0.1:9008
|
||||
|
||||
./mc ready sitea
|
||||
./mc ready siteb
|
||||
@@ -89,7 +89,7 @@ export MC_HOST_sited=http://minio:silo123@127.0.0.1:9008
|
||||
sleep 10s
|
||||
|
||||
## Add warm tier
|
||||
./mc ilm tier add minio sitea WARM-TIER --endpoint http://localhost:9006 --access-key minio --secret-key silo123 --bucket bucket
|
||||
./mc ilm tier add minio sitea WARM-TIER --endpoint http://localhost:9006 --access-key minio --secret-key silo12345 --bucket bucket
|
||||
|
||||
## Add ILM rules
|
||||
./mc ilm add sitea/bucket --transition-days 0 --transition-tier WARM-TIER --transition-days 0 --noncurrent-expire-days 2 --expire-days 3 --prefix "myprefix" --tags "tag1=val1&tag2=val2"
|
||||
|
||||
@@ -38,7 +38,7 @@ pid=$!
|
||||
|
||||
mc ready mysilo
|
||||
|
||||
mc admin user add mysilo/ silo123 silo123
|
||||
mc admin user add mysilo/ silo123 silo12345
|
||||
|
||||
mc admin policy create mysilo/ deny-non-sse-kms-pol ./docs/iam/policies/deny-non-sse-kms-objects.json
|
||||
mc admin policy create mysilo/ deny-invalid-sse-kms-pol ./docs/iam/policies/deny-objects-with-invalid-sse-kms-key-id.json
|
||||
@@ -50,7 +50,7 @@ mc admin policy attach mysilo consoleAdmin --user silo123
|
||||
mc mb -l mysilo/test-bucket
|
||||
mc mb -l mysilo/multi-key-poc
|
||||
|
||||
export MC_HOST_mysilo1="http://silo123:silo123@localhost:9000/"
|
||||
export MC_HOST_mysilo1="http://silo123:silo12345@localhost:9000/"
|
||||
|
||||
mc cp /etc/issue mysilo1/test-bucket
|
||||
ret=$?
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# R4–R8 集成核验
|
||||
|
||||
## 结论与范围
|
||||
|
||||
2026-09-16,R5、R6、R8 的修复在已包含 R4、R7 的 main 基线上完成集成。
|
||||
本地完整 `cmd`、`internal` 测试、相关 race 检查、仓库 verifiers、构建和
|
||||
HTTP 超时进程探针均通过。环境中的真实 `claude-opus-5`(effort `max`)独立
|
||||
阅读合并差异与调用链,结论为 **GO_WITH_NONBLOCKING_NOTES,零阻断项**。
|
||||
|
||||
本记录对应 main 合并前的代码核验。最终 PR 的 Linux CI、DCO 和合并结果以该
|
||||
PR 的实际提交及检查为准;这里的本地结果不代表发布、部署或多站点生产验收。
|
||||
|
||||
## 提交对应关系
|
||||
|
||||
基线:`9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe`。
|
||||
|
||||
| 问题 | 原修复提交 | 集成提交 | 行为 |
|
||||
| --- | --- | --- | --- |
|
||||
| R4 | PR [#193](https://github.com/pgsty/silo/pull/193),已在基线 | `af2b1794d38d9e70e1d2c3ee692426e4b6cab4bd`(merge) | SSE-KMS 复制保留标签修订时间 |
|
||||
| R7 | PR [#194](https://github.com/pgsty/silo/pull/194),已在基线 | `9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe`(merge) | 复制元数据恢复不再重新写入传输用 aws-chunked |
|
||||
| R5 | `115fe8b12329d147adbaf817faa1737392ecbf9b` | `680eac66e40b0980bc20e70d7ad34185096e63f5` | 删除标签推进修订,接收端抵御乱序事件,重试及 ACK 保留新状态 |
|
||||
| R6 | `cf381a7151ef25fc95ace5fedcd767fa19410de2` | `0c61128d23f05ce6b37e7ace713c3ffbfb68f4cb` | 旧形态 marker purge 正确分类,MRF 恢复 marker 并保留重试次数 |
|
||||
| R6 核验记录 | `d38edb2c46182d3a8fa96e040604493d20a4b478` | `aea3882c95d16ec5598a07b40d593e04054137a9` | 保存 v3 共识及验证边界 |
|
||||
| R8 | `0d48d32d7e038ae1ea5966f3d7e0cb86780a6311` | `055030ea53ca92ee22ce1e601ef4757c247edde8` | 配置绑定到读头绝对超时,正文继续采用滚动空闲超时 |
|
||||
|
||||
各原任务先取得 Opus 方案共识,再实施修复。原始方案、实现复核及验证记录保留在
|
||||
[R5](../r5/verification.md)、[R6](../r6/README.md)、[R8](../r8/README.md)。
|
||||
R5、R6、R8 原任务又分别只读核验了集成后的交叉影响,未发现新增生产阻断项。
|
||||
|
||||
集成使用 `git cherry-pick -x -s`,保留原作者、来源及 DCO。后续
|
||||
`80684fed59f556d579e268c5a855d936c1347b68` 仅处理两类贡献规范问题:
|
||||
|
||||
- 六个新建测试文件统一使用实际贡献者姓名及 AGPL-3.0-or-later 头部;从
|
||||
`package` 开始的内容逐字节不变,Linux build tag 保留。
|
||||
- 按 CONTRIBUTING 的规则更新兼容标识清单。唯一新增条目是 R6 测试拼接既有
|
||||
replication ARN 所用的 `arn:minio:replication::`,没有新增协议名称或生产行为。
|
||||
|
||||
22 个源码/测试文件的最终哈希见 [manifest.json](manifest.json)。共享文件中的
|
||||
R5、R6 补丁与原修复具有相同稳定 patch ID;其余源文件直接比较,六个测试仅允许
|
||||
上述头部差异。[等价检查](evidence/integration-equivalence.json)全部通过。
|
||||
|
||||
## Opus 集成复核与处置
|
||||
|
||||
实际 CLI 为 2.1.270,显式指定 `claude-opus-5 --effort max`;只允许 Read、Grep、
|
||||
Glob,未执行测试或修改代码。实际返回模型为 `claude-opus-5`,进程和结果均成功。
|
||||
复核基于 `055030ea53ca92ee22ce1e601ef4757c247edde8` 的 22 个文件及完整差异;
|
||||
此后的代码变化仅为上文已证明等价的头部与兼容清单调整。
|
||||
|
||||
原文、调用元数据及提示词分别见 [复核结果](evidence/opus-review.md)、
|
||||
[metadata](evidence/opus-integration.metadata.json)、[prompt](evidence/opus-integration.prompt.md)。
|
||||
保留原文中的判断,再用直接证据逐项处置,避免把模型意见当作测试结果:
|
||||
|
||||
| 非阻断意见 | 核验与决定 |
|
||||
| --- | --- |
|
||||
| 非法或空的历史标签时间戳可能使复制失败并重试 | 保留 R5 共识中的失败关闭行为;历史异常数据修复另行处理 |
|
||||
| 带标签修订的版本在 resync 时可能多一次 metadata COPY | R5 已接受的可靠性成本;正常 COMPLETED 路径保持原有门控 |
|
||||
| purge 审计状态由 COMPLETE 规范为 COMPLETED,统计开始记录实际目标结果 | R6 的预期行为;后续发布说明应告知审计/指标使用者 |
|
||||
| 配置的较短 ReadHeaderTimeout 同时缩短 TLS 握手窗口 | Go net/http 的预期语义,已在 R8 共识中说明 |
|
||||
| 新增多池标签测试单独运行可能缺少全局初始化 | **未成立**:精确单独运行通过;`consistencyPools` 经 `prepareErasurePoolsWithContext` → `initObjectLayer` → `newTestObjectLayer` 调用 `initAllSubsystems`。保留测试原样 |
|
||||
| 审计 fixture 重复取消可能输出栈信息 | 本地完整及 race 测试通过;不扩大本次生产修复范围 |
|
||||
| 新测试文件头部应按实际贡献者整理 | 已在 `80684fed` 修正,测试代码及 build tag 不变 |
|
||||
|
||||
## 本地直接验证
|
||||
|
||||
下表全部针对 `80684fed59f556d579e268c5a855d936c1347b68`,未使用额外的源码或
|
||||
容量 overlay;测试代码自身的容量 fixture 保留。限制并行度仅为
|
||||
`GOMAXPROCS=4`、`GOFLAGS=-p=2`,并使用
|
||||
仓库 CI 的 `MINIO_API_REQUESTS_MAX=10000`。详细命令、时间和日志哈希在
|
||||
[validation-results.json](evidence/validation-results.json)。
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| `make verifiers`:lint、生成文件、rebrand guard | 通过,76.9 秒;可选 typos 工具按现有 Makefile 规则跳过 |
|
||||
| `make build`、`./silo --version` | 通过,产物为 silo |
|
||||
| `CGO_ENABLED=0 go test -p 2 ./cmd ./internal/... -count=1 -timeout=30m` | 全部通过,340.2 秒,50 个有测试的包 |
|
||||
| `CGO_ENABLED=1 go test -race`,cmd/deadlineconn/http 中变更测试的函数集合 | 通过,46.3 秒;Linux build tag 用例由最终 Linux CI 覆盖 |
|
||||
| `TestAPIPoolsTaggingReplicaDeletion` 精确单独执行 | 通过,无需其他测试预先运行 |
|
||||
| 实际 silo 进程的 CLI/环境变量读头超时探针 | 两种配置均在 100ms 读头限制下拒绝 400ms 才完成的请求头;空闲超时为 2s,随后健康请求成功 |
|
||||
|
||||
进程探针使用二进制 SHA-256
|
||||
`1cc536f1a3c8d8372ff2d5b140b1fd2bc98a299324fea0f73f67288d48104ce4`。
|
||||
原始输出见 [runtime-probe.json](evidence/runtime-probe.json)。
|
||||
|
||||
各子任务较早遇到的磁盘容量不足或筛选测试初始化问题,不作为这次通过的证据。
|
||||
本地完整测试已重新执行并成功;原失败记录仍保留在各自调查档案。
|
||||
|
||||
## 验收边界
|
||||
|
||||
- 最终 PR 必须通过实际提交的全部仓库检查,尤其 Linux internal 测试、完整 cmd
|
||||
测试、构建/vet、lint/生成文件、交叉编译、S3 Select race、DCO 和漏洞检查。
|
||||
- 既有无时间戳对象、异常时间戳、标签筛选的目标选择、任意站点时钟偏差等不由本次
|
||||
修复追溯重建。共享状态解析器的历史限制按 R6 v3 共识在写入点规避。
|
||||
- R8 原先未完成的 S3 长传输脚本不计为通过;本次进程探针验证配置生效,不替代
|
||||
S3 长传输、独立多进程、多节点或跨区域生产验收。
|
||||
- 本次不引入依赖变更或上游 MinIO 兼容硬门槛;R9 不属于这五项修复。
|
||||
@@ -0,0 +1,79 @@
|
||||
ok github.com/minio/minio/cmd 320.458s
|
||||
ok github.com/minio/minio/internal/amztime 1.605s
|
||||
ok github.com/minio/minio/internal/arn 0.392s
|
||||
ok github.com/minio/minio/internal/auth 0.440s
|
||||
ok github.com/minio/minio/internal/bpool 0.422s
|
||||
ok github.com/minio/minio/internal/bucket/bandwidth 0.450s
|
||||
ok github.com/minio/minio/internal/bucket/cors 0.484s
|
||||
ok github.com/minio/minio/internal/bucket/encryption 0.644s
|
||||
ok github.com/minio/minio/internal/bucket/lifecycle 0.647s
|
||||
ok github.com/minio/minio/internal/bucket/object/lock 0.653s
|
||||
ok github.com/minio/minio/internal/bucket/replication 0.496s
|
||||
ok github.com/minio/minio/internal/bucket/versioning 0.449s
|
||||
ok github.com/minio/minio/internal/cachevalue 5.457s
|
||||
? github.com/minio/minio/internal/color [no test files]
|
||||
ok github.com/minio/minio/internal/config 0.656s
|
||||
? github.com/minio/minio/internal/config/api [no test files]
|
||||
? github.com/minio/minio/internal/config/batch [no test files]
|
||||
? github.com/minio/minio/internal/config/browser [no test files]
|
||||
? github.com/minio/minio/internal/config/callhome [no test files]
|
||||
ok github.com/minio/minio/internal/config/compress 0.624s
|
||||
ok github.com/minio/minio/internal/config/dns 0.731s
|
||||
? github.com/minio/minio/internal/config/drive [no test files]
|
||||
ok github.com/minio/minio/internal/config/etcd 1.015s
|
||||
? github.com/minio/minio/internal/config/heal [no test files]
|
||||
ok github.com/minio/minio/internal/config/identity/ldap 0.593s
|
||||
ok github.com/minio/minio/internal/config/identity/openid 0.675s
|
||||
? github.com/minio/minio/internal/config/identity/openid/provider [no test files]
|
||||
? github.com/minio/minio/internal/config/identity/plugin [no test files]
|
||||
? github.com/minio/minio/internal/config/identity/tls [no test files]
|
||||
ok github.com/minio/minio/internal/config/ilm 1.057s
|
||||
? github.com/minio/minio/internal/config/lambda [no test files]
|
||||
ok github.com/minio/minio/internal/config/lambda/event 0.485s
|
||||
? github.com/minio/minio/internal/config/lambda/target [no test files]
|
||||
ok github.com/minio/minio/internal/config/notify 0.752s
|
||||
? github.com/minio/minio/internal/config/policy/opa [no test files]
|
||||
? github.com/minio/minio/internal/config/policy/plugin [no test files]
|
||||
? github.com/minio/minio/internal/config/scanner [no test files]
|
||||
ok github.com/minio/minio/internal/config/storageclass 0.585s
|
||||
ok github.com/minio/minio/internal/config/subnet 0.582s
|
||||
ok github.com/minio/minio/internal/crypto 0.843s
|
||||
ok github.com/minio/minio/internal/deadlineconn 4.934s
|
||||
ok github.com/minio/minio/internal/disk 0.418s
|
||||
ok github.com/minio/minio/internal/dsync 131.144s
|
||||
ok github.com/minio/minio/internal/etag 0.504s
|
||||
ok github.com/minio/minio/internal/event 0.595s
|
||||
ok github.com/minio/minio/internal/event/target 0.957s
|
||||
ok github.com/minio/minio/internal/grid 7.616s
|
||||
ok github.com/minio/minio/internal/handlers 0.717s
|
||||
ok github.com/minio/minio/internal/hash 0.700s
|
||||
? github.com/minio/minio/internal/hash/sha256 [no test files]
|
||||
ok github.com/minio/minio/internal/http 13.643s
|
||||
? github.com/minio/minio/internal/init [no test files]
|
||||
ok github.com/minio/minio/internal/ioutil 1.921s
|
||||
ok github.com/minio/minio/internal/jwt 0.423s
|
||||
ok github.com/minio/minio/internal/kms 0.538s
|
||||
ok github.com/minio/minio/internal/lock 1.077s
|
||||
ok github.com/minio/minio/internal/logger 0.546s
|
||||
? github.com/minio/minio/internal/logger/message/audit [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/console [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/http [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/kafka [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/loggertypes [no test files]
|
||||
? github.com/minio/minio/internal/logger/target/testlogger [no test files]
|
||||
ok github.com/minio/minio/internal/lsync 10.501s
|
||||
? github.com/minio/minio/internal/mcontext [no test files]
|
||||
? github.com/minio/minio/internal/mountinfo [no test files]
|
||||
? github.com/minio/minio/internal/net [no test files]
|
||||
? github.com/minio/minio/internal/once [no test files]
|
||||
ok github.com/minio/minio/internal/pubsub 0.511s
|
||||
ok github.com/minio/minio/internal/rest 0.525s
|
||||
ok github.com/minio/minio/internal/ringbuffer 1.316s
|
||||
ok github.com/minio/minio/internal/s3select 0.603s
|
||||
ok github.com/minio/minio/internal/s3select/csv 0.446s
|
||||
ok github.com/minio/minio/internal/s3select/json 0.439s
|
||||
ok github.com/minio/minio/internal/s3select/jstream 0.417s
|
||||
? github.com/minio/minio/internal/s3select/parquet [no test files]
|
||||
? github.com/minio/minio/internal/s3select/simdj [no test files]
|
||||
ok github.com/minio/minio/internal/s3select/sql 0.425s
|
||||
ok github.com/minio/minio/internal/store 1.451s
|
||||
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"file": "cmd/replication-delete-mrf_test.go",
|
||||
"before_sha256": "5e160f7e19cbbb8cd5fa4e7ffd9cff9e09361b3fc4c5ee5ae61458f99c777781",
|
||||
"after_sha256": "6fcfaf505d28f98e42dff9c0d965895be2c2d112e9e996220d424aea1f76d691",
|
||||
"body_sha256": "5b3cecec74273540f4a5f82ca55e8a28545d0252822af3568f7e36b19011bda8",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
},
|
||||
{
|
||||
"file": "cmd/replication-delete-operation_test.go",
|
||||
"before_sha256": "2888a04a543324776041316de2821f388d28c3c1a6f5d0031e5ac9d34f58d504",
|
||||
"after_sha256": "2e674cab5ca4dbc38cb2c1ddcca117269276e6419e1869c154c3bd6a05676715",
|
||||
"body_sha256": "7921af42a9f123450e3e567d0bf65cd030678404709c77fde4a9cbb366230c35",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
},
|
||||
{
|
||||
"file": "cmd/server_deadline_config_test.go",
|
||||
"before_sha256": "1013157f83baa5f7882ec2d41c7b1fccb9e05fb418d0fa61263953037c9698c4",
|
||||
"after_sha256": "a8259d273922a8973b44d9a468791761d373fe265fff8b2b53871e4626b11405",
|
||||
"body_sha256": "5714a9275aee7230d54ba8ed03e3705a3cf6e150bda122929a9131b4b2b8dd5c",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
},
|
||||
{
|
||||
"file": "internal/deadlineconn/deadlineconn_strict_test.go",
|
||||
"before_sha256": "f405690c9ff044595f48323d68f4a9b33ce695b3ad6820f54f17151067bfae5e",
|
||||
"after_sha256": "b4aec28c5daddb36e6ebb98dd8af2a44b7a2c48f0660068534f69669609f67a7",
|
||||
"body_sha256": "ab2b54e29ed34de6e59ba2fa4984d61ed5f93d74ae4f6cd22dd44ba77a37e569",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
},
|
||||
{
|
||||
"file": "internal/http/dial_deadline_linux_test.go",
|
||||
"before_sha256": "0939d05b72a09760d53fcdf249775989e3f89bca824b9961d0b2a657ebfdf41e",
|
||||
"after_sha256": "c4c83bda92ba9bac53166453920456134b3d8265cd59101b4203067c67eca59e",
|
||||
"body_sha256": "fac091ef08f29fe32c2668eccd8cd505106c9fe3c4715e4c3ed9063d4b71c86e",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
},
|
||||
{
|
||||
"file": "internal/http/server_deadline_test.go",
|
||||
"before_sha256": "a6e687b3904a876fa92a4c5b86453159f3e5a38a4b9412dc213c7772f47fbf0b",
|
||||
"after_sha256": "87303cc389bf1cf759898489f06b001073de2dd9c4b3687c4eca14aa186b62ab",
|
||||
"body_sha256": "20b238f34090c356b2254388e863d77d0a316f33b849291d15d94fad260f23dd",
|
||||
"body_identical": true,
|
||||
"build_prefix_preserved": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"head": "80684fed59f556d579e268c5a855d936c1347b68",
|
||||
"checks": {
|
||||
"r6_shared_file_patch": true,
|
||||
"r5_shared_file_patch": true,
|
||||
"cmd/erasure-object.go": true,
|
||||
"cmd/erasure-server-pool-consistency.go": true,
|
||||
"cmd/erasure-server-pool.go": true,
|
||||
"cmd/object-handlers-common.go": true,
|
||||
"cmd/object-handlers.go": true,
|
||||
"cmd/object-multipart-handlers.go": true,
|
||||
"cmd/replication-tagging-order_test.go": true,
|
||||
"cmd/replication-tagging-sender_test.go": true,
|
||||
"cmd/bucket-replication-utils.go": true,
|
||||
"cmd/replication-delete-marker_test.go": true,
|
||||
"cmd/replication-delete-operation_test.go": true,
|
||||
"cmd/replication-delete-mrf_test.go": true,
|
||||
"cmd/common-main.go": true,
|
||||
"cmd/server-main.go": true,
|
||||
"internal/deadlineconn/deadlineconn.go": true,
|
||||
"internal/http/listener.go": true,
|
||||
"internal/http/server.go": true,
|
||||
"cmd/server_deadline_config_test.go": true,
|
||||
"internal/deadlineconn/deadlineconn_strict_test.go": true,
|
||||
"internal/http/dial_deadline_linux_test.go": true,
|
||||
"internal/http/server_deadline_test.go": true,
|
||||
"only_reviewed_hygiene_changes": true,
|
||||
"dco:80684fed59f556d579e268c5a855d936c1347b68": true,
|
||||
"dco:055030ea53ca92ee22ce1e601ef4757c247edde8": true,
|
||||
"dco:aea3882c95d16ec5598a07b40d593e04054137a9": true,
|
||||
"dco:0c61128d23f05ce6b37e7ace713c3ffbfb68f4cb": true,
|
||||
"dco:680eac66e40b0980bc20e70d7ad34185096e63f5": true
|
||||
},
|
||||
"all_pass": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ok github.com/minio/minio/cmd 1.583s
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"-p",
|
||||
"2",
|
||||
"./cmd",
|
||||
"-run",
|
||||
"^TestAPIPoolsTaggingReplicaDeletion$",
|
||||
"-count=1",
|
||||
"-timeout=2m"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"seconds": 5.096,
|
||||
"log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/isolated-pools-before.log",
|
||||
"log_sha256": "f014a1a72dda7b973cd1e0b7e77c70b470eeebca0d04805d98f07c2622c87b1e"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
Checking dependencies
|
||||
Building Silo binary to './silo'
|
||||
@@ -0,0 +1,7 @@
|
||||
Running lint check
|
||||
0 issues.
|
||||
typos binary is not found.. skipping..
|
||||
compatibility manifest: imports=119 env=428 metrics=19 headers=87 routes=224 roots=1 grid=3 storage=16 policy=59 brand=181 sha256=ad05829578cf879b462a12fa65f3c10c8c7c6aa8d6c329e04779459eec2645be
|
||||
Silo rebrand compatibility baseline is unchanged
|
||||
Silo delivery and runtime rebrand checks passed
|
||||
docker entrypoint argv compatibility tests passed
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"candidate": "055030ea53ca92ee22ce1e601ef4757c247edde8",
|
||||
"base": "9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe",
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"cli_version": "2.1.270",
|
||||
"command": [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"--print",
|
||||
"--model",
|
||||
"claude-opus-5",
|
||||
"--effort",
|
||||
"max",
|
||||
"--safe-mode",
|
||||
"--permission-mode",
|
||||
"plan",
|
||||
"--tools",
|
||||
"Read,Grep,Glob",
|
||||
"--strict-mcp-config",
|
||||
"--no-session-persistence",
|
||||
"--add-dir",
|
||||
"/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab",
|
||||
"--add-dir",
|
||||
"/Users/vonng/pgsty/silo",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose"
|
||||
],
|
||||
"source_sha256": {
|
||||
"cmd/bucket-replication-utils.go": "365641c760901641e8320cc5123697ab92a46f1add612048b991ed2d1cfad43b",
|
||||
"cmd/bucket-replication.go": "2e766c5946dcabaea79455b50a8e426f404e55c92dcbccbe28d55906e2e43843",
|
||||
"cmd/common-main.go": "f8777fe8a07d175aceee07b4dd13792b2384449c404c004c38a06893be997843",
|
||||
"cmd/erasure-object.go": "4bc848685ea714d88cabbd5d1b8585fbcc06f7b19c775e1a811030e783d0e1a4",
|
||||
"cmd/erasure-server-pool-consistency.go": "d2736ef6bffbb5c5758eba8df38f8d4ecb888a838ab0de8ad3cf015c051f8ad7",
|
||||
"cmd/erasure-server-pool.go": "87ad0b25dfa3081d0e63d0073b788614a9c88e2498a2ce0956b93f8a0a03ef53",
|
||||
"cmd/object-handlers-common.go": "101bd7d7447072d13fed50983b69b562e4725632645e623d7fdd490f388ecdec",
|
||||
"cmd/object-handlers.go": "61897a260f3f5f660f41edcb50956c60e914ef98f9a987da824f16d78171fde2",
|
||||
"cmd/object-multipart-handlers.go": "d9622c69c540ab32dd23916e3f534b6886473a98370c9dd17673e69a423b2a7e",
|
||||
"cmd/replication-delete-marker_test.go": "d967787804d558ac6266b113228fdf4a4f9fcb7cab39138a4fb07558814ccca4",
|
||||
"cmd/replication-delete-mrf_test.go": "5e160f7e19cbbb8cd5fa4e7ffd9cff9e09361b3fc4c5ee5ae61458f99c777781",
|
||||
"cmd/replication-delete-operation_test.go": "2888a04a543324776041316de2821f388d28c3c1a6f5d0031e5ac9d34f58d504",
|
||||
"cmd/replication-tagging-order_test.go": "c8260b4ccf82fa615e1e24b35a07f2d1aacbcf776e5c6f9dadffea4a09ad6ea8",
|
||||
"cmd/replication-tagging-sender_test.go": "3770a1a48a6efe58fe8127e1e4fdf6bd7cf171e17db20f15222ea2f7b85db1af",
|
||||
"cmd/server-main.go": "04c265de211412ba0297396928096d7f2d971244a957a3126154846035263514",
|
||||
"cmd/server_deadline_config_test.go": "1013157f83baa5f7882ec2d41c7b1fccb9e05fb418d0fa61263953037c9698c4",
|
||||
"internal/deadlineconn/deadlineconn.go": "b9272ef640f1d4403b3d0af6cdbaba9186c51ad9a0226dfe449e8ef738e1ec4b",
|
||||
"internal/deadlineconn/deadlineconn_strict_test.go": "f405690c9ff044595f48323d68f4a9b33ce695b3ad6820f54f17151067bfae5e",
|
||||
"internal/http/dial_deadline_linux_test.go": "0939d05b72a09760d53fcdf249775989e3f89bca824b9961d0b2a657ebfdf41e",
|
||||
"internal/http/listener.go": "49628575367f6ab9b6986caf594726d74d370f7d2ac4eed582903600b6eb3fa2",
|
||||
"internal/http/server.go": "b7b0355f2781f8c5f7c77bc910cd4180cd3e5f22a87de41bd35ef119d36b4cdf",
|
||||
"internal/http/server_deadline_test.go": "a6e687b3904a876fa92a4c5b86453159f3e5a38a4b9412dc213c7772f47fbf0b"
|
||||
},
|
||||
"diff_sha256": "d8c4e60f9e4a5b1338f3e6e07b758b1bb279db80eec26847e3b35fde0d049485",
|
||||
"prompt_sha256": "f95edcf71afedceab190ff57bfbec812b06c8c18b5370d887131a760228c2b2c",
|
||||
"started_at": "2026-09-15T16:41:37.757693+00:00",
|
||||
"status": "completed",
|
||||
"exit_code": 0,
|
||||
"actual_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"finished_at": "2026-09-15T16:53:09.761932+00:00",
|
||||
"raw_sha256": "3e1a45ebdf3f4420fb05647bb383c00d0a86452a6c357a92679485c99d8f4eb3",
|
||||
"stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"result_subtype": "success",
|
||||
"is_error": false
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
Independently review the complete SILO R4-R8 integration candidate for a user-authorized merge to main. You are the real Claude Opus reviewer; provide your own conclusion from source inspection. Read-only: no edits, no GitHub actions, no test execution claims.
|
||||
|
||||
Exact candidate: 055030ea53ca92ee22ce1e601ef4757c247edde8; integration branch codex/merge-r4-r8. Base: 9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe, already contains separately reviewed and CI-accepted R4 (SSE-KMS tag timestamp) and R7 (replication metadata/aws-chunked). This candidate adds the final R5, R6, and R8 local repairs, cherry-picked without conflicts and with provenance/DCO preserved.
|
||||
|
||||
Read /Users/vonng/pgsty/silo/AGENTS.md and CONTRIBUTING.md. The maintained PGSTY stack is the release target; upstream MinIO compatibility is best effort. Read /Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/integration-code.diff and /Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/reviewed-source.json, then inspect complete relevant functions and tests in this worktree. Each repair already has real same-version Opus plan consensus: docs/investigations/r5/plan-v2.md and consensus.md; r6/plan-v3.md and consensus.md; r8/plan-v2.md and consensus.md. Prior implementation reviews and validation reports are supporting evidence, not substitutes for this integration review. The R6 v2 multi-target parser proof was disproved by a real storage counterexample and fixed only after v3 consensus; verify the final empty creation-update invariant.
|
||||
|
||||
Focus on concrete integration correctness:
|
||||
1. R5 tag revision persistence and empty/nonempty ordering through COPY, PUT, multipart, retries and source ACK, with R6 purge/MRF state writes and shared bucket-replication.go functions.
|
||||
2. R7 restores only six replication-specific fields. Verify tag values/timestamps remain handled correctly and aws-chunked is not reintroduced, including R4 KMS options.
|
||||
3. R6 marker creation versus canonical/legacy purge, all exits, per-target statistics, disk creation/replica metadata preservation, identity-checked marker 405 recovery, retry counts and bounded scanner fallback.
|
||||
4. R8 absolute request-header deadlines and CLI/env propagation, HTTP/1 streaming bodies, keep-alive/TLS/h2 boundaries, default DeadlineConn callers and effects on replication I/O.
|
||||
5. Full-package test global state, cleanup/initialization ordering and any compile/dependency conflicts that separate scoped tests would miss.
|
||||
|
||||
Current local make verifiers is running; full candidate runtime tests and actual PR CI are still pending. Do not label them as passed. The host previously had ENOSPC; it now has more than 700 GiB free, so standard full-package tests can run without capacity overlays. Historical reports accurately retain earlier environment limits and incomplete multi-process deployment acceptance.
|
||||
|
||||
Two new R6 and four new R8 test files have boilerplate copyright headers queued for repository-policy normalization, after confirming original authorship. They will be header-only changes with package-and-body byte equivalence checked; do not conflate them with production semantic edits. Any actual implementation blocker will be fixed and re-reviewed as needed.
|
||||
|
||||
Return GO_WITH_NONBLOCKING_NOTES or REQUEST_CHANGES, number of blocking findings, exact candidate identity and inspected scope. For each blocker give path/line, real triggering conditions, impact and smallest compatible correction. Clearly distinguish inherited out-of-scope limitations from newly introduced defects and required CI from optional further deployment tests. Do not invent a blocker merely from absence of production deployment. This request is main merge, not release/deploy.
|
||||
@@ -0,0 +1,49 @@
|
||||
The plan file can't be written (Write is disabled this session), so here is the complete review inline.
|
||||
|
||||
# SILO R4–R8 integration review — independent, source-only
|
||||
|
||||
**Verdict: GO_WITH_NONBLOCKING_NOTES — 0 blocking findings.**
|
||||
|
||||
**Candidate identity:** head `055030ea53ca92ee22ce1e601ef4757c247edde8`, branch `codex/merge-r4-r8`, base `9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe`, worktree `/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/silo`. Scope of this review is a **main merge**, not a release or deployment. I executed nothing — no tests, no builds; every statement below is from reading source.
|
||||
|
||||
**Inspected scope:** `integration-code.diff` in full (3367 lines) and all 22 files in `reviewed-source.json`, then the complete surrounding functions in the worktree: `cmd/bucket-replication.go` (`replicateDelete`, `replicateDeleteToTarget`, `replicateObject`, `replicateAll`, `putReplicationOpts`, `getReplicationAction`, `queueMRFHeal`, `queueReplicaDeleteTask`, `queueMRFSave`, `queueReplicationHeal`), `bucket-replication-utils.go`, `bucket-replication-stats.go`, `xl-storage-format-v2.go DeleteVersion`, `erasure-object.go`, `erasure-multipart.go`, `erasure-server-pool.go`, `erasure-server-pool-consistency.go`, `object-handlers.go`, `object-handlers-common.go`, `object-multipart-handlers.go`, `handler-utils.go`, `object-api-options.go`, `bucket-handlers.go`, `common-main.go`, `server-main.go`, `internal/http/{server,listener}.go`, `internal/deadlineconn/deadlineconn.go`, all six new test files plus the base tests they interact with, `Makefile`, `.golangci.yml`, `AGENTS.md`, `CONTRIBUTING.md`, and the r5/r6/r8 plan + consensus records. Cross-checked against Go 1.27.1 `net/http/server.go` and pinned `minio-go/v7 v7.3.1-0.20260910142817-60bd07042d49`.
|
||||
|
||||
## Independent confirmation of the key claims
|
||||
|
||||
**R6 final empty creation-update invariant (the disproved-v2 point).** The v2 proof fails exactly as recorded: `replStatusRegex` (`bucket-replication-utils.go:168`) matches `arn1=;arn2=;` yielding `{arn1: ";arn2="}` — non-empty — while single-target `arn1=;` does not match at all. The v3 fix at `bucket-replication.go:575-582` sets `ReplicationStatusInternal=""`, `Targets=nil`, `ReplicaStatus=""`; `CompositeReplicationStatus` (`bucket-replication-utils.go:356-379`) then returns empty via both the internal string and the replica fallback, so `xlMetaV2.DeleteVersion` (`xl-storage-format-v2.go:1396-1405`, `1438-1447`) skips the creation/replica write while still writing `VersionPurgeStatusKey` (`1406-1408`, `1448-1450`). `ReplicationTimeStamp` is therefore inert (consensus N5 holds). I also confirmed the counterexample's precondition independently: `erasure-object.go:2099-2105` leaves `deleteMarker=true` when the stored marker carries no purge status, which is what sets `fi.Deleted=true` and reaches the rewriting branch. COMPLETE purges still remove the version (`1379-1393`, `1457-1459`).
|
||||
|
||||
**R6 classification / all exits.** `isVersionPurge()` (`1954-1956`) parses as `VersionID != "" || (DeleteMarkerVersionID != "" && !VersionPurgeStatus().Empty())`. Every live producer emits only one shape (`object-handlers.go:3232-3248`, `bucket-replication.go:3362-3378`, `3814-3836`), and the dir-object `nullVersionID` re-add (`bucket-handlers.go:552-555`, `679-681`) classifies as a purge under both old and new code, so no wire-form change there. All exits of `replicateDeleteToTarget` select the purge field consistently, HEAD probing is creation-only, `ReplicationDeleteMarker` is false for purges. `purgeReplicationStatus` maps only the legacy `COMPLETE` spelling; `ReplicationStats.Update` (`bucket-replication-stats.go:179-237`) consumes the passed status and never `rinfo.ReplicationStatus`, with `ri.Size == 0` giving count-only/zero-byte deltas. The `ResetStatusesMap` nil guard fixes a genuine nil-map assignment panic, since `ObjectToDelete.ReplicationState()` never initialises that map.
|
||||
|
||||
**R6 MRF recovery / budget.** `queueMRFHeal:4118-4125` parses as `(err != nil && !validMarker) || oi.Name == ""`; `decodeDirObject` is identity for both `obj` and `dir/`, matching `GetObjectInfo`'s decoded name, and `erasure-server-pool-consistency.go:143-145` is what returns a populated marker `ObjectInfo` with `MethodNotAllowed`. `RetryCount int` matches the persisted `MRFReplicateEntry.RetryCount` and `QueueReplicationHeal`'s parameter — no on-disk format change. All three increment sites feed the existing `> mrfRetryLimit` drop accounting (`3926-3931`), and the scanner fallback restarts with a fresh budget.
|
||||
|
||||
**R5 tag revision flow.** Sender: `replicationTaggingTimestamp` (`804-812`) serves both the full retransmit and the metadata-COPY branch, the latter now failing closed symmetrically (`1720-1725`). `getCopyObjMetadata:765-766` always emits `X-Amz-Tagging` (possibly empty) + `REPLACE`; minio-go `copyObjectDo` forwards empty header values; the receiver's `getRequestHeaderOrQueryValue` (`handler-utils.go:160-171`) treats presence-with-empty-value as authoritative — so an ordered deletion is genuinely representable on the wire. Receiver: `CopyObjectHandler:1799-1840` captures the stored stamp before REPLACE rebuilds the map, every branch writes or deletes the key explicitly, and the unconditional `delete(encMetadata, …)` is safe *because* of that, blocking the SSE-C rotation snapshot (`1655-1659`) from re-merging at `1910`. `PutObjectHandler:2323-2325` and `NewMultipartUploadHandler:315-318` mutate the same map that becomes `opts.UserDefined` (`object-api-options.go:451`), and the header is parsed only under trusted replication (`388-396`). Ordering is re-applied under the write lock by the existing `reconcileStoredObjectTags` callers (`erasure-object.go:136-139`, `1312-1315`; `erasure-multipart.go:1161-1189`; `erasure-server-pool.go:1443-1456`) — which is also what keeps the base R4 KMS test's `missing-timestamp` expectation intact. Dropping the `ri.UserTags` re-injection in the source ACK (`1294-1304`) is right: `cleanMetadata` strips the tagging key from `UserDefined`, so stored tags are now left alone, and the pools path re-derives them from merged `UserTags`.
|
||||
|
||||
**R7 boundary.** `replicationToInternalHeaders` has exactly six entries (`handler-utils.go:106-114`); `extractReplicationMetadataFromMime` restores only those and re-extracts no ordinary metadata, so the `aws-chunked` normalisation in `extractMetadata:225-241` is not undone. R5 touches neither, and R4's KMS options (`object-api-options.go:449-460`) still carry the three replication timestamps unmodified.
|
||||
|
||||
**R8 deadlines.** Against Go 1.27.1: header window set at `server.go:2038`/`2177`, whole-request deadline unconditionally at `1103`, `StateActive` at `2056-2058` firing after *every* successful `readRequest` because `readRequest` calls `setInfiniteReadLimit()` at `1067`. That is the one place where the naive reading of the `c.r.remain` comment is wrong — the pipelined/fully-buffered request does get the strict→rolling flip, so consensus N2 is correct. `startBackgroundRead` (`741`) and `hijackLocked` zero the deadline, which `infReads` honours — that is why background reads and hijacked grid/websocket conns still work. The strict cap only shortens, never extends; zero/past semantics unchanged; `readExplicit`/`readDeadlineStrict` only touched under `mu`. The h2 skip is defensive rather than load-bearing (net/http uses `skipHooks` for ALPN h2; the h2 server zeroes the conn deadline), and a nil `raw` fails the type assertion safely. Strict mode is opt-in, so every other `DeadlineConn` caller — the optional Linux internode dialer (`dial_linux.go:126-131`, currently disabled at `server-main.go:422`) and all outbound replication transports — keeps legacy rolling reads. The real fix is propagation: flag, field and `UseReadHeaderTimeout` already existed; `ctxt.ReadHeaderTimeout` was simply never populated before `common-main.go:448`.
|
||||
|
||||
**Full-package test state.** No duplicate symbols (`tagTestCapacityDisk` defined once in base `erasure-server-pool-tags_test.go:258`); no helper collisions in `internal/http`; `testdata/config/1.yaml`, `fmtGenFlags`, `serverCmd.Flags` all exist; `buildServerCtxt` mutates no globals. Globals are swapped/restored, and `prepareFS`/`prepareErasure`/`initAPIHandlerTest` re-run `initAllSubsystems` between backends, so leaked target-sys entries can't cross a fixture boundary. `logger.UpdateAuditWebhooks(ctx, nil)` really clears the list (`targets.go:227-273`), so the audit fixture is re-enterable across the SD and Erasure passes. `make verifiers` = lint + check-gen + rebrand-guard, and `.golangci.yml` enables no header linter.
|
||||
|
||||
## Blocking findings
|
||||
|
||||
**None.**
|
||||
|
||||
## Non-blocking notes (newly introduced here)
|
||||
|
||||
1. `bucket-replication.go:804-812` — an unparseable stored tagging timestamp now fails replication for *untagged* objects too. Trigger: a corrupt/legacy value, or the empty value `erasure-server-pool-consistency.go:211-214` can persist when a non-primary pool holds a malformed stamp. Fail-closed (FAILED + MRF retry), never silent divergence. Optional hardening: treat missing-or-empty as absent, keeping the hard error for non-empty malformed values.
|
||||
2. `bucket-replication.go:1031-1036` — any version with a tag revision now resolves to `replicateMetadata` instead of `replicateNone`; since `object-handlers.go:1835-1836` gives every non-replica COPY a revision, large resyncs issue one extra metadata COPY per object. Steady state is unaffected (COMPLETED gate at `3810`). Accepted in r5 consensus; flagged as operational cost.
|
||||
3. `bucket-replication.go:435-445`, `551-566`, `1958-1964` — purge audit status changes from legacy `COMPLETE` to `COMPLETED`, and per-target purge outcomes now reach `globalReplicationStats` where the empty creation status previously recorded nothing. Intended (r6 dispositions 1–2); worth one release-note line for audit/metric consumers.
|
||||
4. `common-main.go:448` — now that `ReadHeaderTimeout` is populated it also participates in `net/http`'s `tlsHandshakeTimeout()` minimum. No change at defaults (30s vs 30s); a small configured header timeout also shortens the TLS handshake window. Documented in r8 consensus N1.
|
||||
5. `replication-tagging-order_test.go:1805-1814` — `TestAPIPoolsTaggingReplicaDeletion` calls `globalBucketMetadataSys.Update` after `consistencyPools`, which (unlike `initAPIHandlerTest`) does not run `initAllSubsystems`; run in isolation with `-run` it would nil-dereference. Harmless in full-package CI. Smallest fix: use `initAPIHandlerTest`, as the neighbouring consistency tests do.
|
||||
6. `replication-delete-mrf_test.go:1208-1215` — the audit cleanup cancels targets twice; non-panicking because `SafeClose(nil)` only prints a stack (`internal/ioutil/ioutil.go:463-471`), but it can add stderr noise.
|
||||
7. Headers (already queued, header-only): the two new R6 test files use `Copyright (c) 2026 PGSTY` + SPDX-only, and the four new R8 test files carry `Copyright (c) 2015-2021 MinIO, Inc.` despite being new original work; CONTRIBUTING.md asks new original files to name their actual holders with AGPL boilerplate (the two new R5 files already comply). No verifier enforces headers, so this does not gate CI and is distinct from production semantics.
|
||||
|
||||
## Inherited / out-of-scope limitations (not introduced by this candidate)
|
||||
|
||||
Purge-target subset merging under narrowed fan-out; nil/missing target clients cannot restore already-lost tracking; the `ResetStatusesMap` key asymmetry between `targetResetHeader(arn)` writes and bare-`arn` reads (`bucket-replication-utils.go:392-399` vs `419-426`) — the candidate only adds the panic-preventing nil guard; the shared `replStatusRegex` still mis-parses serialized empty statuses (R6 deliberately works around it at the write site); unqualified (no `versionId`) replica PUT has no stored-tag reconcile (`object-handlers.go:2442`); TLS handshake *write* deadlines keep rolling behaviour (r8 N9); historical objects without a revision, tag-filter target selection and real multi-site clock skew remain unsolved (r5 consensus).
|
||||
|
||||
## Verification still owed (I ran nothing; nothing below is "passed")
|
||||
|
||||
**Required for this merge — repository gates only:** `make verifiers` (lint, check-gen, rebrand-guard), `make build` producing `silo`, and full-package tests, at minimum `./cmd`, `./internal/http`, `./internal/deadlineconn`; a Linux CI leg to compile and run the build-tagged `TestInternodeDialReadDeadline`; DCO sign-off/authorship checks on the cherry-picked commits. With >700 GiB free, no capacity overlay is needed.
|
||||
|
||||
**Optional, explicitly not a merge gate:** `SILO_TEST_LONG_UPLOAD=1` (>30s default-idle transfer regressions), multi-process/multi-node deployment acceptance, cross-region replication mesh. Their absence is a coverage boundary, not a defect; I do not treat missing production deployment as a blocker.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"binary": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/silo/silo",
|
||||
"binary_sha256": "1cc536f1a3c8d8372ff2d5b140b1fd2bc98a299324fea0f73f67288d48104ce4",
|
||||
"expected_rejection": true,
|
||||
"cases": [
|
||||
{
|
||||
"source": "flag",
|
||||
"header_timeout_ms": 100,
|
||||
"idle_timeout_ms": 2000,
|
||||
"header_completion_delay_ms": 400,
|
||||
"rejected": true,
|
||||
"status": "",
|
||||
"elapsed_seconds": 0.403,
|
||||
"still_alive": true
|
||||
},
|
||||
{
|
||||
"source": "environment",
|
||||
"header_timeout_ms": 100,
|
||||
"idle_timeout_ms": 2000,
|
||||
"header_completion_delay_ms": 400,
|
||||
"rejected": true,
|
||||
"status": "",
|
||||
"elapsed_seconds": 0.402,
|
||||
"still_alive": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"command": [
|
||||
"python3",
|
||||
"docs/investigations/r8/evidence/runtime_probe.py",
|
||||
"/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/silo/silo",
|
||||
"fixed",
|
||||
"/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/runtime-probe"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"seconds": 1.638,
|
||||
"stdout_sha256": "e493d9757c130c2531072dc0eaee42b8fc1fa0f45fc43d0d0e098aac55f0d387"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
silo version DEVELOPMENT.2026-09-15T16-44-34Z (commit-id=80684fed59f556d579e268c5a855d936c1347b68)
|
||||
Runtime: go1.27.1 darwin/arm64
|
||||
License: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html
|
||||
Copyright: 2015-2025 MinIO, Inc.
|
||||
Modifications: Copyright 2025-2026 PGSTY
|
||||
Source compatibility: based on MinIO technology
|
||||
@@ -0,0 +1,3 @@
|
||||
ok github.com/minio/minio/cmd 13.654s
|
||||
ok github.com/minio/minio/internal/deadlineconn 2.528s
|
||||
ok github.com/minio/minio/internal/http 14.301s
|
||||
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"head": "80684fed59f556d579e268c5a855d936c1347b68",
|
||||
"baseline": "9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe",
|
||||
"source_sha256": {
|
||||
"cmd/bucket-replication-utils.go": "365641c760901641e8320cc5123697ab92a46f1add612048b991ed2d1cfad43b",
|
||||
"cmd/bucket-replication.go": "2e766c5946dcabaea79455b50a8e426f404e55c92dcbccbe28d55906e2e43843",
|
||||
"cmd/common-main.go": "f8777fe8a07d175aceee07b4dd13792b2384449c404c004c38a06893be997843",
|
||||
"cmd/erasure-object.go": "4bc848685ea714d88cabbd5d1b8585fbcc06f7b19c775e1a811030e783d0e1a4",
|
||||
"cmd/erasure-server-pool-consistency.go": "d2736ef6bffbb5c5758eba8df38f8d4ecb888a838ab0de8ad3cf015c051f8ad7",
|
||||
"cmd/erasure-server-pool.go": "87ad0b25dfa3081d0e63d0073b788614a9c88e2498a2ce0956b93f8a0a03ef53",
|
||||
"cmd/object-handlers-common.go": "101bd7d7447072d13fed50983b69b562e4725632645e623d7fdd490f388ecdec",
|
||||
"cmd/object-handlers.go": "61897a260f3f5f660f41edcb50956c60e914ef98f9a987da824f16d78171fde2",
|
||||
"cmd/object-multipart-handlers.go": "d9622c69c540ab32dd23916e3f534b6886473a98370c9dd17673e69a423b2a7e",
|
||||
"cmd/replication-delete-marker_test.go": "d967787804d558ac6266b113228fdf4a4f9fcb7cab39138a4fb07558814ccca4",
|
||||
"cmd/replication-delete-mrf_test.go": "6fcfaf505d28f98e42dff9c0d965895be2c2d112e9e996220d424aea1f76d691",
|
||||
"cmd/replication-delete-operation_test.go": "2e674cab5ca4dbc38cb2c1ddcca117269276e6419e1869c154c3bd6a05676715",
|
||||
"cmd/replication-tagging-order_test.go": "c8260b4ccf82fa615e1e24b35a07f2d1aacbcf776e5c6f9dadffea4a09ad6ea8",
|
||||
"cmd/replication-tagging-sender_test.go": "3770a1a48a6efe58fe8127e1e4fdf6bd7cf171e17db20f15222ea2f7b85db1af",
|
||||
"cmd/server-main.go": "04c265de211412ba0297396928096d7f2d971244a957a3126154846035263514",
|
||||
"cmd/server_deadline_config_test.go": "a8259d273922a8973b44d9a468791761d373fe265fff8b2b53871e4626b11405",
|
||||
"internal/deadlineconn/deadlineconn.go": "b9272ef640f1d4403b3d0af6cdbaba9186c51ad9a0226dfe449e8ef738e1ec4b",
|
||||
"internal/deadlineconn/deadlineconn_strict_test.go": "b4aec28c5daddb36e6ebb98dd8af2a44b7a2c48f0660068534f69669609f67a7",
|
||||
"internal/http/dial_deadline_linux_test.go": "c4c83bda92ba9bac53166453920456134b3d8265cd59101b4203067c67eca59e",
|
||||
"internal/http/listener.go": "49628575367f6ab9b6986caf594726d74d370f7d2ac4eed582903600b6eb3fa2",
|
||||
"internal/http/server.go": "b7b0355f2781f8c5f7c77bc910cd4180cd3e5f22a87de41bd35ef119d36b4cdf",
|
||||
"internal/http/server_deadline_test.go": "87303cc389bf1cf759898489f06b001073de2dd9c4b3687c4eca14aa186b62ab"
|
||||
},
|
||||
"no_capacity_overlay": true,
|
||||
"race_test_selection": [
|
||||
"TestAPILocalTaggingAlwaysAdvancesRevision",
|
||||
"TestAPIPoolsTaggingReplicaDeletion",
|
||||
"TestAPITaggingMultipartCommitRechecksRevision",
|
||||
"TestAPITaggingReplicationOrdering",
|
||||
"TestAPITaggingReplicationOrderingKMS",
|
||||
"TestAPITaggingSSECRotationPreservesDeletionRevision",
|
||||
"TestAPITaggingUnqualifiedCopyOrdering",
|
||||
"TestConcurrentStrictReadDeadline",
|
||||
"TestDefaultReadDeadlineStillRenews",
|
||||
"TestInternodeDialReadDeadline",
|
||||
"TestLocalTaggingCommitCannotRegressRevision",
|
||||
"TestReplicateDeleteMarkerPurge",
|
||||
"TestReplicateDeleteMarkerTargetSemantics",
|
||||
"TestReplicateDeleteOperationExits",
|
||||
"TestReplicateDeletePurgeMissingTargetState",
|
||||
"TestReplicationDeleteQueueFullRetryBudget",
|
||||
"TestReplicationMRFMarkerRecovery",
|
||||
"TestServerBackgroundReadNoDeadline",
|
||||
"TestServerConnStateHook",
|
||||
"TestServerContinuousDownload",
|
||||
"TestServerContinuousUpload",
|
||||
"TestServerDefaultIdleLongDownload",
|
||||
"TestServerDefaultIdleLongUpload",
|
||||
"TestServerEarlyBodyClose",
|
||||
"TestServerHTTP2Deadlines",
|
||||
"TestServerHijackedDeadline",
|
||||
"TestServerIdleBodyDeadline",
|
||||
"TestServerKeepAliveDeadline",
|
||||
"TestServerPipelinedDeadline",
|
||||
"TestServerReadHeaderDeadline",
|
||||
"TestServerReadHeaderTimeoutConfig",
|
||||
"TestServerTLSHandshakeReadDeadline",
|
||||
"TestStrictExpiredFutureReadDeadline",
|
||||
"TestStrictReadDeadline",
|
||||
"TestStrictReadDeadlineRepeatedRenewal",
|
||||
"TestTaggingProductionCopyWireShape",
|
||||
"TestTaggingRepeatedValueNeedsRevisionDelivery",
|
||||
"TestTaggingReplicaContentDuplicateGuard",
|
||||
"TestTaggingReplicationSenderRetryAndAcknowledgment",
|
||||
"TestTaggingTimestampWire"
|
||||
],
|
||||
"started_at": "2026-09-15T16:45:09.640646+00:00",
|
||||
"checks": [
|
||||
{
|
||||
"name": "make-verifiers-final",
|
||||
"command": [
|
||||
"make",
|
||||
"verifiers",
|
||||
"GOLANGCI=/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/golangci-serial"
|
||||
],
|
||||
"env": {
|
||||
"GOMAXPROCS": "4",
|
||||
"GOFLAGS": "-p=2",
|
||||
"MINIO_API_REQUESTS_MAX": "10000"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"seconds": 76.933,
|
||||
"log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/make-verifiers-final.log",
|
||||
"log_sha256": "54c906cff1d33d0148fbc4cac918c0785a3bcc8f7a4eb7e04a2f774c2f010bb4"
|
||||
},
|
||||
{
|
||||
"name": "make-build",
|
||||
"command": [
|
||||
"make",
|
||||
"build"
|
||||
],
|
||||
"env": {
|
||||
"GOMAXPROCS": "4",
|
||||
"GOFLAGS": "-p=2",
|
||||
"MINIO_API_REQUESTS_MAX": "10000"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"seconds": 19.366,
|
||||
"log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/make-build.log",
|
||||
"log_sha256": "6ba9b545236be964861749c72e7609edf12b8f470df30d1ede8fd62f497e629b"
|
||||
},
|
||||
{
|
||||
"name": "silo-version",
|
||||
"command": [
|
||||
"./silo",
|
||||
"--version"
|
||||
],
|
||||
"env": {
|
||||
"GOMAXPROCS": "4",
|
||||
"GOFLAGS": "-p=2",
|
||||
"MINIO_API_REQUESTS_MAX": "10000"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"seconds": 1.259,
|
||||
"log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/silo-version.log",
|
||||
"log_sha256": "ada27f2be570c33df5712e86782a7be2ce3acfef54c8bb22a9230db3606513af"
|
||||
},
|
||||
{
|
||||
"name": "full-tests",
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"-p",
|
||||
"2",
|
||||
"./cmd",
|
||||
"./internal/...",
|
||||
"-count=1",
|
||||
"-timeout=30m"
|
||||
],
|
||||
"env": {
|
||||
"GOMAXPROCS": "4",
|
||||
"GOFLAGS": "-p=2",
|
||||
"MINIO_API_REQUESTS_MAX": "10000",
|
||||
"CGO_ENABLED": "0"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"seconds": 340.193,
|
||||
"log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/full-tests.log",
|
||||
"log_sha256": "24b986e2f5aef0e668116abaab5024bf19ac664aec010ca7aa5ef56886f47ad1"
|
||||
},
|
||||
{
|
||||
"name": "targeted-race",
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"-race",
|
||||
"-p",
|
||||
"2",
|
||||
"./cmd",
|
||||
"./internal/deadlineconn",
|
||||
"./internal/http",
|
||||
"-run",
|
||||
"^(TestAPILocalTaggingAlwaysAdvancesRevision|TestAPIPoolsTaggingReplicaDeletion|TestAPITaggingMultipartCommitRechecksRevision|TestAPITaggingReplicationOrdering|TestAPITaggingReplicationOrderingKMS|TestAPITaggingSSECRotationPreservesDeletionRevision|TestAPITaggingUnqualifiedCopyOrdering|TestConcurrentStrictReadDeadline|TestDefaultReadDeadlineStillRenews|TestInternodeDialReadDeadline|TestLocalTaggingCommitCannotRegressRevision|TestReplicateDeleteMarkerPurge|TestReplicateDeleteMarkerTargetSemantics|TestReplicateDeleteOperationExits|TestReplicateDeletePurgeMissingTargetState|TestReplicationDeleteQueueFullRetryBudget|TestReplicationMRFMarkerRecovery|TestServerBackgroundReadNoDeadline|TestServerConnStateHook|TestServerContinuousDownload|TestServerContinuousUpload|TestServerDefaultIdleLongDownload|TestServerDefaultIdleLongUpload|TestServerEarlyBodyClose|TestServerHTTP2Deadlines|TestServerHijackedDeadline|TestServerIdleBodyDeadline|TestServerKeepAliveDeadline|TestServerPipelinedDeadline|TestServerReadHeaderDeadline|TestServerReadHeaderTimeoutConfig|TestServerTLSHandshakeReadDeadline|TestStrictExpiredFutureReadDeadline|TestStrictReadDeadline|TestStrictReadDeadlineRepeatedRenewal|TestTaggingProductionCopyWireShape|TestTaggingRepeatedValueNeedsRevisionDelivery|TestTaggingReplicaContentDuplicateGuard|TestTaggingReplicationSenderRetryAndAcknowledgment|TestTaggingTimestampWire)$",
|
||||
"-count=1",
|
||||
"-timeout=15m"
|
||||
],
|
||||
"env": {
|
||||
"GOMAXPROCS": "4",
|
||||
"GOFLAGS": "-p=2",
|
||||
"MINIO_API_REQUESTS_MAX": "10000",
|
||||
"CGO_ENABLED": "1"
|
||||
},
|
||||
"exit_code": 0,
|
||||
"seconds": 46.286,
|
||||
"log": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/targeted-race.log",
|
||||
"log_sha256": "7d021e57e513ea81617df44a6253703622145bf5e72e6180de84c2bd3c7186d3"
|
||||
}
|
||||
],
|
||||
"source_unchanged": true,
|
||||
"finished_at": "2026-09-15T16:53:13.682876+00:00"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"baseline": "9f3037e941a49ab4cd8a0eed7c0f01083fbe4bbe",
|
||||
"reviewed_code_commit": "055030ea53ca92ee22ce1e601ef4757c247edde8",
|
||||
"tested_commit": "80684fed59f556d579e268c5a855d936c1347b68",
|
||||
"integration_branch": "codex/merge-r4-r8",
|
||||
"current_source_matches_tested_commit": true,
|
||||
"production_or_test_body_changes_after_review": false,
|
||||
"source_sha256": {
|
||||
"cmd/bucket-replication-utils.go": "365641c760901641e8320cc5123697ab92a46f1add612048b991ed2d1cfad43b",
|
||||
"cmd/bucket-replication.go": "2e766c5946dcabaea79455b50a8e426f404e55c92dcbccbe28d55906e2e43843",
|
||||
"cmd/common-main.go": "f8777fe8a07d175aceee07b4dd13792b2384449c404c004c38a06893be997843",
|
||||
"cmd/erasure-object.go": "4bc848685ea714d88cabbd5d1b8585fbcc06f7b19c775e1a811030e783d0e1a4",
|
||||
"cmd/erasure-server-pool-consistency.go": "d2736ef6bffbb5c5758eba8df38f8d4ecb888a838ab0de8ad3cf015c051f8ad7",
|
||||
"cmd/erasure-server-pool.go": "87ad0b25dfa3081d0e63d0073b788614a9c88e2498a2ce0956b93f8a0a03ef53",
|
||||
"cmd/object-handlers-common.go": "101bd7d7447072d13fed50983b69b562e4725632645e623d7fdd490f388ecdec",
|
||||
"cmd/object-handlers.go": "61897a260f3f5f660f41edcb50956c60e914ef98f9a987da824f16d78171fde2",
|
||||
"cmd/object-multipart-handlers.go": "d9622c69c540ab32dd23916e3f534b6886473a98370c9dd17673e69a423b2a7e",
|
||||
"cmd/replication-delete-marker_test.go": "d967787804d558ac6266b113228fdf4a4f9fcb7cab39138a4fb07558814ccca4",
|
||||
"cmd/replication-delete-mrf_test.go": "6fcfaf505d28f98e42dff9c0d965895be2c2d112e9e996220d424aea1f76d691",
|
||||
"cmd/replication-delete-operation_test.go": "2e674cab5ca4dbc38cb2c1ddcca117269276e6419e1869c154c3bd6a05676715",
|
||||
"cmd/replication-tagging-order_test.go": "c8260b4ccf82fa615e1e24b35a07f2d1aacbcf776e5c6f9dadffea4a09ad6ea8",
|
||||
"cmd/replication-tagging-sender_test.go": "3770a1a48a6efe58fe8127e1e4fdf6bd7cf171e17db20f15222ea2f7b85db1af",
|
||||
"cmd/server-main.go": "04c265de211412ba0297396928096d7f2d971244a957a3126154846035263514",
|
||||
"cmd/server_deadline_config_test.go": "a8259d273922a8973b44d9a468791761d373fe265fff8b2b53871e4626b11405",
|
||||
"internal/deadlineconn/deadlineconn.go": "b9272ef640f1d4403b3d0af6cdbaba9186c51ad9a0226dfe449e8ef738e1ec4b",
|
||||
"internal/deadlineconn/deadlineconn_strict_test.go": "b4aec28c5daddb36e6ebb98dd8af2a44b7a2c48f0660068534f69669609f67a7",
|
||||
"internal/http/dial_deadline_linux_test.go": "c4c83bda92ba9bac53166453920456134b3d8265cd59101b4203067c67eca59e",
|
||||
"internal/http/listener.go": "49628575367f6ab9b6986caf594726d74d370f7d2ac4eed582903600b6eb3fa2",
|
||||
"internal/http/server.go": "b7b0355f2781f8c5f7c77bc910cd4180cd3e5f22a87de41bd35ef119d36b4cdf",
|
||||
"internal/http/server_deadline_test.go": "87303cc389bf1cf759898489f06b001073de2dd9c4b3687c4eca14aa186b62ab"
|
||||
},
|
||||
"compatibility_inventory_sha256": "208c78a9e9fc98d6de7f1e0f03cfec97d8491df65ad4de3d88f04c36c555f819",
|
||||
"evidence_sha256": {
|
||||
"evidence/full-tests.log": "24b986e2f5aef0e668116abaab5024bf19ac664aec010ca7aa5ef56886f47ad1",
|
||||
"evidence/header-equivalence.json": "0aee56aa978515579aa59215152b685f614cd5bda323fe26690a4c21f837f109",
|
||||
"evidence/integration-equivalence.json": "473711fa8660504275242b70e80622277a32c73e0c7df1de6f5ba2d6bbfcc54e",
|
||||
"evidence/isolated-pools-before.log": "f014a1a72dda7b973cd1e0b7e77c70b470eeebca0d04805d98f07c2622c87b1e",
|
||||
"evidence/isolated-pools-before.result.json": "3a761d8a6fd1869a9a9d2b3d506ddec4fdaaad098da2b6a2f85252acf9561774",
|
||||
"evidence/make-build.log": "6ba9b545236be964861749c72e7609edf12b8f470df30d1ede8fd62f497e629b",
|
||||
"evidence/make-verifiers-final.log": "54c906cff1d33d0148fbc4cac918c0785a3bcc8f7a4eb7e04a2f774c2f010bb4",
|
||||
"evidence/opus-integration.metadata.json": "c468e952e2364625ffe04a7221489185705857ad073f6fdb8dee3fe6aad9345c",
|
||||
"evidence/opus-integration.prompt.md": "f95edcf71afedceab190ff57bfbec812b06c8c18b5370d887131a760228c2b2c",
|
||||
"evidence/opus-review.md": "463f6b97e8d19929187242724e6bcfc2406217cd82702739716b65e19a8f2360",
|
||||
"evidence/runtime-probe.json": "e493d9757c130c2531072dc0eaee42b8fc1fa0f45fc43d0d0e098aac55f0d387",
|
||||
"evidence/runtime-probe.result.json": "d8703c201493cf865d758d53cbc6d92b71535345dafe3c2f27ca2a29ad53e353",
|
||||
"evidence/silo-version.log": "ada27f2be570c33df5712e86782a7be2ce3acfef54c8bb22a9230db3606513af",
|
||||
"evidence/targeted-race.log": "7d021e57e513ea81617df44a6253703622145bf5e72e6180de84c2bd3c7186d3",
|
||||
"evidence/validation-results.json": "29182a752a8ffb75367e8cce51e1f2ef080e453a059248ae2c868aa66627aede"
|
||||
},
|
||||
"original_raw_review": {
|
||||
"path": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/opus-integration.jsonl",
|
||||
"sha256": "3e1a45ebdf3f4420fb05647bb383c00d0a86452a6c357a92679485c99d8f4eb3"
|
||||
},
|
||||
"original_review_diff": {
|
||||
"path": "/Users/vonng/tmp/silo-r4-r8-main-20260916-01a0a5ab/integration-code.diff",
|
||||
"sha256": "d8c4e60f9e4a5b1338f3e6e07b758b1bb279db80eec26847e3b35fde0d049485"
|
||||
},
|
||||
"scope": "Local integration validation and independent source review. Final PR checks and remote merge are recorded separately."
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# R4 plan consensus and review disposition
|
||||
|
||||
## Agreed version
|
||||
|
||||
- Plan: [plan v1](plan-v1.md), SHA-256 `ad539f2071155de6955b583991684ed33c4bfe2e29660005840cdc97d7e1a754`. The frozen file remains unchanged.
|
||||
- Baseline: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`.
|
||||
- Actual reviewer: Claude Code 2.1.270, every assistant model in the review stream is `claude-opus-5`; explicit `--effort max`.
|
||||
- Opus: **GO_WITH_NONBLOCKING_NOTES**, zero blockers; explicitly agrees that this exact plan can enter local implementation. [Unedited returned review](opus-v1-review.md), [machine-readable provenance](opus-v1.metadata.json).
|
||||
- Codex: agrees that adding the already-parsed timestamp to the KMS literal fixes R4, and accepts the nonblocking dispositions below. **No blocking disagreement remains on plan v1.** No production source edits were made before this record was saved.
|
||||
- The agreement permits the planned local implementation and tests; it is not implementation acceptance, a merge decision or production release approval.
|
||||
|
||||
## Item-by-item disposition
|
||||
|
||||
| Opus ID | Disposition |
|
||||
|---|---|
|
||||
| R4-01 | Accepted citation correction here, leaving the agreed hash frozen: `ReplicaLockReconcile` is at baseline `object-handlers.go:1847`; encryption merge is at `:1903`. |
|
||||
| R4-02 | Accepted scope clarification: ErasureSD and Erasure16 are both single-pool local backends. KMS rewrites use PutObject under-lock reconciliation. Multi-pool and multi-site validation are optional and deferred to the wider integration gate. Test comments and the final report will identify this boundary. |
|
||||
| R4-03 | Accepted wording clarification: source encryption alone does not request destination encryption. Source-only SSE-C copy headers do not prevent destination bucket/default auto-KMS from selecting KMS. The three destination trigger categories stay unchanged. |
|
||||
| R4-04 | Accepted intent. The regression matrix uses identical expected mtime, ETag, trust and all three source timestamps across all encryption modes, giving field-by-field equivalence without constructing expected values through the production function. The temporary expanded baseline matrix fails only trusted valid KMS tag timestamps. |
|
||||
| R4-05 | Accepted optional test within the existing scope: a signed KMS COPY with nonempty tags and no source tag timestamp must preserve the stored value/time. This adds evidence, not production behavior. |
|
||||
| R4-06 | Registered as a separate unverified-impact finding: KMS construction also omits `ProxyHeaderSet`, `ProxyRequest`, `Speedtest` relative to `getDefaultOpts`. No R4 fix or correctness claim for those flags. Send the observation to the parent for separate triage; do not assign it to R5. |
|
||||
| R4-07 | Accepted. Assertions target final disk state; the REPLACE handler rebuilds metadata, while final stored-tag rejection occurs under the storage write lock. HTTP 200 alone is not acceptance. |
|
||||
| R4-08 | Resolved provenance uncertainty by Codex: SHA-256 recomputed before/after review, baseline identity and current GitHub main/PR query captured in `baseline-identity.txt`. History was inspected locally with `git blame` / `git show`. Opus's read-only tools did not independently recompute the hash or check GitHub; those facts remain attributed to the local commands. |
|
||||
|
||||
## Raw evidence
|
||||
|
||||
Directory: `/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/`.
|
||||
|
||||
- `review-prompt-v1.md`, `opus-review-v1.jsonl`, `opus-review-v1.stderr.log`, `opus-review-v1.exit`.
|
||||
- `baseline-identity.txt`, `r4_repro_test.go`, `overlay.json`, `baseline-repro.log`.
|
||||
- `options_repro_test.go`, `options-overlay.json`, `baseline-options.log`.
|
||||
|
||||
The stream includes an attempted Write to Claude's own plan file. Its tool was disabled; the reviewer returned the full result in text and did not edit production source. The successful result and actual assistant models are checked separately from rate-limit status and auxiliary-model usage.
|
||||
@@ -0,0 +1,39 @@
|
||||
已完成独立复核:读了三份改动文件在 HEAD 的实际内容、`putOptsFromHeaders` 全函数与两条返回路径、`CopyObjectHandler` 的默认加密与标签排序段、`reconcileStoredObjectTags` 的三处生产调用点,以及全部指定日志与证据文件。
|
||||
|
||||
# R4 实现复核结论
|
||||
|
||||
**Verdict: GO_WITH_NONBLOCKING_NOTES(0 阻断项)**
|
||||
|
||||
- 复核 HEAD:`dbcf8dec589deb5d91e17d295cb70997635f5b55`
|
||||
- 代码/测试 diff SHA-256(按提供值记录):`c8cd6648f8ecea835ec74a038cdeaa82acaa3f36250395f97ead3260dc2fc0a5`
|
||||
- 本会话无 shell,未重算该哈希;改为逐行比对 diff 与工作树三份文件,内容完全一致(`cmd/object-api-options.go`、`cmd/object-api-options-replication_test.go`、`cmd/object-copy-replication-tagging_test.go`)。
|
||||
|
||||
## 核验到的事实
|
||||
|
||||
- 生产改动确为一个字段 + 相邻注释:`cmd/object-api-options.go:459` 的 `ReplicationSourceTaggingTimestamp: taggingtimestmp`,变量来自 `:419-425` 已解析值,与非 KMS 路径 `:473` 对齐。未动解析、信任判定、KMS key/context、返回结构。
|
||||
- 影响面封闭:全仓该字段唯一消费点是 `cmd/object-handlers.go:1820`(COPY 标签排序)。PUT/POST/multipart 虽同经 `putOptsFromReq`,但无消费者,故不可能回归——与 R4/R5 切分一致。
|
||||
- 三条 KMS 触发路径真实可达:`object-handlers.go:1428-1433` 在 `copyDstOpts`(`:1454`)之前套用目的端默认;`bucket-sse-config.go:139-151` 在 `nil 配置 + AutoEncrypt` 与桶默认 KMS 两种情况下都写入 `aws:kms`,因此 explicit / auto / bucket 三种模式均进入 KMS 分支。
|
||||
- 回归证明成立:`baseline-final.log` 用 `-overlay` 换回未修复 constructor,失败面精确为「trusted × 有效标签时间戳 × SSE-KMS / SSE-KMS-context」和 6 个 KMS COPY 子测试(`tags="key=old"`、`kms=true`、HTTP 200),`none`/`SSE-S3`/`SSE-C`/非 trusted 全通过。修复后 `focused.log:194-204` 全 PASS。
|
||||
- 测试确实覆盖被要求的维度:信任边界(trusted=false 时 mtime/ETag/三时间戳全归零)、错误路径(trusted + 畸形值必须报错且错误串含头名)、SSE 序列化回环(KMS keyID/context 原样还原)、磁盘终态(每事件 `obj.GetObjectInfo` 读真实盘)、版本一致性、签名 GET 明文可读。全局 `GlobalKMS`/`globalAutoEncryption`/`set.getDisks` 均 defer 还原。
|
||||
- `race` exit 0、`vet` 空输出、`golangci-lint` 0 issues,均记录了与 HEAD 一致的三文件哈希。
|
||||
- 未发现 `verification.md` / `verification.json` / `consensus.md` 中与日志矛盾的陈述。(评审者版本/模型/effort 这类 provenance 声明不在我可验证范围,未作背书。)
|
||||
|
||||
## 发现清单
|
||||
|
||||
| ID | 内容 | 阻断 |
|
||||
|---|---|---|
|
||||
| IMPL-01 | 单字段修复正确且充分,位置、变量、注释与 `:473` 语义一致 | 否(确认项) |
|
||||
| IMPL-02 | 基线失败/修复通过的判别力成立,对照组不误报 | 否(确认项) |
|
||||
| IMPL-03 | KMS 字面量相对 `getDefaultOpts` 仍缺 `ProxyHeaderSet`/`ProxyRequest`/`Speedtest`(`object-api-options.go:40-44` vs `:449-460`)。R4 范围外,已登记为 R4-06 | 否,不设为新合并门槛 |
|
||||
| IMPL-04 | `metadata-directive: REPLACE` 下 `getCpObjMetadataFromHeader`(`:1143-1156`)返回全新 map,故 `:1818` 的 `lastTaggingTimestamp` 为空、`:1822` 解析失败使 handler 侧比较恒「incoming 胜」;真正的 stale 拒绝发生在写锁内的 `reconcileStoredObjectTags`(`erasure-object.go:1312-1315`)。测试终态断言仍正确,文档 R4-07 已明示此分工 | 否(R5 上下文) |
|
||||
| IMPL-05 | 测试卫生:`bucket-kms` 模式写入的 `bucketSSEConfig` 未还原,仅因它是最后一个 mode、且 `ExecObjectLayerAPITest` 每后端重建对象层并 `resetTestGlobals()` 才安全;后续若在其后追加 mode 会继承默认 KMS | 否 |
|
||||
| IMPL-06 | `object-api-options-replication_test.go:35` 局部变量名 `context` 遮蔽标准包名(本文件未导入该包),纯观感 | 否 |
|
||||
| IMPL-07 | `focused.log` exit 1 的唯一失败是既有 `TestAPICopyObjectReplicaRetentionRemovalUnderBucketKMS`(`replication-trust_test.go:1284`,"Storage reached its minimum free drive threshold"),属本机磁盘余量环境问题,非本次引入;容量 overlay 是测试专用、未提交。新增 COPY 测试自带 `tagTestCapacityDisk` 包装,不受该阈值影响 | 否 |
|
||||
|
||||
**没有发现阻断性正确性问题。** 生产语义、存储格式、API 与既有排序规则均未改变,无任何既有测试断言旧(缺陷)行为。
|
||||
|
||||
## 合并适配性
|
||||
|
||||
`dbcf8dec5` 直接位于实时 main `9ebe81c1b` 之上,可快进合并。按仓库 CI 通过为前提,本实现适合合入 main。
|
||||
|
||||
*(我未运行任何测试,也未查询 GitHub;以上仅基于源码阅读与所提供日志。)*
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"baseline": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a",
|
||||
"reviewed_head": "dbcf8dec589deb5d91e17d295cb70997635f5b55",
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"cli_version": "2.1.270",
|
||||
"diff_sha256": "c8cd6648f8ecea835ec74a038cdeaa82acaa3f36250395f97ead3260dc2fc0a5",
|
||||
"started_at": "2026-09-15T15:59:41.004963+00:00",
|
||||
"status": "completed",
|
||||
"command": "/opt/homebrew/bin/claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --add-dir /Users/vonng/tmp/silo-r4-evidence-20260915-a9cb --output-format stream-json --verbose",
|
||||
"completed_at": "2026-09-15T16:03:52.331640+00:00",
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"observed_model": "claude-opus-5",
|
||||
"verdict": "GO_WITH_NONBLOCKING_NOTES",
|
||||
"blocking_findings": 0,
|
||||
"session_id": "c09bc4fb-85f1-4af8-a1de-c453398e4a20",
|
||||
"duration_ms": 161469,
|
||||
"subtype": "success",
|
||||
"is_error": false,
|
||||
"used_tools": {
|
||||
"Read": 20,
|
||||
"Glob": 3,
|
||||
"Grep": 14,
|
||||
"ExitPlanMode": 1
|
||||
},
|
||||
"raw_stream": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/merge-review-1/opus.jsonl",
|
||||
"stream_sha256": "875f617454b27e15ab44d9b777de89643ee352e0ccb948faad570fc546261acc",
|
||||
"review_sha256": "962ff88d2ffcb75cd692ec17017411d624de80dc120f8dedc675e0fe25009335",
|
||||
"prompt_sha256": "e1341c721161229431b943ba18d89b740e94470803c099b9ae3d597fd50544a4",
|
||||
"review_extraction": "The substantive review is an earlier assistant text block; result.result only repeats CLI plan-mode merge limitations. Full raw stream and all assistant text are retained."
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"original_reviewed_head": "dbcf8dec589deb5d91e17d295cb70997635f5b55",
|
||||
"dco_signed_equivalent_head": "03027727d1d1b97d8beb83ac55569ea9a83dab23",
|
||||
"notice_equivalence": {
|
||||
"cmd/object-api-options-replication_test.go": {
|
||||
"before_sha256": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
|
||||
"after_sha256": "c21fc8889a079085d9a882499a1cbe868278a3517580651f3bed1102e2a6aef8",
|
||||
"package_body_sha256": "096f143c0b0a068581f9bb892f35ded0d65b6b60ab711f043236d27fbf51ca33",
|
||||
"body_unchanged": true
|
||||
},
|
||||
"cmd/object-copy-replication-tagging_test.go": {
|
||||
"before_sha256": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3",
|
||||
"after_sha256": "73f066ed7258d430f078ecc90e551ece878bd3d4672bc762094d434ff8fec23d",
|
||||
"package_body_sha256": "6b5173db2ded2d54055073c3259be208a4d7c8eac0367687082877f1fd3bef15",
|
||||
"body_unchanged": true
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"verifiers": {
|
||||
"command": [
|
||||
"make",
|
||||
"verifiers",
|
||||
"GOLANGCI=/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/merge-review-1/golangci-serial"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"started_at": "2026-09-15T16:04:55.536605+00:00",
|
||||
"finished_at": "2026-09-15T16:07:03.252820+00:00",
|
||||
"cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
|
||||
"env_override": {
|
||||
"GOMAXPROCS": "2",
|
||||
"GOFLAGS": "-p=2"
|
||||
},
|
||||
"source_sha256": {
|
||||
"cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"cmd/object-api-options-replication_test.go": "c21fc8889a079085d9a882499a1cbe868278a3517580651f3bed1102e2a6aef8",
|
||||
"cmd/object-copy-replication-tagging_test.go": "73f066ed7258d430f078ecc90e551ece878bd3d4672bc762094d434ff8fec23d"
|
||||
},
|
||||
"log_sha256": "e42a5bb55f5c1ebfcf02cebebf6d82cf1ec5a2d74590cdf838deba16dd80bfdf"
|
||||
},
|
||||
"build": {
|
||||
"command": [
|
||||
"make",
|
||||
"build"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"started_at": "2026-09-15T16:07:03.253715+00:00",
|
||||
"finished_at": "2026-09-15T16:07:35.594062+00:00",
|
||||
"cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
|
||||
"env_override": {
|
||||
"GOMAXPROCS": "2",
|
||||
"GOFLAGS": "-p=2"
|
||||
},
|
||||
"source_sha256": {
|
||||
"cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"cmd/object-api-options-replication_test.go": "c21fc8889a079085d9a882499a1cbe868278a3517580651f3bed1102e2a6aef8",
|
||||
"cmd/object-copy-replication-tagging_test.go": "73f066ed7258d430f078ecc90e551ece878bd3d4672bc762094d434ff8fec23d"
|
||||
},
|
||||
"log_sha256": "6ba9b545236be964861749c72e7609edf12b8f470df30d1ede8fd62f497e629b"
|
||||
},
|
||||
"binary-version": {
|
||||
"command": [
|
||||
"./silo",
|
||||
"--version"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"started_at": "2026-09-15T16:07:35.594918+00:00",
|
||||
"finished_at": "2026-09-15T16:07:37.616706+00:00",
|
||||
"cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
|
||||
"env_override": {
|
||||
"GOMAXPROCS": "2",
|
||||
"GOFLAGS": "-p=2"
|
||||
},
|
||||
"source_sha256": {
|
||||
"cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"cmd/object-api-options-replication_test.go": "c21fc8889a079085d9a882499a1cbe868278a3517580651f3bed1102e2a6aef8",
|
||||
"cmd/object-copy-replication-tagging_test.go": "73f066ed7258d430f078ecc90e551ece878bd3d4672bc762094d434ff8fec23d"
|
||||
},
|
||||
"log_sha256": "36317d06b691593fe0d74f88d053a24485500c15fc2001e857f2fc6fa5ba752a"
|
||||
}
|
||||
},
|
||||
"binary_version": "silo version DEVELOPMENT.2026-09-15T16-03-52Z (commit-id=03027727d1d1b97d8beb83ac55569ea9a83dab23)\nRuntime: go1.27.1 darwin/arm64\nLicense: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html\nCopyright: 2015-2025 MinIO, Inc.\nModifications: Copyright 2025-2026 PGSTY\nSource compatibility: based on MinIO technology\n",
|
||||
"raw_evidence_directory": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/merge-review-1",
|
||||
"all_function_and_test_bodies_identical_to_opus_reviewed_version": true
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# R4 合并前复核
|
||||
|
||||
用户已明确追加授权:使用 Opus 5 max 核实最终实现,确认无误后合并 main。本轮授权取代此前只交付本地补丁的范围限制。
|
||||
|
||||
## 真实实现评审
|
||||
|
||||
- 独立新调用:Claude Code 2.1.270,`--model claude-opus-5 --effort max`。
|
||||
- 复核代码提交:`dbcf8dec589deb5d91e17d295cb70997635f5b55`;当时实时 main 与 fetch 结果均为 `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`。
|
||||
- 实际 assistant 模型只有 `claude-opus-5`。结论 **GO_WITH_NONBLOCKING_NOTES,0 阻断项**,明确表示仓库 CI 通过后适合合入 main。
|
||||
- [原始实现评审正文](implementation-review.md)、[实际模型与输出哈希](implementation-review.metadata.json) 已保存。
|
||||
- 原始流、全部 assistant 正文与最终 result 位于 `/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/merge-review-1/`。实质评审出现在较早的 assistant 消息;最终 result 只重复 Claude 只读会话不能自行合并的工具限制,不是对修复结论的撤回。本任务由 Codex 按用户明确授权完成合并。
|
||||
|
||||
## 意见处置
|
||||
|
||||
| 条目 | 处置 |
|
||||
|---|---|
|
||||
| IMPL-01 / IMPL-02 | 确认单字段修复和基线失败/修复通过的测试判别力,无需追加修改。 |
|
||||
| IMPL-03 | Proxy/Speedtest 选项遗漏已交父任务单独核验,维持范围外,不纳入 R4 合并。 |
|
||||
| IMPL-04 | REPLACE 请求的旧标签拒绝由写锁内对账完成,测试断言真实落盘状态,已有文档准确说明。 |
|
||||
| IMPL-05 | 当前测试固定以 bucket-kms 为最后一种模式,且每后端重新初始化;现有执行顺序安全。后续增添模式需同步隔离桶默认配置,本次保持已评审测试逻辑。 |
|
||||
| IMPL-06 | 局部变量 context 命名建议为可选观感项,不改动已评审逻辑。 |
|
||||
| IMPL-07 | 既有锁测试的磁盘余量限制及仅测试容量 overlay 已如实记录;新测试和 race 不使用生产代码 overlay。 |
|
||||
|
||||
## 提交规范调整
|
||||
|
||||
按 `CONTRIBUTING.md` 补齐提交作者对应的 DCO sign-off,并将两个新原创测试文件的文件头改为 `Copyright (c) 2026 Feng Ruohang`,保留 AGPL-3.0-or-later。原有生产文件的继承声明保持原样。
|
||||
|
||||
生产函数和测试的 `package cmd` 之后内容与 Opus 审查版本逐字节相同。`merge-review-1/notice-equivalence.json` 记录了旧/新文件哈希及不变的代码正文哈希。原 `verification.json` 保留当时原始验证记录,不覆盖历史哈希;本轮 PR 的 CI 对最终提交重新验证。
|
||||
|
||||
## 合并门槛
|
||||
|
||||
`make verifiers` 已通过:全仓 lint 为 0 issues,生成文件检查通过,rebrand 兼容性清单未变化,交付/运行时标识检查和 entrypoint 参数兼容性测试通过。首次执行曾遇到其他任务持有 golangci-lint 进程锁;使用工具自带 `--allow-serial-runners` 串行等待后完成全部检查。可选 typos 工具未安装,由仓库 Makefile 按既有规则跳过。
|
||||
|
||||
`make build` 通过,已生成本地 `silo` 并成功执行 `./silo --version`。最终三个源文件哈希与本轮校验记录一致,详情见 [本轮验证清单](merge-verification.json)。
|
||||
|
||||
接下来由 PR CI 验证最终候选,并在合并前再次核对 main 和精确 PR head。CI 与合并事实以 GitHub PR 状态和本机原始合并证据为准,评审意见不等同于合并或发布。
|
||||
@@ -0,0 +1,46 @@
|
||||
## R4 独立评审(Opus 5.0,只读复核)
|
||||
|
||||
**计划**:plan v1 — `docs/investigations/r4/plan-v1.md`
|
||||
**哈希(按任务给定)**:`ad539f2071155de6955b583991684ed33c4bfe2e29660005840cdc97d7e1a754`
|
||||
**Baseline**:`9ebe81c1b3611f9cc73e676b5b741c2be62c467a`
|
||||
|
||||
### 裁定:GO_WITH_NONBLOCKING_NOTES
|
||||
|
||||
无阻断项。**我同意这份 exact plan(上述哈希)可以进入本地实现。** 下列 R4-01…R4-08 均为非阻断;其中 R4-02/04/05 的测试建议是**可选**的,不自动并入必做范围。
|
||||
|
||||
(说明:本会话 Write 工具被禁用,评审仅以正文返回,未写入任何文件,也未改动任何源码。)
|
||||
|
||||
### 我实际核验到的关键事实(支撑"单字段补丁正确且充分")
|
||||
|
||||
1. **缺陷确认**:`cmd/object-api-options.go:449-460` 的 KMS 字面量带了 MTime/PreserveETag/ReplicationRequest + 两个 Object Lock 时间戳,独缺 tagging;默认路径 `:473` 有。补丁片段中的变量名 `taggingtimestmp` 与 `:419` 完全一致,可直接编译;gofmt 对齐由更长的两个 Lock 键决定,不会扰动他行。
|
||||
2. **影响面封闭**:全仓 `ReplicationSourceTaggingTimestamp` 只在 `cmd/object-handlers.go:1820` 被读取(定义于 `object-api-interface.go:99`)。因此该字段对 PUT/分段路径天然无效果——既印证 R4/R5 的切分合理,也说明补丁不可能回归其他路径。
|
||||
3. **充分性的关键点(我重点查证的风险)**:`encMetadata` 只有在 SSE-C 轮换分支 `object-handlers.go:1648-1659` 才批量快照全部保留键,而该分支与 KMS options 分支互斥(目的端是 SSE-C 时 `crypto.S3KMS.IsRequested` 为假)。故 `:1903` 的 `maps.Copy(srcInfo.UserDefined, encMetadata)` **不会**覆盖 KMS COPY 新写入的 tags/时间戳 —— 单字段补丁在 R4 边界内充分。
|
||||
4. **三个触发点准确**:`bucket-sse-config.go:135-153`(显式请求优先 → nil 配置 + AutoEncrypt → KMS → bucket 默认 KMS 写 header+keyID;默认 AES 走 AES 分支),配合 `object-handlers.go:1428-1433` 仅在非联邦时套用目的端默认。
|
||||
5. **REPLACE 副本路径准确**:`reconcileStoredObjectTags`(`erasure-server-pool-consistency.go:232-243`)语义即"存量有效时间戳胜过缺失/更旧/相等的 incoming,并连同 tag 值一起还原"。KMS 目的端因 `isTargetEncrypted` 使 `metadataOnly=false`,实际落到 `erasure-server-pool.go:1499-1513`(`ReplicaLockReconcile` 经 `:1509` 透传)→ `erasure-object.go:1276-1316`,在 `cloneMSS`(:1324) 之前于写锁内完成对账;纯元数据路径走 `erasure-object.go:136-139`。计划同时引用 `:136` 与 `:1509`,判断正确。
|
||||
6. **证据可信**:`baseline-repro.log` 中 options 用例非 KMS 保留 `...123456789Z`、KMS 返回零值;COPY 用例 6/6(ErasureSD + Erasure16 × explicit/auto/bucket KMS)失败,且均为 200、`kms=true`、明文 GET 通过、tags 停在 `key=old`。即"请求成功、加密正常,但复制标签被静默丢弃",与计划表述一致,未夸大。
|
||||
7. **修复后推演**:newer/stale/duplicate/newer-again 在 handler(:1817-1833) 与写锁对账的双重排序下分别得到 new/new/new/latest,与测试期望吻合;旧发送端不带 `X-Minio-Source-Tagging-Timestamp` 时仍为零值 → 行为不变,兼容性主张成立。
|
||||
|
||||
### 问题清单
|
||||
|
||||
| ID | 阻断 | 内容与建议 |
|
||||
|---|---|---|
|
||||
| **R4-01** | 否 | 行号漂移:计划写的 `1851/1910`,实际是 `object-handlers.go:1847`(`ReplicaLockReconcile`)与 `:1903`(encMetadata merge)。建议更正引用。 |
|
||||
| **R4-02** | 否(建议可选) | `ExecObjectLayerAPITest` 两种后端均为**单 pool**(`test-utils_test.go:216` `mustGetPoolEndpoints(0, ...)`),故 `erasure-server-pool.go:1443` 多池分支未被覆盖;且 KMS 目的端命中的是 PutObject 重写对账而非 `CopyObject:136`。建议在计划或测试注释中点明"单盘/16 盘均为单池";补多池覆盖**可选**,不必进必做范围。 |
|
||||
| **R4-03** | 否 | 措辞:`crypto.Requested`(`internal/crypto/sse.go:74`)只检查**目的端** SSE 头,因此仅带 SSE-C *copy-source* 头的请求在 KMS 默认桶/自动加密下仍会进入 KMS 分支(归入触发点 2/3,枚举仍完整)。建议澄清 "source encryption alone…" 一句。 |
|
||||
| **R4-04** | 否(**可选**) | 建议在 options 矩阵里加一条 KMS 分支 vs 默认分支的**逐字段等价断言**(MTime/PreserveETag/ReplicationRequest/三个复制时间戳)。这是阻止第三次复发最廉价的护栏(2021 漏、2026 补了两个 Lock 时间戳仍漏此项)。计划第 1 条已基本覆盖,此为结构化建议。 |
|
||||
| **R4-05** | 否(**可选**) | 建议加一例"KMS 目的端 + 有 tags 但无 tagging 时间戳头 → 存量不变",把兼容性主张钉在 handler 层而不仅在 options 层。 |
|
||||
| **R4-06** | 否(范围外,仅登记) | 同一 KMS 字面量相对 `getDefaultOpts`(`object-api-options.go:40-44`) 还遗漏 `ProxyHeaderSet/ProxyRequest/Speedtest`;`opts.Speedtest` 在 `erasure-object.go:1625` 被读取,全局自动加密下 speedtest PUT 会丢该标志。**不要在 R4 修**,且当前也不在 R5 声明范围内,建议单列条目登记。 |
|
||||
| **R4-07** | 否 | REPLACE 时 `getCpObjMetadataFromHeader:1143-1156` 会重建 map,`lastTaggingTimestamp` 为空 → handler 对 stale 事件**恒接受**,真正的拒绝来自写锁内对账。因此回归测试必须断言**最终落盘状态**(现有复现已如此),不要改为断言 handler 层行为。 |
|
||||
| **R4-08** | 否(不确定性) | 本会话无 shell,无法独立复算计划 SHA-256、验证 `c4373ef290 / b2dca43fda / cfefc049c` 历史归属与 PR #184/#187。可由 `shasum -a 256 docs/investigations/r4/plan-v1.md` 与 `git log -L` 输出消解;均不影响补丁正确性。`cfefc049c` 的 KMS context 编码修复实体(`:436-444` 的 `sdkContext`)仍在,补丁不触碰。 |
|
||||
|
||||
### 对计划各主张的逐项裁定
|
||||
|
||||
- 单字段补丁**正确且对本 bounded issue 充分**:同意(依据 2/3/7)。
|
||||
- 三个目的端 KMS 触发点**描述准确**:同意(R4-03 仅措辞澄清)。
|
||||
- 当前 REPLACE 副本路径**描述准确**:同意(R4-01/02 属引用精度)。
|
||||
- 回归矩阵与存量状态说明**充分**:同意;存量部分"不自动回填、丢失源时间不可重建、并列/更旧事件不保证修复"的表述与 `reconcileStoredObjectTags` 实际语义一致。
|
||||
- 信任边界、错误行为、加密 key/context、tie 语义、兼容性:补丁均未触碰,维持不变。
|
||||
|
||||
### 交付
|
||||
|
||||
本轮为**计划共识**,非实现验收。我未作任何源码或文件修改;R4 可按 plan v1 在本地实施,实施后的差异与测试证据需另行验收。
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"baseline": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a",
|
||||
"plan": "docs/investigations/r4/plan-v1.md",
|
||||
"plan_sha256": "ad539f2071155de6955b583991684ed33c4bfe2e29660005840cdc97d7e1a754",
|
||||
"requested_model": "claude-opus-5",
|
||||
"requested_effort": "max",
|
||||
"cli_version": "2.1.270",
|
||||
"started_at": "2026-09-15T15:45:46.438630+00:00",
|
||||
"status": "completed",
|
||||
"raw_output": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/opus-review-v1.jsonl",
|
||||
"raw_stderr": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/opus-review-v1.stderr.log",
|
||||
"command": "/opt/homebrew/bin/claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --add-dir /Users/vonng/tmp/silo-r4-evidence-20260915-a9cb --output-format stream-json --verbose",
|
||||
"completed_at": "2026-09-15T15:49:48.150062+00:00",
|
||||
"assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"observed_model": "claude-opus-5",
|
||||
"verdict": "GO_WITH_NONBLOCKING_NOTES",
|
||||
"blocking_findings": 0,
|
||||
"result_subtype": "success",
|
||||
"is_error": false,
|
||||
"session_id": "63e14a68-8565-41fa-9746-3e405fb63e9f",
|
||||
"duration_ms": 161784,
|
||||
"num_turns": 35,
|
||||
"used_tools": {
|
||||
"Read": 18,
|
||||
"Glob": 4,
|
||||
"Grep": 11,
|
||||
"Write": 1
|
||||
},
|
||||
"stream_sha256": "eb0918d8a6185b180dddcfc664a96682f05502ecf3b686b08a0547f09879d57d",
|
||||
"review_sha256": "e1dc12dd99326ae432623ff8de201813e6e84e7ed16a5556c21f9c514d663676",
|
||||
"prompt_sha256": "07c225beff1523e056c154b3a387cf1ae345def4b4d0173065b882140ac5abdf",
|
||||
"tool_scope_note": "Read/Grep/Glob allowed. Claude attempted Write to its own plan; the tool was disabled and no file was written. git diff before consensus showed no production source changes.",
|
||||
"auxiliary_model_note": "assistant_models records actual reviewing assistant messages. Auxiliary usage is distinct. --effort max is explicit in the command, not inferred from model usage."
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# R4 plan v1: preserve the replicated tag timestamp for SSE-KMS
|
||||
|
||||
## Baseline and ownership
|
||||
|
||||
- Baseline: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`, verified against GitHub main on 2026-09-15.
|
||||
- Branch: `codex/r4-kms-tag-timestamp`; worktree: `/Users/vonng/.codex/worktrees/a9cb/silo`.
|
||||
- Live open PRs at inspection: #184 and #187, neither owns this options change.
|
||||
- The worktree lacks the ignored `AGENTS.md`; the parent explicitly confirms `/Users/vonng/pgsty/silo/AGENTS.md` applies. Maintain the PGSTY product graph and inexpensive compatibility.
|
||||
- R4 owns only the missing field in `cmd/object-api-options.go` and its regression tests. R5 owns DELETE/empty tags, PUT/multipart receiving, sender propagation and full receiver ordering. R4 will supply a standalone source patch to R5; neither task edits the other's worktree.
|
||||
|
||||
## Proven defect and actual trigger
|
||||
|
||||
`putOptsFromHeaders` parses the trusted source tag timestamp before selecting encryption. The SSE-KMS branch constructs and returns another `ObjectOptions` carrying mtime, ETag, replication trust and both Object Lock timestamps, but omits `ReplicationSourceTaggingTimestamp`. The normal path retains it. The parser accepts and preserves RFC3339 fractional seconds even though its layout is `time.RFC3339`; the reproduction uses nanoseconds.
|
||||
|
||||
`CopyObjectHandler` applies local destination encryption configuration before `copyDstOpts` → `putOptsFromReq` → `putOpts` → `putOptsFromHeaders`. The omission is reached by:
|
||||
|
||||
1. Explicit destination SSE-KMS request headers (with or without a key ID/context).
|
||||
2. A destination bucket with default SSE-KMS, when the request has no explicit SSE choice.
|
||||
3. Global automatic encryption with no bucket SSE override and no explicit SSE choice.
|
||||
|
||||
Explicit AES256/SSE-C takes its existing branch; source encryption alone does not select the destination KMS branch. Remote federation skips local destination defaults. The relevant trigger is trusted metadata entering the destination KMS branch, not every SSE-KMS object or every tag operation.
|
||||
|
||||
At `CopyObjectHandler`'s tag decision, a zero source timestamp skips the tag update. Current under-lock reconciliation can preserve the stored tag/timestamp when metadata REPLACE reconstructs the map with no timestamp. In the observed same-version replica COPY, the request succeeds, destination encryption is valid, and the old tags/timestamp remain. A missing field in the options layer is not itself proof of a content-read failure.
|
||||
|
||||
PUT and multipart consumers' independent failure to persist a parsed tag timestamp remain R5's responsibility. R4 does not claim to fix all tag replication by correcting this constructor.
|
||||
|
||||
## Source and reproduction evidence
|
||||
|
||||
- `cmd/object-api-options.go`: trusted parsing at 383–426; KMS construction at 433–460; normal assignments at 469–475.
|
||||
- `cmd/object-handlers.go`: destination default encryption at 1425–1435; `copyDstOpts` at 1454; tag timestamp consumption at 1807–1834; replica reconciliation enabled at 1851; encryption metadata merge at 1910.
|
||||
- `internal/bucket/encryption/bucket-sse-config.go:135`: explicit request wins, absent config + auto encryption selects KMS, otherwise configured bucket algorithm/key ID applies.
|
||||
- `cmd/erasure-server-pool-consistency.go:232`: stored valid timestamp wins over absent, older or equal incoming timestamp; writes preserve the stored tag value alongside its timestamp.
|
||||
- `cmd/erasure-object.go:136` and `cmd/erasure-server-pool.go:1509`: same-version replica COPY reaches existing under-lock tag reconciliation, including object-data rewrites.
|
||||
- History: the omission exists in `c4373ef290` (2021-09-18); `b2dca43fda` (2026-09-05) added the two Object Lock timestamps but not the tag timestamp. `cfefc049c` fixed KMS context encoding independently and must remain intact.
|
||||
- Fresh temporary reproduction: `/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/r4_repro_test.go` and `baseline-repro.log` (overlay; no production edits).
|
||||
- Command: `GOMAXPROCS=2 go test -p 2 -overlay /Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/overlay.json ./cmd -run '^TestReviewR4' -count=1 -timeout 5m -v`.
|
||||
- Result: expected failure. Unencrypted and AES256 options preserve `2026-09-15T01:00:00.123456789Z`; KMS returns zero. Signed metadata REPLACE COPY on ErasureSD and Erasure (16 disks), across explicit/default/automatic KMS, returns 200 but retains `key=old` and the old timestamp for newer events. Actual encrypted metadata and plaintext GET roundtrips pass. Test deltas are 1–3 nanoseconds.
|
||||
- These are in-process signed HTTP router and real local disk tests. `kms.NewStub` replaces the remote key service; the normal server encryption/decryption code still runs. Existing `tagTestCapacityDisk` avoids the host's free-space percentage threshold; it delegates all object data/metadata I/O to real test disks.
|
||||
|
||||
## Proposed production change
|
||||
|
||||
Add exactly this field to the existing KMS `ObjectOptions` literal:
|
||||
|
||||
```go
|
||||
ReplicationSourceTaggingTimestamp: taggingtimestmp,
|
||||
```
|
||||
|
||||
Update the neighboring explanatory comment to include tagging alongside retention/legal hold. Do not refactor the common return paths, change parsing/fallback/equal-timestamp semantics, change encryption context encoding, modify trust decisions, add SDK dependencies, or change storage/wire format. Those changes are unnecessary to restore the missing existing contract.
|
||||
|
||||
## Required validation after consensus
|
||||
|
||||
1. Add an options regression matrix covering unencrypted, SSE-S3, SSE-KMS with no context, SSE-KMS with a context, and SSE-C. Validate trusted/untrusted requests, missing/valid/malformed tag timestamps, nanosecond and timezone/whitespace handling, all three replication timestamps, mtime/ETag/trust, nonnil metadata, and unchanged SSE header serialization (including KMS key/context).
|
||||
2. Promote the temporary COPY reproduction into a named, isolated regression test. Use actual signed same-version metadata COPY with REPLACE metadata and tagging directives, on single-disk and 16-disk backends. For explicit, bucket-default and automatic SSE-KMS, check newer update, older delivery, duplicate replay, and a second newer update. Verify stored tags, exact timestamp, version ID, encryption kind and plaintext GET after each operation. Include an unencrypted/SSE-S3 control if the fixture can do so without expanding implementation scope.
|
||||
3. Fail the final regression tests against unmodified baseline using an overlay. Then run them on the fixed source, alongside existing replication-trust/options and bucket-KMS Object Lock tests. Check `gofmt`, `git diff --check`, and `go vet ./cmd`.
|
||||
4. Run the new focused tests under `-race`. Use `GOMAXPROCS=2` and `-p 2` while sibling tasks share the host. A one-field pure option fix does not justify concurrent full-repository suites in all five tasks; full Linux CI and multi-site validation remain separate delivery gates.
|
||||
5. If a test exposes a separate handler/storage defect, report evidence and coordinate with R5. Do not broaden R4's production patch to make unrelated tests pass.
|
||||
|
||||
## Compatibility, existing state, effort and delivery
|
||||
|
||||
- Public API, header names, stored key names, KMS context/key handling and supported dependencies remain unchanged. Untrusted source headers stay ignored; malformed trusted timestamps continue to fail; absent timestamp remains zero. Existing non-KMS behavior remains unchanged.
|
||||
- No automatic rewrite/backfill. Lost source tag times cannot be reconstructed from the receiver alone. Upgrading permits subsequent properly timestamped events to be consumed. Review source-of-truth and target state before any targeted resync; full historical convergence also depends on R5. Repeated events subject to existing timestamp/tie semantics are not a universal repair guarantee.
|
||||
- The source fix can land independently; complete deletion/empty-tag and mixed-encryption convergence needs R5 plus its integration evidence.
|
||||
- Expected effort: approximately 0.5–1 engineer-day including reproduction, review and local validation; key-service deployment, multi-site failures and existing-state remediation are separate.
|
||||
- After actual Opus 5.0/max agreement on this exact plan hash, implement locally without another user permission prompt. Preserve raw review, assistant model identity, request effort, baseline and plan hash, issue-by-issue disposition and explicit consensus before source edits.
|
||||
- Deliver a reviewable local diff, tests and evidence. No main merge, remote publication/release, deployment or existing-state rewrite is authorized by this plan.
|
||||
@@ -0,0 +1,18 @@
|
||||
# R4 research log
|
||||
|
||||
## Verified baseline
|
||||
|
||||
2026-09-15: local clean HEAD and GitHub main both `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`. Branch created as `codex/r4-kms-tag-timestamp`. Live GitHub open PRs #184 (`6addf9eb916b5a4b837480cf534cd1efa5407d3c`) and #187 (`b8f2fdde41dff3dc3b8db669c1d42d30ca5c1d3d`) concern other tasks. Claude Code reports `2.1.270`; Go reports `go1.27.1 darwin/arm64`.
|
||||
|
||||
## Coordination
|
||||
|
||||
- Parent task: `01a0a5ab-ee43-7911-bddd-1aca6f8afcc8`.
|
||||
- R5: `01a0a5b9-602d-7470-9882-4817cf5fdcd1`, `/Users/vonng/.codex/worktrees/77ad/silo`.
|
||||
- Parent and R5 acknowledged the ownership boundary: R4 options constructor and nonempty KMS COPY tests; R5 producer/receiver ordering and empty values. R5 will consume R4's minimal patch for combined KMS acceptance.
|
||||
- Initial conservative expectation separated metadata COPY from REPLACE ordering. Inspection of current `ReplicaLockReconcile` and `reconcileStoredObjectTags` shows that stored timestamps are also reconciled under the write lock for REPLACE. The temporary reproduction therefore uses REPLACE directly; the fix must demonstrate the actual sender-shaped path without changing the handler.
|
||||
|
||||
## Baseline reproduction
|
||||
|
||||
Temporary overlay test source and output are in `/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/`. The options and HTTP/disk reproductions fail for the expected missing timestamp. Every KMS HTTP request completed with 200; newer tags remained old; encrypted object metadata and subsequent ordinary plaintext GET succeeded. The result is narrower than claiming all KMS replication fails, and stronger than merely comparing options.
|
||||
|
||||
The temporary source is not a production implementation. See [plan v1](plan-v1.md) for exact scope and required acceptance. The plan is frozen by SHA-256 before invoking real Opus.
|
||||
@@ -0,0 +1,358 @@
|
||||
{
|
||||
"baseline": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a",
|
||||
"branch": "codex/r4-kms-tag-timestamp",
|
||||
"source_sha256": {
|
||||
"cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
|
||||
"cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
|
||||
},
|
||||
"source_files_match_all_test_runs": true,
|
||||
"checks": [
|
||||
{
|
||||
"name": "baseline-final",
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"-p",
|
||||
"2",
|
||||
"-overlay",
|
||||
"/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/baseline-final-overlay.json",
|
||||
"./cmd",
|
||||
"-run",
|
||||
"^Test(PutOptsFromHeadersReplicationTimestamps|APICopyObjectReplicaTaggingTimestampUnderKMS)$",
|
||||
"-count=1",
|
||||
"-timeout=5m",
|
||||
"-v"
|
||||
],
|
||||
"cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
|
||||
"env_override": {
|
||||
"GOMAXPROCS": "2"
|
||||
},
|
||||
"started_at": "2026-09-15T15:50:49.430860+00:00",
|
||||
"finished_at": "2026-09-15T15:51:20.637630+00:00",
|
||||
"exit_code": 1,
|
||||
"log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/baseline-final.log",
|
||||
"source_sha256": {
|
||||
"cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
|
||||
"cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
|
||||
},
|
||||
"log_sha256": "f849c2082235213764e3db7a314d52af75c4859102478a1e8f837afcaa8f1ea8",
|
||||
"overlay_sha256": "f4cbc16e4ffccaf63191de2e8476162876055796adb4c38cda2d8c569a3bbabc",
|
||||
"overlay_sources": {
|
||||
"/Users/vonng/.codex/worktrees/a9cb/silo/cmd/object-api-options.go": {
|
||||
"path": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/object-api-options.baseline.go",
|
||||
"sha256": "16a560d0990ae929393f682f22b32ecd2e7f4d9390484b03b54e176fcd00cff5"
|
||||
}
|
||||
},
|
||||
"assessment": "Expected baseline regression failure; KMS timestamp loss. Non-KMS controls pass."
|
||||
},
|
||||
{
|
||||
"name": "focused",
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"-p",
|
||||
"2",
|
||||
"./cmd",
|
||||
"-run",
|
||||
"^Test(PutOptsFromHeadersReplicationTimestamps|APICopyObjectReplicaTaggingTimestampUnderKMS|ReplicationTrustControlsInternalOptionsAndEvents|GetAndValidateAttributesOpts.*|APICopyObjectReplicaRetentionRemovalUnderBucketKMS)$",
|
||||
"-count=1",
|
||||
"-timeout=5m",
|
||||
"-v"
|
||||
],
|
||||
"cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
|
||||
"env_override": {
|
||||
"GOMAXPROCS": "2"
|
||||
},
|
||||
"started_at": "2026-09-15T15:51:20.638633+00:00",
|
||||
"finished_at": "2026-09-15T15:51:48.382212+00:00",
|
||||
"exit_code": 1,
|
||||
"log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/focused.log",
|
||||
"source_sha256": {
|
||||
"cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
|
||||
"cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
|
||||
},
|
||||
"log_sha256": "9a64cbc46e9fd6186b1d3031034b720857851ce70b1466b1da5da6d1a52b76ba",
|
||||
"assessment": "New tests and options/trust pass; pre-existing KMS lock fixture blocked by host disk free-space percentage."
|
||||
},
|
||||
{
|
||||
"name": "focused-capacity-adapted",
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"-p",
|
||||
"2",
|
||||
"-overlay",
|
||||
"/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/capacity-overlay.json",
|
||||
"./cmd",
|
||||
"-run",
|
||||
"^Test(PutOptsFromHeadersReplicationTimestamps|APICopyObjectReplicaTaggingTimestampUnderKMS|ReplicationTrustControlsInternalOptionsAndEvents|GetAndValidateAttributesOpts.*|APICopyObjectReplicaRetentionRemovalUnderBucketKMS)$",
|
||||
"-count=1",
|
||||
"-timeout=5m",
|
||||
"-v"
|
||||
],
|
||||
"cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
|
||||
"env_override": {
|
||||
"GOMAXPROCS": "2"
|
||||
},
|
||||
"started_at": "2026-09-15T15:52:20.113475+00:00",
|
||||
"finished_at": "2026-09-15T15:52:50.842773+00:00",
|
||||
"exit_code": 0,
|
||||
"log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/focused-capacity-adapted.log",
|
||||
"source_sha256": {
|
||||
"cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
|
||||
"cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
|
||||
},
|
||||
"log_sha256": "13f7aa54564a433bef4dddcf2c5ad1fe46fa03869527fb902a255ce4c3263bc9",
|
||||
"overlay_sha256": "76a7e6fb364bdaaa3469b1dc79f9ea318059f287a4888f322639920c76dfe53a",
|
||||
"overlay_sources": {
|
||||
"/Users/vonng/.codex/worktrees/a9cb/silo/cmd/replication-trust_test.go": {
|
||||
"path": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/replication-trust-capacity_test.go",
|
||||
"sha256": "c81b526ae51983881bbf464199e6b90f074fa695300fa8ff005e427e4d3c8208"
|
||||
}
|
||||
},
|
||||
"assessment": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "race",
|
||||
"command": [
|
||||
"go",
|
||||
"test",
|
||||
"-p",
|
||||
"2",
|
||||
"-race",
|
||||
"./cmd",
|
||||
"-run",
|
||||
"^Test(PutOptsFromHeadersReplicationTimestamps|APICopyObjectReplicaTaggingTimestampUnderKMS)$",
|
||||
"-count=1",
|
||||
"-timeout=5m",
|
||||
"-v"
|
||||
],
|
||||
"cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
|
||||
"env_override": {
|
||||
"GOMAXPROCS": "2"
|
||||
},
|
||||
"started_at": "2026-09-15T15:52:50.843898+00:00",
|
||||
"finished_at": "2026-09-15T15:53:43.789325+00:00",
|
||||
"exit_code": 0,
|
||||
"log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/race.log",
|
||||
"source_sha256": {
|
||||
"cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
|
||||
"cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
|
||||
},
|
||||
"log_sha256": "ae66a8c9e569a5c4b57ae56e75afc85c06a8b76f1567187519da0726605a4de4",
|
||||
"assessment": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "vet",
|
||||
"command": [
|
||||
"go",
|
||||
"vet",
|
||||
"-p",
|
||||
"2",
|
||||
"./cmd"
|
||||
],
|
||||
"cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
|
||||
"env_override": {
|
||||
"GOMAXPROCS": "2"
|
||||
},
|
||||
"started_at": "2026-09-15T15:53:43.790197+00:00",
|
||||
"finished_at": "2026-09-15T15:53:51.129773+00:00",
|
||||
"exit_code": 0,
|
||||
"log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/vet.log",
|
||||
"source_sha256": {
|
||||
"cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
|
||||
"cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
|
||||
},
|
||||
"log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"assessment": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "lint",
|
||||
"command": [
|
||||
"/Users/vonng/pgsty/silo/.bin/golangci/v2.13.1/golangci-lint",
|
||||
"run",
|
||||
"--build-tags",
|
||||
"kqueue",
|
||||
"--timeout=10m",
|
||||
"--config",
|
||||
"./.golangci.yml",
|
||||
"./cmd/..."
|
||||
],
|
||||
"cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
|
||||
"env_override": {
|
||||
"GOMAXPROCS": "2"
|
||||
},
|
||||
"started_at": "2026-09-15T15:53:51.130456+00:00",
|
||||
"finished_at": "2026-09-15T15:55:39.114303+00:00",
|
||||
"exit_code": 0,
|
||||
"log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/lint.log",
|
||||
"source_sha256": {
|
||||
"cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
|
||||
"cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
|
||||
},
|
||||
"log_sha256": "e92606b0bf483111dff0a120c315ea165821348f31365020e2468a0059095c47",
|
||||
"assessment": "PASS"
|
||||
}
|
||||
],
|
||||
"format_checks": [
|
||||
{
|
||||
"command": [
|
||||
"gofmt",
|
||||
"-l",
|
||||
"cmd/object-api-options.go",
|
||||
"cmd/object-api-options-replication_test.go",
|
||||
"cmd/object-copy-replication-tagging_test.go"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"output": ""
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"output": ""
|
||||
}
|
||||
],
|
||||
"evidence_directory": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb",
|
||||
"evidence_files": {
|
||||
"baseline-final-overlay.json": {
|
||||
"size": 177,
|
||||
"sha256": "f4cbc16e4ffccaf63191de2e8476162876055796adb4c38cda2d8c569a3bbabc"
|
||||
},
|
||||
"baseline-final.json": {
|
||||
"size": 1584,
|
||||
"sha256": "5bf7be51e5a0eb41a40dc5fc5d3a1aba5df7733ad5dcb001f8d870a01c4233ba"
|
||||
},
|
||||
"baseline-final.log": {
|
||||
"size": 22493,
|
||||
"sha256": "f849c2082235213764e3db7a314d52af75c4859102478a1e8f837afcaa8f1ea8"
|
||||
},
|
||||
"baseline-identity.txt": {
|
||||
"size": 1676,
|
||||
"sha256": "9b21841e19a0cbb8ded18c2597488a527a27bedc65109d05d4ff598103073b68"
|
||||
},
|
||||
"baseline-options.log": {
|
||||
"size": 11395,
|
||||
"sha256": "d692f0a4bc9c58e2ac0087afa356ddf48f86e1040ea8138d68ffc4d0992bf3cb"
|
||||
},
|
||||
"baseline-repro.log": {
|
||||
"size": 5693,
|
||||
"sha256": "aa89b76f4723c6a3ce224faa7796403628d978a8707544bd97848b8887de2113"
|
||||
},
|
||||
"capacity-fixture.diff": {
|
||||
"size": 870,
|
||||
"sha256": "8d01e0b0068441f37ecee37125b81424d1f30d7c4fb37d435ea0cfe2e4617e5e"
|
||||
},
|
||||
"capacity-overlay.json": {
|
||||
"size": 185,
|
||||
"sha256": "76a7e6fb364bdaaa3469b1dc79f9ea318059f287a4888f322639920c76dfe53a"
|
||||
},
|
||||
"final_copy_repro_test.go": {
|
||||
"size": 5942,
|
||||
"sha256": "8949e07d96d2949a79f5a9e83c7a7c0473733d77b407e9c51da477d4ab74f1a8"
|
||||
},
|
||||
"focused-capacity-adapted.json": {
|
||||
"size": 1661,
|
||||
"sha256": "1a599b41caabfc5eb44db8d89c8b7e4f4f84f5f036d008369155fd337f65bdf9"
|
||||
},
|
||||
"focused-capacity-adapted.log": {
|
||||
"size": 20384,
|
||||
"sha256": "13f7aa54564a433bef4dddcf2c5ad1fe46fa03869527fb902a255ce4c3263bc9"
|
||||
},
|
||||
"focused.json": {
|
||||
"size": 1253,
|
||||
"sha256": "d9bb4979ea8aeaabb809cdc6e400a8673530bc83abf3dc2b2a06853a8523d0d9"
|
||||
},
|
||||
"focused.log": {
|
||||
"size": 20475,
|
||||
"sha256": "9a64cbc46e9fd6186b1d3031034b720857851ce70b1466b1da5da6d1a52b76ba"
|
||||
},
|
||||
"format-checks.json": {
|
||||
"size": 352,
|
||||
"sha256": "7569260900a799d5efdfb39db1f575ab1dadbbb04ace222e036968e66b6b59e7"
|
||||
},
|
||||
"lint.json": {
|
||||
"size": 991,
|
||||
"sha256": "a7144713b069f470a94b1ebe6fca6683a4b866a891a2756e15f28c280666ca14"
|
||||
},
|
||||
"lint.log": {
|
||||
"size": 10,
|
||||
"sha256": "e92606b0bf483111dff0a120c315ea165821348f31365020e2468a0059095c47"
|
||||
},
|
||||
"object-api-options.baseline.go": {
|
||||
"size": 16653,
|
||||
"sha256": "16a560d0990ae929393f682f22b32ecd2e7f4d9390484b03b54e176fcd00cff5"
|
||||
},
|
||||
"options-overlay.json": {
|
||||
"size": 185,
|
||||
"sha256": "5506b9c3b998b32f01c45af3cf01605eae9e4fb262c9ff3a6b0040abe719d4d9"
|
||||
},
|
||||
"options_repro_test.go": {
|
||||
"size": 4192,
|
||||
"sha256": "1a57a47bdd370042fa0f0d2d90efe447abedee9b9ef48a938d4bed631d83ec0b"
|
||||
},
|
||||
"opus-review-v1.exit": {
|
||||
"size": 2,
|
||||
"sha256": "9a271f2a916b0b6ee6cecb2426f0b3206ef074578be55d9bc94f6f3fe3ab86aa"
|
||||
},
|
||||
"opus-review-v1.jsonl": {
|
||||
"size": 410466,
|
||||
"sha256": "eb0918d8a6185b180dddcfc664a96682f05502ecf3b686b08a0547f09879d57d"
|
||||
},
|
||||
"opus-review-v1.stderr.log": {
|
||||
"size": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"overlay.json": {
|
||||
"size": 158,
|
||||
"sha256": "95f7c3f7e206fe36731e6d7e4a48f90c7403f07ae8155c125c6b86d2f1c2d487"
|
||||
},
|
||||
"r4-kms-tag-timestamp.patch": {
|
||||
"size": 874,
|
||||
"sha256": "2d4806d986bbd94ba4bc3951f3aeee48401ee1921c28ded0988fa09ca76ca26f"
|
||||
},
|
||||
"r4_repro_test.go": {
|
||||
"size": 5508,
|
||||
"sha256": "9bcefb6da2416b577b58085485cad60f677c2265e9dfa84d02e465e1b203766b"
|
||||
},
|
||||
"race.json": {
|
||||
"size": 1026,
|
||||
"sha256": "50b73e4acbc2426f3dcfadde78d0f0a86f10702345d2939a30204600bc750a13"
|
||||
},
|
||||
"race.log": {
|
||||
"size": 18566,
|
||||
"sha256": "ae66a8c9e569a5c4b57ae56e75afc85c06a8b76f1567187519da0726605a4de4"
|
||||
},
|
||||
"replication-trust-capacity_test.go": {
|
||||
"size": 61271,
|
||||
"sha256": "c81b526ae51983881bbf464199e6b90f074fa695300fa8ff005e427e4d3c8208"
|
||||
},
|
||||
"review-prompt-v1.md": {
|
||||
"size": 2770,
|
||||
"sha256": "07c225beff1523e056c154b3a387cf1ae345def4b4d0173065b882140ac5abdf"
|
||||
},
|
||||
"run-checks.py": {
|
||||
"size": 2266,
|
||||
"sha256": "ddecea5220bc9c286df18c0e9eca101f3d8ab731c307cd4acef937f3ecd11e65"
|
||||
},
|
||||
"vet.json": {
|
||||
"size": 853,
|
||||
"sha256": "e0964444bc91640ed6cf78229050bad5b94af4210bdf44b11d1a848f1ee930a6"
|
||||
},
|
||||
"vet.log": {
|
||||
"size": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
}
|
||||
},
|
||||
"scope": "Darwin arm64; real signed HTTP + disk I/O; KMS service stub; single-pool single/16-disk fixtures; no remote CI, multi-site, release or deployment."
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# R4 修复与本地验收
|
||||
|
||||
这是 2026-09-15 的本地验收快照。用户后续授权的实现级复核、提交规范调整与合并流程见 [合并前复核](merge-verification.md);以下原始测试记录及哈希保留当时状态。
|
||||
|
||||
## 结果
|
||||
|
||||
在 `putOptsFromHeaders` 的 SSE-KMS 选项构造中补齐 `ReplicationSourceTaggingTimestamp`。目的端使用显式 SSE-KMS、桶默认 KMS 或自动加密时,可信复制 COPY 现在能消费来源标签时间戳,并在现有存储锁内完成排序。
|
||||
|
||||
生产修改只有一个字段和相邻注释。API、存储格式、KMS key/context、信任判断和既有排序规则保持兼容。R5 的删除/空标签及 PUT/multipart 时间戳传播独立交付。
|
||||
|
||||
## 方案与 Opus 共识
|
||||
|
||||
- 基线:`9ebe81c1b3611f9cc73e676b5b741c2be62c467a`,已重新查询 GitHub main。
|
||||
- 分支:`codex/r4-kms-tag-timestamp`。
|
||||
- [冻结方案 v1](plan-v1.md):SHA-256 `ad539f2071155de6955b583991684ed33c4bfe2e29660005840cdc97d7e1a754`。
|
||||
- 真实评审为本机 Claude Code 2.1.270,实际 assistant 模型 `claude-opus-5`,显式 `--effort max`。结论 **GO_WITH_NONBLOCKING_NOTES,0 个阻断项**。
|
||||
- [逐条意见处置与双方共识](consensus.md)、[原始返回评审正文](opus-v1-review.md)、[模型与哈希记录](opus-v1.metadata.json) 已保存。先保存共识,再修改生产源码。
|
||||
|
||||
## 变更与测试
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `cmd/object-api-options.go` | 在 KMS 字面量中保留已解析的来源标签时间戳。 |
|
||||
| `cmd/object-api-options-replication_test.go` | 无加密、SSE-S3、SSE-KMS、带 key/context 的 KMS、SSE-C;可信/非可信;缺失、有效、无效标签时间;纳秒、时区与空格;mtime/ETag/三个时间戳、metadata 与 SSE 序列化。 |
|
||||
| `cmd/object-copy-replication-tagging_test.go` | 两种单池后端 × 五种目的端加密模式 × 五个有序事件,共 50 次签名 COPY 和 50 次普通 GET。每步检查最终标签、精确时间戳、对象版本、加密类型及明文内容。 |
|
||||
|
||||
COPY 使用 `metadata=REPLACE`、`tagging=REPLACE` 和可信复制身份。事件为较新更新、乱序旧更新、重复事件、再次更新,以及不带来源标签时间戳的请求。更新间隔仅 1–3 纳秒,防止时间精度退化被秒级测试掩盖。无加密与 AES256 是对照;KMS 覆盖显式、桶默认和自动加密入口。
|
||||
|
||||
## 验证状态
|
||||
|
||||
| 检查 | 结果 | 证据文件 |
|
||||
|---|---|---|
|
||||
| 最终测试 + 未修复基线 constructor overlay | 预期失败;只有可信 KMS 有效标签时间戳及 KMS COPY 更新失败,对照通过 | `baseline-final.log/json` |
|
||||
| 修复后最终新增测试与既有 trust/options 测试 | 通过;未使用生产源码 overlay | `focused.log` |
|
||||
| 既有 KMS Object Lock 回归 | 首次受宿主机磁盘余量阈值阻挡;仅适配测试容量报告后,与上述定向测试一起通过 | `focused-capacity-adapted.log/json`、`capacity-fixture.diff` |
|
||||
| 新增测试 `-race` | 通过 | `race.log/json` |
|
||||
| `go vet -p 2 ./cmd` | 通过 | `vet.log/json` |
|
||||
| 仓库配置的 golangci-lint,范围 `./cmd/...`、`kqueue` build tag | 通过,0 issues | `lint.log/json` |
|
||||
| gofmt、git diff --check | 通过 | `format-checks.json` |
|
||||
|
||||
原始日志和每条命令的运行记录位于 `/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/`。每份检查 JSON 都记录命令、退出码、时间和三个源码/测试文件的 SHA-256;最终交付已逐一确认文件哈希一致。[验证清单](verification.json) 另记录 overlay 的实际替换文件哈希,避免混淆基线与修复版执行代码。
|
||||
|
||||
测试使用真实签名 HTTP 路由、实际本地对象数据/元数据读写、服务器加解密代码;远程密钥服务由 `kms.NewStub` 代替。ErasureSD 与 16 盘 Erasure 均为单池。容量适配只使用已有 `tagTestCapacityDisk`,避免本机磁盘使用比例触发防写阈值,所有对象 I/O 仍由真实测试磁盘承担;未调整生产容量保护。
|
||||
|
||||
## 交付与剩余边界
|
||||
|
||||
- 本地实现和要求的定向验证均已完成,将源码、回归、研究、共识和验收记录作为一个本地提交交付。
|
||||
- R4 的独立生产补丁已提供给 R5:`/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/r4-kms-tag-timestamp.patch`,SHA-256 `2d4806d986bbd94ba4bc3951f3aeee48401ee1921c28ded0988fa09ca76ca26f`。
|
||||
- Opus 共识为方案级共识;本地测试结论来自实际运行,不把它记作 Opus 执行了测试。
|
||||
- 多池/多站点故障恢复、外部 KMS 服务、完整 Linux CI、主干合并、远端发布和部署尚未执行。
|
||||
- 没有改写存量。丢失的来源时间戳不能仅从接收端推导;后续重放/重同步须核对来源权威性及 R5 的全链路处理,不保证旧事件重放可以修复全部历史状态。
|
||||
- 另登记 KMS 字面量缺少 Proxy/Speedtest 标志的范围外观察,已交父任务单独核验,本次未扩大修复。
|
||||
@@ -0,0 +1,17 @@
|
||||
# R5 investigation baseline
|
||||
|
||||
- Worktree: `/Users/vonng/.codex/worktrees/77ad/silo`
|
||||
- HEAD: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`; GitHub main checked live on 2026-09-15.
|
||||
- Branch: `codex/r5-tag-deletion-ordering`.
|
||||
- WORKFLOW: `/Users/vonng/tmp/silo-r4-r8-20260915-01a0a5ab/WORKFLOW.md` read completely.
|
||||
- This isolated worktree has no AGENTS.md. Read `/Users/vonng/pgsty/silo/AGENTS.md`: PGSTY supported stack, minimal compatible changes, separate local/merge/release gates.
|
||||
- Existing tag storage reconciliation is already in HEAD; inspect and reuse it.
|
||||
- No open R5 PR in live `gh pr list`; unrelated open PRs #184 and #187 belong to R6/R7.
|
||||
- Parent reproduction: `/Users/vonng/tmp/silo-r4-r8-20260915-01a0a5ab/baseline-evidence/r5-handler.log`.
|
||||
- Current reproduction overlay and raw output: `/Users/vonng/tmp/silo-r5-20260915-77ad/`.
|
||||
- Claude Code actual version: 2.1.270 at `/opt/homebrew/bin/claude`. Required model `claude-opus-5`, effort `max`; model identity must be checked in assistant messages.
|
||||
- Toolchain: go1.27.1 darwin/arm64. Targeted tests use GOMAXPROCS=2 and -p 1 to share the host.
|
||||
|
||||
## Ownership
|
||||
|
||||
R4 owns `cmd/object-api-options.go` KMS common-field preservation and option tests. R5 does not edit that file. R5 owns tag state generation, wire propagation, COPY/PUT/multipart persistence and ordered replay tests. Coordination requested through parent while R4 actual task ID is pending.
|
||||
@@ -0,0 +1,32 @@
|
||||
# R5 plan consensus
|
||||
|
||||
Date: 2026-09-15. Research base: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`.
|
||||
|
||||
## Accepted plan
|
||||
|
||||
- Version: **v2**, `plan-v2.md`.
|
||||
- SHA256: `5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca`.
|
||||
- Actual reviewer: **claude-opus-5**, explicitly invoked **--effort max** through `/opt/homebrew/bin/claude` 2.1.270. All assistant messages in both reviews identify this model. The auxiliary Haiku usage in CLI bookkeeping is separately retained in modelUsage and is not the reviewer.
|
||||
- Actual result: **APPROVE_WITH_NONBLOCKING_NOTES; 0 blocking items** in opus-v2-review.md.
|
||||
- Codex accepts this exact v2 and its bounded per-hop scope. Plan hash was checked locally immediately before implementation. Opus's read-only tools did not run hashing; the original caveat is retained in raw review.
|
||||
- Workflow permission: after this written consensus, local implementation and verification proceed without another user approval. No main merge, remote push, release, deployment or production state rewrite.
|
||||
|
||||
## Discussion and resolved differences
|
||||
|
||||
V1 was REQUEST_CHANGES with five blockers. See opus-v1-response.md for individual treatment and source evidence. V2 resolves all five. Opus explicitly withdrew its empty-only transfer proposal after the same-value re-addition counterexample, corrected its KMS COPY statement after inspecting bucket-default/auto encryption, and accepted that per-pool-only local clock guards are insufficient for ordinary source reads.
|
||||
|
||||
## Nonblocking notes accepted during implementation
|
||||
|
||||
- Extra metadata I/O occurs on scheduled metadata/heal/existing-object work with a recorded revision; ordinary object replication dispatches straight to full transfer. Completed scanner gates and incoming replication suppression avoid a feedback loop. Test the incoming no-reschedule decision.
|
||||
- Pin unchanged object ModTime for local tagging changes.
|
||||
- Keep a single-set monotonic guard and one uniform multi-pool candidate; direct-to-set writes outside the pool lock can transiently differ and re-converge on the next pooled mutation.
|
||||
- Check actual failed COPY status/action and subsequent retry. Malformed timestamps fail both PUT and metadata COPY construction.
|
||||
- Preserve scope limitations: tag-filter target selection, historical missing revisions, arbitrary unversioned content overwrites, and real multi-site/host-clock skew are not solved or production-accepted here.
|
||||
|
||||
## R4 dependency
|
||||
|
||||
Reuse reviewed local R4 commit `dbcf8dec589deb5d91e17d295cb70997635f5b55` on this isolated branch before implementation. Its only production change is the KMS options field, already examined against the provided patch SHA256 `2d4806d986bbd94ba4bc3951f3aeee48401ee1921c28ded0988fa09ca76ca26f`. R5 does not reimplement or modify that field. This makes R4+R5 tests run on actual combined source, with R5's eventual commit measured against the R4 dependency.
|
||||
|
||||
## Raw records
|
||||
|
||||
`/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v1.jsonl`, `opus-v1.stderr.log`, `opus-v1-request.json`, and matching `opus-v2.*`. In-repository review texts, prompts and metadata preserve plan hashes, model identity, usage and verdicts. V1 failure is not treated as approval.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"tested_dependency": "dbcf8dec589deb5d91e17d295cb70997635f5b55",
|
||||
"merged_dependency": "af2b1794d38d9e70e1d2c3ee692426e4b6cab4bd",
|
||||
"pr": "https://github.com/pgsty/silo/pull/193",
|
||||
"files": {
|
||||
"cmd/object-api-options.go": {
|
||||
"tested_sha256": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"merged_sha256": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
|
||||
"package_body_sha256": "23fa2a25307bf1e41b665217a3f39aa7a8b860686a54209092fa9c0f1f13243a",
|
||||
"package_body_identical": true
|
||||
},
|
||||
"cmd/object-api-options-replication_test.go": {
|
||||
"tested_sha256": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
|
||||
"merged_sha256": "c21fc8889a079085d9a882499a1cbe868278a3517580651f3bed1102e2a6aef8",
|
||||
"package_body_sha256": "1a57a47bdd370042fa0f0d2d90efe447abedee9b9ef48a938d4bed631d83ec0b",
|
||||
"package_body_identical": true
|
||||
},
|
||||
"cmd/object-copy-replication-tagging_test.go": {
|
||||
"tested_sha256": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3",
|
||||
"merged_sha256": "73f066ed7258d430f078ecc90e551ece878bd3d4672bc762094d434ff8fec23d",
|
||||
"package_body_sha256": "ef77de91cd91d1bd1c3cb10e4fee171c724c4f548749a23c7e48dcfcfb6a1585",
|
||||
"package_body_identical": true
|
||||
}
|
||||
},
|
||||
"other_changes": [
|
||||
"cmd/object-api-options-replication_test.go",
|
||||
"cmd/object-copy-replication-tagging_test.go",
|
||||
"docs/investigations/r4/implementation-review.md",
|
||||
"docs/investigations/r4/implementation-review.metadata.json",
|
||||
"docs/investigations/r4/merge-verification.json",
|
||||
"docs/investigations/r4/merge-verification.md",
|
||||
"docs/investigations/r4/verification.md"
|
||||
],
|
||||
"scope": "R5 local branch will rebase onto this exact R4 merge; no R5 push or merge."
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
{
|
||||
"raw_files": [
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-ack.log",
|
||||
"bytes": 607,
|
||||
"sha256": "ef698b8f46c850928057a31bf4e92f64f9a0dcebb8753bab00e9dcef2b44ffb6"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-extended.log",
|
||||
"bytes": 2764,
|
||||
"sha256": "a52bb6644b82a1986c233deeb9fb7b6cd3f4975337aa11446a1b27c459355db9"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-matrix.log",
|
||||
"bytes": 20974,
|
||||
"sha256": "5348c2ae4a20238ae50f70bcaea3aa55169b3479f60eab522692bdabe3420ab0"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-pools-rotation.log",
|
||||
"bytes": 2249,
|
||||
"sha256": "1d47acbe4d759b0f413f90589ff51b1f844f1885885d45d11c72a6295f5a4653"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-pools.log",
|
||||
"bytes": 358,
|
||||
"sha256": "56dd5efc0c833070576c4c7e2cb2abca8a82380060596be58871067526557c32"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/bucket-replication.go",
|
||||
"bytes": 137081,
|
||||
"sha256": "1e4d27c9eb2bff51eb28460d167faa3279b541d43c77ce35ad010dcab58bf7c5"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/erasure-object.go",
|
||||
"bytes": 84089,
|
||||
"sha256": "1012ae265e2453db125c6f2d16f866c72760b58d61c18f79dff4a551c208e3ce"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/erasure-server-pool-consistency.go",
|
||||
"bytes": 14240,
|
||||
"sha256": "78e63ca1117ea2d3e3e93864a4365dfa9bb303b4707de5087bdc144d0502a0db"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/erasure-server-pool.go",
|
||||
"bytes": 99706,
|
||||
"sha256": "2bfe0899fe3e42840fa4f078887184e4d7d2332d780ce63c31c9495af7056406"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/object-handlers-common.go",
|
||||
"bytes": 19143,
|
||||
"sha256": "00bf8409d25f9cf6a098a90f9e0bd7d2b237be7adc12622f0b9a66146af0a828"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/object-handlers.go",
|
||||
"bytes": 146644,
|
||||
"sha256": "27d47a17e12088f41b35de51da875f28a89a7e821bc27d0f88e6064ec386c993"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/cmd/object-multipart-handlers.go",
|
||||
"bytes": 48935,
|
||||
"sha256": "c818ce72d9115ed4f9cbe51571e7737957010a3934a4d000000895e45100edc7"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production/empty_test.go",
|
||||
"bytes": 12,
|
||||
"sha256": "9c78355c4da37df8f708f143fe19173dc146adcd99d1636594d265c5407755bf"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-production-overlay.json",
|
||||
"bytes": 1508,
|
||||
"sha256": "874d728abc9cb67c5db17ef4c3ce875d789ae4b5000dcbb593fe8dcd47094533"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-related-suite.log",
|
||||
"bytes": 3521,
|
||||
"sha256": "68b23b21c4de8b8252689f841376dec990257d929f0277d3087f467a246728e8"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline-resync.log",
|
||||
"bytes": 116,
|
||||
"sha256": "a620c0ecd112aceac9fd17b989b6283604a3866928ed51e9ed0b16079284fcbf"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline.log",
|
||||
"bytes": 1629,
|
||||
"sha256": "29d80ad52b0302d4eb4993db7a63c88bbc920923afa9775634d3c2fe000c066f"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/baseline_test.go",
|
||||
"bytes": 18743,
|
||||
"sha256": "ce609764fa53f7a86e77dc8f2c00c6d8b878c4c9b6f885fb2618bf8f932cad04"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/build-result.json",
|
||||
"bytes": 729,
|
||||
"sha256": "2df4873188d94c3745631b6e774e76a7646b3366fab7a3badf7ac1be136f7bd9"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/cache-reclaim.json",
|
||||
"bytes": 2482128,
|
||||
"sha256": "8dc7866b30bfd7fed339a3cd4a2dd40c0ec8f3b471cb004fc14f303a41642ad8"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture/cmd/erasure-server-pool-consistency_test.go",
|
||||
"bytes": 54157,
|
||||
"sha256": "c3c1bf441e5f97fd2648c5fc9b89cb11679018e349daa4d0eef4ebbfada322db"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture/cmd/post-policy_test.go",
|
||||
"bytes": 33420,
|
||||
"sha256": "697f8a08dceae481fd1aae7b5e7f3906b55b34fe9928688456eaa943eba79020"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture/cmd/test-utils_test.go",
|
||||
"bytes": 79596,
|
||||
"sha256": "fb4847b10d3d59c7d62ee79a61d54e8c0bc93d6eb32cb520f5662bb7b52750f5"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture-manifest.json",
|
||||
"bytes": 426,
|
||||
"sha256": "8940a56a6f46b7e9c236c3a8d39d927735954ae42601ca52c4a190339fb1f21f"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture-overlay.json",
|
||||
"bytes": 368,
|
||||
"sha256": "3b8586973d426f78145aa25f0c3c9d48faf93678ffe9410fc9aa089cb6167af0"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-fixture.patch",
|
||||
"bytes": 842,
|
||||
"sha256": "3e3732c7fab2b95b9f2e80a6ee973a588600f84eeb2b74c2f15a09373228247f"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-post-manifest.json",
|
||||
"bytes": 205,
|
||||
"sha256": "34ae2c860a5cc1a9615d7b410eff781f5f01d54dba65f66edae0f724d3d232c7"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-post-overlay.json",
|
||||
"bytes": 522,
|
||||
"sha256": "8d0b7594481fa9028fbc4284e1e94cb853828925423d2a7b3cd6f5cbabb82e3c"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/capacity-post.patch",
|
||||
"bytes": 358,
|
||||
"sha256": "f4aa03d4a0a9f0bb220fa3b3b988a8dda1ad7d6764daaeb4e60eb4ee4e996674"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/discussion-baseline.log",
|
||||
"bytes": 1029,
|
||||
"sha256": "9581de36ec9a403d304c32192d17265b1e9c9414c60e9f2cb57321faebbe23dc"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/final-check-results.json",
|
||||
"bytes": 939,
|
||||
"sha256": "6652d2db1ac98d65232e37eda7738972a74f9569ac63b534f1af798ebf19f642"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/final-post-and-pools.log",
|
||||
"bytes": 1280,
|
||||
"sha256": "e7e5fec0761976470eafbf31bcefe4abc241525514f6c3b9a2baa035354144fa"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/fixed-resync-isolated.log",
|
||||
"bytes": 116,
|
||||
"sha256": "bed15b381379998bbe2a0aa1be19c2cfec1b0063886143dbe82918f9652c28ff"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/fixed-targeted-final.log",
|
||||
"bytes": 25221,
|
||||
"sha256": "32283f5d7eea5ce4974fefa0724a1c4de565bb0ef9a0f4fbcad68140bccc32b1"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/fixed-targeted-latest.log",
|
||||
"bytes": 32772,
|
||||
"sha256": "45f362258e21b631cb5ebcd15dec98bd2fa3186f509f18fe216d7cd0ebdb5a9c"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/fixed-targeted.log",
|
||||
"bytes": 27709,
|
||||
"sha256": "a30f7754f90cfade50ed80a066c5e2bd53faf225bd615cd07b9cf1350b2e3139"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/golangci-lint-serial",
|
||||
"bytes": 103,
|
||||
"sha256": "b2a00c2702468165a7851ee3a6addbef9e581833a33492d44ac66d531cfc4fff"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/implementation-files.json",
|
||||
"bytes": 936,
|
||||
"sha256": "7bd1279e1c99da9da4562cda6e0265d36d53a9bb546f10d42c4174c1a34b5c70"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/implementation-v1.patch",
|
||||
"bytes": 48399,
|
||||
"sha256": "8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-build.log",
|
||||
"bytes": 55,
|
||||
"sha256": "6ba9b545236be964861749c72e7609edf12b8f470df30d1ede8fd62f497e629b"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-verifiers-final.log",
|
||||
"bytes": 217,
|
||||
"sha256": "d973482061716daa245e7d7162765dec0e6ecd5fee41249d2702a2d8bfce1c32"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-verifiers-serial.log",
|
||||
"bytes": 410,
|
||||
"sha256": "e42a5bb55f5c1ebfcf02cebebf6d82cf1ec5a2d74590cdf838deba16dd80bfdf"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-verifiers-success.log",
|
||||
"bytes": 162,
|
||||
"sha256": "b4982b6a7302e733c7bec4a5fb36b8ee8865fe95f1595e7079ce7f8455406210"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/make-verifiers.log",
|
||||
"bytes": 285,
|
||||
"sha256": "42f4b147ef4aac45ba91457e7932db30f9b57f285466ee3f10bbaa9a911e5bc7"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/matrix-overlay.json",
|
||||
"bytes": 153,
|
||||
"sha256": "a0fb5eb71ebb2bdb3374813752de5867848454794bcbf302ba0f6d7d4f6c0519"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/matrix_test.go",
|
||||
"bytes": 22996,
|
||||
"sha256": "c9cf527c63800fa045bdf0b8e95d740b81a3d5be3a08c74811fa5c06bed56d4f"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-implementation-request.json",
|
||||
"bytes": 909,
|
||||
"sha256": "9fab15ce1cfb5bad102b1880968e4731a7b5cb02d6d01e6cb2caf8bc9029a150"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-implementation.jsonl",
|
||||
"bytes": 1184150,
|
||||
"sha256": "fef155a7382c8f66f69b7afd5fd94559edcc6f72fc13aeb7ef01319c22c09861"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-implementation.stderr.log",
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v1-request.json",
|
||||
"bytes": 216,
|
||||
"sha256": "175e013154c241805e00368f7841d41faf48ee847ff5251aad96ae57831e244a"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v1.jsonl",
|
||||
"bytes": 1017882,
|
||||
"sha256": "63c00d8362f236a18293e1a637eff3b7c7e38b0bbd11805f75d91e8774efcdb2"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v1.stderr.log",
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v2-request.json",
|
||||
"bytes": 216,
|
||||
"sha256": "ed710eff03d3ebabab277c9e453048097a8649582df4b01f99d0ee0ad3a7c831"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v2.jsonl",
|
||||
"bytes": 414265,
|
||||
"sha256": "e888fbf38ba7fd49891e0757c18006875d02a6bbb325906a31fc8d98e54d0e39"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v2.stderr.log",
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/overlay.json",
|
||||
"bytes": 142,
|
||||
"sha256": "d51934eb99e2b19d149478e090ec327ed2753a5ad2a026c8745b8e2554962a00"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-filter.txt",
|
||||
"bytes": 5771,
|
||||
"sha256": "f022bc24ae0fe391ae51a5095db1d2e415a994934327c7508e6c65edc313cc78"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-names.txt",
|
||||
"bytes": 5767,
|
||||
"sha256": "369ed4b6742d15e8ab4d790615842304a7178fbdc598e231e9205fc096c0785a"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-suite-capacity-final.log",
|
||||
"bytes": 137077,
|
||||
"sha256": "322d4854ba909bd99d5c7740abeeac05eb76cb2d39d2c7541642335ae6a1ffe9"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-suite-capacity.log",
|
||||
"bytes": 3362,
|
||||
"sha256": "1e4f1bc6be2b4339a0d9b7a2e951774fc24ec5521be9fcf53b2ce02031f5cdbe"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-suite-rest.log",
|
||||
"bytes": 172665,
|
||||
"sha256": "846d10084299a77253153c0eed8546e275c789a0289a4198052773e49a73423f"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/related-suite.log",
|
||||
"bytes": 3521,
|
||||
"sha256": "38e3e8e4b7ae815fce40931009a0d4755601a4f3f7f9f44a569edff024c9239b"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/sender_test.go",
|
||||
"bytes": 5140,
|
||||
"sha256": "a1a58fd6968b41cf6c565d9f63a1d0fa1c907f008f70acfd13c7a6c525376357"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/silo-version.log",
|
||||
"bytes": 317,
|
||||
"sha256": "8ce9c5d15082a78e696aa79f8ec007f72ce969ce6ebd7dab2f7db69b20b51f8b"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/targeted-race-final.log",
|
||||
"bytes": 39753,
|
||||
"sha256": "127cfb73f415bad43e2fd79c05150ab765322dcadb29e687b752b6174d4ad850"
|
||||
},
|
||||
{
|
||||
"path": "/Users/vonng/tmp/silo-r5-20260915-77ad/verifiers-result.json",
|
||||
"bytes": 417,
|
||||
"sha256": "6faea89c420685ccae0642be88ddf86938bb25e24f066fd9e57138d16c9e9856"
|
||||
}
|
||||
],
|
||||
"binary": {
|
||||
"path": "/Users/vonng/.codex/worktrees/77ad/silo/silo",
|
||||
"bytes": 93070802,
|
||||
"sha256": "dd789126966d4a42bc0a9bcd8b8eab9714e3eadc7a524505a6224ea6d76c750f"
|
||||
},
|
||||
"scope": "Local development build and exact raw verification/review records; no publication or production acceptance."
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"research_base": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a",
|
||||
"tested_dependency": "dbcf8dec589deb5d91e17d295cb70997635f5b55",
|
||||
"reviewed_patch_sha256": "8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b",
|
||||
"plan_version": "v2",
|
||||
"plan_sha256": "5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca",
|
||||
"files": {
|
||||
"cmd/bucket-replication.go": "cbab22ffe316fabc076e7f4a1fa5b1417d07b265ae9dc1b27454689355926d35",
|
||||
"cmd/erasure-object.go": "4bc848685ea714d88cabbd5d1b8585fbcc06f7b19c775e1a811030e783d0e1a4",
|
||||
"cmd/erasure-server-pool-consistency.go": "d2736ef6bffbb5c5758eba8df38f8d4ecb888a838ab0de8ad3cf015c051f8ad7",
|
||||
"cmd/erasure-server-pool.go": "87ad0b25dfa3081d0e63d0073b788614a9c88e2498a2ce0956b93f8a0a03ef53",
|
||||
"cmd/object-handlers-common.go": "101bd7d7447072d13fed50983b69b562e4725632645e623d7fdd490f388ecdec",
|
||||
"cmd/object-handlers.go": "61897a260f3f5f660f41edcb50956c60e914ef98f9a987da824f16d78171fde2",
|
||||
"cmd/object-multipart-handlers.go": "d9622c69c540ab32dd23916e3f534b6886473a98370c9dd17673e69a423b2a7e",
|
||||
"cmd/replication-tagging-order_test.go": "c8260b4ccf82fa615e1e24b35a07f2d1aacbcf776e5c6f9dadffea4a09ad6ea8",
|
||||
"cmd/replication-tagging-sender_test.go": "3770a1a48a6efe58fe8127e1e4fdf6bd7cf171e17db20f15222ea2f7b85db1af"
|
||||
},
|
||||
"production_unchanged_after_review": true,
|
||||
"delivery_dependency": "af2b1794d38d9e70e1d2c3ee692426e4b6cab4bd"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"base_commit": "dbcf8dec589deb5d91e17d295cb70997635f5b55",
|
||||
"plan_version": "v2",
|
||||
"plan_sha256": "5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca",
|
||||
"patch_sha256": "8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b",
|
||||
"files": {
|
||||
"cmd/bucket-replication.go": "cbab22ffe316fabc076e7f4a1fa5b1417d07b265ae9dc1b27454689355926d35",
|
||||
"cmd/erasure-object.go": "4bc848685ea714d88cabbd5d1b8585fbcc06f7b19c775e1a811030e783d0e1a4",
|
||||
"cmd/erasure-server-pool-consistency.go": "d2736ef6bffbb5c5758eba8df38f8d4ecb888a838ab0de8ad3cf015c051f8ad7",
|
||||
"cmd/erasure-server-pool.go": "87ad0b25dfa3081d0e63d0073b788614a9c88e2498a2ce0956b93f8a0a03ef53",
|
||||
"cmd/object-handlers-common.go": "101bd7d7447072d13fed50983b69b562e4725632645e623d7fdd490f388ecdec",
|
||||
"cmd/object-handlers.go": "61897a260f3f5f660f41edcb50956c60e914ef98f9a987da824f16d78171fde2",
|
||||
"cmd/object-multipart-handlers.go": "d9622c69c540ab32dd23916e3f534b6886473a98370c9dd17673e69a423b2a7e",
|
||||
"cmd/replication-tagging-order_test.go": "64d6dfe3436970caeafcb914157bdedac5982a2105fe72c1753a8d68cf7ed6ef",
|
||||
"cmd/replication-tagging-sender_test.go": "3770a1a48a6efe58fe8127e1e4fdf6bd7cf171e17db20f15222ea2f7b85db1af"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# R5 implementation review disposition
|
||||
|
||||
Real reviewer: `claude-opus-5`, explicit `--effort max`, session `599b4759-add2-4a41-b5b2-865af7a2c096`.
|
||||
Verdict: **GO_WITH_NONBLOCKING_NOTES; 0 blockers**. Raw review is preserved verbatim in `opus-implementation-review.md`; model usage, original plan/patch hashes and raw log location are in `opus-implementation-metadata.json`.
|
||||
|
||||
The accepted v2 plan remains immutable. The following implementation notes supplement it; they do not retroactively change the hash on which plan consensus was reached.
|
||||
|
||||
## Nonblocking notes
|
||||
|
||||
- **N1 accepted:** a scheduled metadata COPY can rewrite object data when the receiver applies bucket-default/automatic KMS encryption. Its cost can therefore exceed metadata I/O. The existing completed-object/scanner and incoming-replica scheduling gates still prevent a feedback loop. No new transfer optimization or HEAD protocol is introduced.
|
||||
- **N2 accepted:** a malformed recorded source tag timestamp fails sender construction and remains a retry failure until an explicit correct tag mutation/repair supplies a valid revision. A missing revision is different from a present invalid/empty value. No historical time is fabricated, and no automatic production rewrite is performed.
|
||||
- **N3 retained scope:** existing marker/trust/REPLICA/version predicates are preserved. Production sender requests satisfy the relevant predicates; R5 does not broaden replication trust.
|
||||
- **N4 accepted compatibility change:** a trusted metadata COPY without a source tag revision preserves stored tags, including the metadata-REPLACE shape. This is the deliberate missing-revision rule in plan C, and is tested under UUID/null versions and unqualified COPY.
|
||||
- **N5 accepted:** ordinary COPY records its chosen tag state, including an empty REPLACE and unchanged tags during key rotation, as a fresh local event. This is consistent with the accepted last-writer-wins scheme.
|
||||
- **N6 no change:** all production writers use the lowercase reserved timestamp key. Case-insensitive sender lookup is compatible with those writers and existing lock timestamp handling.
|
||||
|
||||
## Coverage notes
|
||||
|
||||
- **L1:** the review was supplied a passing run with **13**, not 12, top-level R5 tests. Its verdict explicitly did not claim execution of the wider tests. The wider selection reproduced the same `TestReplicationResync` order-dependent initialization panic on the unmodified production baseline; that test passes in isolation on both baseline and R5. Host-capacity and actual ENOSPC failures are retained, not reported as passes. Final related, race and static/build results are recorded separately in `verification.md`. An unfiltered full `cmd` package run remains an integration check before any later merge; this task delivers a local patch and does not claim that full-package or multi-site production gate passed.
|
||||
- **L2 addressed:** after every incoming multi-pool replay, the R5 test now rereads the addressed version through normal pool routing and checks its empty value and deletion revision. The per-pool checks still inspect every retained copy. This prevents a vacuous pass if all copies disappear. The test deliberately allows existing duplicate suppression to retain both identical copies; existing pool cleanup/retry tests separately exercise retirement.
|
||||
- **L3 accepted boundary:** the combined KMS cases exercise destination encryption and plaintext readback; source fixtures are populated through storage APIs. They do not establish encrypted-source-to-encrypted-destination replication across two running sites. SSE-C key rotation has its own signed HTTP and decrypted GET test.
|
||||
- **L4 confirmed:** both the actual SDK default metadata directive and peer metadata-REPLACE shapes are exercised.
|
||||
|
||||
## Changes after review
|
||||
|
||||
Production code is unchanged from reviewed patch SHA256 `8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b`. Test-only follow-up adds the L2 normal-routing read and applies the repository's gofumpt formatting. `implementation-manifest.json` records the exact reviewed files; the final verification manifest records the final files, so the two versions are distinguishable.
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"model": "claude-opus-5",
|
||||
"effort": "max",
|
||||
"baseline": "dbcf8dec589deb5d91e17d295cb70997635f5b55",
|
||||
"plan_version": "v2",
|
||||
"plan_sha256": "5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca",
|
||||
"patch_sha256": "8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b",
|
||||
"actual_assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"session_id": "599b4759-add2-4a41-b5b2-865af7a2c096",
|
||||
"is_error": false,
|
||||
"modelUsage": {
|
||||
"claude-haiku-4-5-20251001": {
|
||||
"inputTokens": 2125,
|
||||
"outputTokens": 15,
|
||||
"cacheReadInputTokens": 0,
|
||||
"cacheCreationInputTokens": 0,
|
||||
"webSearchRequests": 0,
|
||||
"costUSD": 0.0022,
|
||||
"contextWindow": 200000,
|
||||
"maxOutputTokens": 32000,
|
||||
"thinkingTokens": 0,
|
||||
"canonicalModel": "claude-haiku-4-5",
|
||||
"provider": "firstParty",
|
||||
"costBasis": "list"
|
||||
},
|
||||
"claude-opus-5": {
|
||||
"inputTokens": 106,
|
||||
"outputTokens": 64414,
|
||||
"cacheReadInputTokens": 7275193,
|
||||
"cacheCreationInputTokens": 236959,
|
||||
"webSearchRequests": 0,
|
||||
"costUSD": 7.618066499999999,
|
||||
"contextWindow": 1000000,
|
||||
"maxOutputTokens": 64000,
|
||||
"thinkingTokens": 45128,
|
||||
"canonicalModel": "claude-opus-5",
|
||||
"provider": "firstParty",
|
||||
"costBasis": "list"
|
||||
}
|
||||
},
|
||||
"result": "GO_WITH_NONBLOCKING_NOTES",
|
||||
"blocking_items": 0,
|
||||
"raw_output": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-implementation.jsonl"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
Review the actual R5 implementation independently for correctness and regressions, using Claude Opus 5 at max effort. This is a read-only final code review after an already recorded two-round plan consensus. Do not edit files. Do not simulate tests or claim you executed them. Read the relevant source and evidence yourself; focus on material blockers and minimal compatible fixes.
|
||||
|
||||
Working tree: /Users/vonng/.codex/worktrees/77ad/silo
|
||||
Base dependency commit: dbcf8dec589deb5d91e17d295cb70997635f5b55 (R4 KMS timestamp field, one production addition)
|
||||
R5 plan v2 SHA256: 5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca
|
||||
R5 implementation patch SHA256: 8f6f76ee874c43b0827fde272e8a947f118efb1c1af92bf4a02ef88f93554c1b
|
||||
Manifest: /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/implementation-manifest.json
|
||||
Frozen review patch (7 production files + 2 new tests): /Users/vonng/tmp/silo-r5-20260915-77ad/implementation-v1.patch
|
||||
Plan: /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/plan-v2.md
|
||||
Prior actual review: /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/opus-v2-review.md
|
||||
Consensus and disagreements: /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/consensus.md, /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/opus-v1-response.md
|
||||
Baseline reproduction: /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/reproduction.md
|
||||
Latest new regression run: /Users/vonng/tmp/silo-r5-20260915-77ad/fixed-targeted-latest.log (PASS, 9.161s; signed HTTP and actual storage on single/16 disks, null/UUID, COPY default and REPLACE, PUT/multipart, KMS plaintext GET, SSE-C rotation, multi-pool, retry and stale source ACK; test names and details in source.)
|
||||
Additional related suite and race/static validation are ongoing and are not yet accepted. An expanded suite hit existing TestReplicationResync initialization panic before R5 tests; baseline isolation is ongoing. Do not treat that as a proven R5 regression or a passing test.
|
||||
|
||||
Research and implementation points to scrutinize:
|
||||
1. Empty tag values are ordered states only with recorded timestamp; no fabricated tombstone for empty legacy object. Nonempty legacy sender falls back to ModTime. Malformed stored timestamp fails PUT and metadata COPY sender construction.
|
||||
2. Local PUT/DELETE tagging timestamps are unconditional, advance under existing storage locks; pooled mutation computes a single revision > every copy; do not mutate caller map. Replicas keep source ordering and equal timestamp stored-wins.
|
||||
3. Actual sender getCopyObjMetadata + minio Core.CopyObject sends tagging REPLACE with no metadata directive; peers may also send metadata REPLACE. Capture stored timestamp before reconstruction, accept incoming empty value, prevent stale SSE-C encMetadata snapshot overwrite, keep existing storage rechecks.
|
||||
4. PUT/multipart init persist parsed trusted timestamp. Matching ETag/version no-op is relaxed only for trusted newer tag revision, retaining explicit client preconditions and SSE-C behavior. Multipart completion rechecks revision under lock.
|
||||
5. Equal visible tag values can hide a newer deletion/re-addition revision, so scheduled metadata/heal work with recorded timestamp sends metadata. Preserve existing Completed/scanner and incoming no-reschedule gates.
|
||||
6. Replication status ACK no longer writes old queued ri.UserTags over the current tags. Preserve current value/timestamp under metadata lock including multi-pool.
|
||||
7. Scope explicitly excludes tag-filter target eligibility changes, historical tombstone recovery, arbitrary independent unversioned content replacements, clock skew consensus, and production multi-site acceptance. R4 owns object-api-options.go; it is not in this R5 diff.
|
||||
|
||||
There was an earlier KMS test-only fixture error: it assigned a single-PUT ETag to a multipart-layout retransmission; corrected by seeding a real multipart source and using its actual ETag/layout (full latest run passes plaintext read). Inspect the fixture rather than inferring production encryption correctness solely from prior failed test output.
|
||||
|
||||
Return a clear GO / GO_WITH_NONBLOCKING_NOTES / REQUEST_CHANGES verdict; blocking count; concrete source locations with cause, trigger and effect for any blockers; tests/coverage limitations if material. State which baseline, plan hash and patch hash you reviewed (you may quote supplied hash, but cannot independently compute with Read/Grep/Glob). Do not demand unrelated broad redesign. Distinguish actual source defect from nonblocking improvement or scope limitation.
|
||||
@@ -0,0 +1,58 @@
|
||||
Write is disabled in this session, so I'll deliver the review directly rather than to the plan file.
|
||||
|
||||
# R5 implementation review — tag deletion and ordered replication
|
||||
|
||||
## Verdict: **GO_WITH_NONBLOCKING_NOTES** — blocking count: **0**
|
||||
|
||||
Read-only review. No files edited, no tests run, no results simulated. Every pass I cite is quoted from the supplied log.
|
||||
|
||||
## What I reviewed
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Working tree | `/Users/vonng/.codex/worktrees/77ad/silo`, branch `codex/r5-tag-deletion-ordering` |
|
||||
| Baseline dependency | `dbcf8dec589deb5d91e17d295cb70997635f5b55` (R4 SSE-KMS field) |
|
||||
| Plan | `plan-v2.md`, SHA256 `5a782acf…7fc7446ca` *(quoted)* |
|
||||
| Patch | `implementation-v1.patch`, SHA256 `8f6f76ee…f88f93554c1b` *(quoted)* |
|
||||
|
||||
**Hash caveat:** with Read/Grep/Glob I **cannot compute** either SHA256 — both are quoted from the manifest/task. What I did verify by reading is that every hunk in the frozen patch is present verbatim in the working tree across all seven production files. That is source identity by inspection, not by digest. I also confirmed R5 does not touch `cmd/object-api-options.go`; the R4 branch carrying `ReplicationSourceTaggingTimestamp` (`object-api-options.go:449-460`) is unmodified.
|
||||
|
||||
## Per-claim findings
|
||||
|
||||
**1. Empty values are ordered states; no fabricated legacy tombstone.** Confirmed. `replicationTaggingTimestamp` (`bucket-replication.go:786-794`) returns the recorded stamp even with empty tags, falls back to `ModTime` only for non-empty tags, zero otherwise. Used by both `putReplicationOpts` (`:861-870`) and the metadata sender (`:1702-1707`). The SDK omits the header for a zero time (`minio-go@…60bd07042d49/api-put-object.go:236-238`, `api-compose-object.go:286-288`), so "no revision" really travels as absence. Malformed stamps fail both constructions.
|
||||
|
||||
**2. Local revisions unconditional and monotonic.** Confirmed. Both handlers mint one `UTCNow()` outside the `dsc.ReplicateAny()` branch (`object-handlers.go:3773-3778`, `:3876-3881`); `getOpts` leaves `opts.UserDefined` nil (`object-api-options.go:110`,`:39`), so the unconditional map replacement drops nothing. `er.PutObjectTags` applies the guard under the existing NS lock (`erasure-object.go:2273-2282`, `:2330-2334`); an absent stamp yields `""` and preserves legacy direct-storage semantics. `z.PutObjectTags` folds one candidate strictly beyond every copy and **clones** first (`erasure-server-pool.go:3054-3062`) — `opts` is a value parameter and `er.PutObjectTags` never writes `opts.UserDefined`, so no caller map is mutated. No replica path reaches `PutObjectTags` (the only two production callers are the tagging handlers), so replicas keep strict source ordering via `reconcileStoredObjectTags`, stored-wins on ties (`erasure-server-pool-consistency.go:238-242`).
|
||||
|
||||
**3. COPY receiver.** Confirmed. Stored pair captured before reconstruction (`object-handlers.go:1800`); `srcInfo.UserTags` is never reassigned between the source read and the decision, so it genuinely is stored state. The decision block (`:1818-1837`) accepts an incoming empty value with a stamp and rechecks the captured state; all existing in-lock rechecks still run (`erasure-object.go:136-138`, `:1312-1315`; `erasure-multipart.go:1161-1190`; `erasure-server-pool.go:1443-1450`). The `encMetadata` fix (`:1840`) is safe and correctly placed — `encMetadata` receives reserved keys only on the SSE-C rotation path (`:1655-1659`), and the delete lands after `rotateKey`/`newEncryptReader` and before the merge at `:1910`.
|
||||
|
||||
**4. PUT/multipart persistence and the duplicate exception.** Confirmed. `putOptsFromHeaders` aliases `opts.UserDefined = metadata` in both branches (`object-api-options.go:451`,`:464`), so post-build writes reach storage (`object-handlers.go:2323-2325`; `object-multipart-handlers.go:315-318`, correctly *after* `maps.Copy(metadata, encMetadata)` at `:300`). The relaxation (`object-handlers-common.go:243-246`) sits below the explicit `If-Match`/`If-None-Match` checks, is gated on `isReplicaTrusted` + `olderThan` (zero source never wins, `bucket-object-lock.go:370-376`), and leaves the SSE-C exemption intact. It cannot loop: once the write lands the stamps are equal and the next attempt 412s. `completeMultipartOpts` sets neither `PreserveETag` nor a tagging timestamp (`object-api-options.go:501-550`), so completion needs no new exception and reconciles under the lock (`object-multipart-handlers.go:1201`).
|
||||
|
||||
**5. Equal values can hide a newer revision.** Confirmed and correctly scoped. The gate (`bucket-replication.go:1013-1018`) sits after the null-version resync exclusion and after **every** branch that can return `replicateAll`; from there only `replicateMetadata`/`replicateNone` are reachable, so it can never downgrade a needed full transfer. It is reached only from `replicationActionForTarget` → `replicateAll` (`:1608`), not from the object-replication fast path (`:1328-1343`). The Completed gate (`:3775`) and failures-only requeue (`:1316`) bound the work, and an incoming replica COPY schedules no outgoing event. Existing fixtures carry no tagging stamp (`bucket-replication_test.go:716-739`), so they are unaffected.
|
||||
|
||||
**6. ACK no longer overwrites current tags.** Confirmed removed (`bucket-replication.go:1276-1286`). Preservation holds on both write-backs: `er.PutObjectMetadata` copies from `ObjectInfo.UserDefined`, which `cleanMetadata` strips of `x-amz-tagging` (`object-api-utils.go:403-407`; `erasure-object.go:2254-2260`); `updatePoolMetadata` falls back to merged `UserTags` and rewrites the merged newest stamp (`erasure-server-pool-consistency.go:194-214`). Both under the object lock (`erasure-object.go:2196-2205`; `erasure-server-pool.go:3020-3029`). The sender also re-reads current state first (`bucket-replication.go:1527-1550`).
|
||||
|
||||
**7. Scope.** Respected — no tag-filter eligibility change, no historical tombstone invention, no clock-skew consensus, no `object-api-options.go` change.
|
||||
|
||||
**Trust boundary re-checked:** the reserved key cannot be injected from the wire — `containsReservedMetadata` rejects the whole `X-Minio-Internal-` class outside the SSE allowlist (`generic-handlers.go:75-85`), and `extractMetadataFromMimeWithReplication` maps only `replicationToInternalHeaders` (`handler-utils.go:258-298`).
|
||||
|
||||
**KMS fixture inspected directly**, not inferred from prior output: the multipart case now seeds a real multipart source and reuses its actual ETag/part layout (`replication-tagging-order_test.go:472-489`). The earlier single-PUT-ETag mismatch is gone. See L3 for what it still does not cover.
|
||||
|
||||
## Non-blocking notes (no change required)
|
||||
|
||||
- **N1 — on encrypted destinations the forced metadata COPY is not metadata-only.** The gate at `bucket-replication.go:1013-1018` yields a replica COPY; with bucket-default/auto KMS the destination applies SSE before `copyDstOpts` (`object-handlers.go:1428-1433`) and then clears `srcInfo.metadataOnly` (`:1669-1677`) — so it **rewrites object data**. Bounded to one COPY per object entering heal and one per object per explicit resync (not a loop), but the plan's "extra metadata I/O" understates this case. Worth a sentence in the cost note.
|
||||
- **N2 — fail-closed on a malformed stored revision is terminal for that object** (`:786-794` → `:1702-1707`/`:867-870`, requeued by MRF at `:1316-1322`). No production writer can produce such a value, so this is a defensive tail. Note the asymmetry: storage self-heals the same corruption (invalid *stored* ⇒ incoming wins, `erasure-server-pool-consistency.go:238-242`) while the sender refuses to proceed. The minimal hardening, if ever wanted, is to treat a present-but-**empty** value as absent — I traced no reachable path producing one, and the current behavior is what plan and consensus chose, so I am not asking for it.
|
||||
- **N3 — trusted-marker vs REPLICA asymmetry (pre-existing).** `object-handlers.go:2323` / `object-multipart-handlers.go:316` persist on `opts.ReplicationRequest`, while the in-lock recheck needs `isReplicaTrusted` **and** a version ID (`:2442`). Production sets both; the new precondition exception uses the stricter predicate. Accepted in plan §D.
|
||||
- **N4 — a trusted metadata COPY with no source revision now ignores the request's tag value** (`object-handlers.go:1826-1833`). For a peer sending `x-amz-metadata-directive: REPLACE` without a revision, the value used to land (`X-Amz-Tagging` is in `supportedHeaders`, `handler-utils.go:271-283`). No MinIO sender produces that shape, and the R4 case `object-copy-replication-tagging_test.go:89` already expects stored-wins there, reaching it via the storage reconcile. Deliberate per plan §C.
|
||||
- **N5 — ordinary COPY always writes an explicit tag value plus a fresh revision** (`:1834-1837`). (a) `x-amz-tagging-directive: REPLACE` with no tags now genuinely clears the destination, where the default-metadata path used to carry source tags forward — an S3 conformance improvement, covered by `TestAPILocalTaggingAlwaysAdvancesRevision`. (b) An in-place key-rotation COPY advances the revision without changing any value, re-asserting current tags against an older in-flight remote deletion. Both follow from last-writer-wins as specified.
|
||||
- **N6 — cosmetic.** The key is read case-insensitively at `bucket-replication.go:787`/`:1016`, exactly elsewhere. `TaggingTimestamp` is lowercase (`:74`) and storage writes only lowercase, so they agree; the same mix already exists for lock timestamps in that file (`:896` vs `:1708`).
|
||||
|
||||
## Tests and coverage limitations (material)
|
||||
|
||||
- **L1 — the only established green result is the 12 R5 tests** (`fixed-targeted-latest.log`, `ok … 9.161s`): signed HTTP through real single-disk and 16-disk storage, null/UUID, COPY default and REPLACE, PUT/multipart, KMS, SSE-C rotation, multi-pool, sender retry and stale ACK. The wider `cmd` package, `-race`, `gofmt` and `git diff --check` are ongoing, and the `TestReplicationResync` panic is unattributed. I treat that as an **open verification item**, not a regression and not a pass. My static read found no existing test whose expectations R5 flips — I checked the `getReplicationAction` fixtures, the R4 KMS COPY table including its `missing-timestamp` case (R5 satisfies it via the handler instead of the storage reconcile), and confirmed no pre-existing test calls `PutObjectTags` with a supplied revision.
|
||||
- **L2 — multi-pool convergence after an *incoming* replica write is asserted weakly.** `replication-tagging-order_test.go:688-698` skips any pool whose version is absent and only asserts a retained copy is correct; it pins neither which pool is retained nor that retirement ran. The case that matters for §A — one revision strictly greater than every copy, in the response *and* every stored copy — is pinned by `TestLocalTaggingCommitCannotRegressRevision`.
|
||||
- **L3 — the KMS variant's source objects are plaintext.** Seeds use `obj.PutObject` (`:468`, `:477-488`), bypassing handler encryption, so encryption enters only via the incoming request and the destination bucket default. Real coverage of the R4 field on the receive side, but not encrypted-source-to-encrypted-destination end to end. The trailing plaintext GET (`:520-523`) does establish the final object is readable.
|
||||
- **L4 — wire shape is well pinned.** `TestTaggingProductionCopyWireShape` asserts the real SDK request (`metadata-directive=""`, `tagging-directive=REPLACE`) against a live peer, and `r5Receive`'s `"copy"` operation covers peer metadata-REPLACE independently. Both required shapes are present.
|
||||
|
||||
## Recommendation
|
||||
|
||||
The production diff is merge-eligible as written; I found no actual source defect. The single gating action before merge is closing **L1** — a clean full-package run (plus `-race`, `gofmt`, `git diff --check`) with the `TestReplicationResync` panic isolated against the unpatched baseline. N1 and N2 deserve a sentence each in the plan's cost/limitations section; N3–N6 are already covered by plan §C/§D and need no action.
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"model": "claude-opus-5",
|
||||
"effort": "max",
|
||||
"plan_version": "v1",
|
||||
"plan_sha256": "fd6051527ebf19f624125bd3238da2f938420917226387c0f9373f1a98e87993",
|
||||
"baseline": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a",
|
||||
"actual_assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"session_id": "e448ee0a-4ab5-4520-98f9-68c9ddf6208f",
|
||||
"is_error": false,
|
||||
"modelUsage": {
|
||||
"claude-haiku-4-5-20251001": {
|
||||
"inputTokens": 1378,
|
||||
"outputTokens": 14,
|
||||
"cacheReadInputTokens": 0,
|
||||
"cacheCreationInputTokens": 0,
|
||||
"webSearchRequests": 0,
|
||||
"costUSD": 0.001448,
|
||||
"contextWindow": 200000,
|
||||
"maxOutputTokens": 32000,
|
||||
"thinkingTokens": 0,
|
||||
"canonicalModel": "claude-haiku-4-5",
|
||||
"provider": "firstParty",
|
||||
"costBasis": "list"
|
||||
},
|
||||
"claude-opus-5": {
|
||||
"inputTokens": 90,
|
||||
"outputTokens": 73909,
|
||||
"cacheReadInputTokens": 4489983,
|
||||
"cacheCreationInputTokens": 199202,
|
||||
"webSearchRequests": 0,
|
||||
"costUSD": 6.085186500000001,
|
||||
"contextWindow": 1000000,
|
||||
"maxOutputTokens": 64000,
|
||||
"thinkingTokens": 54986,
|
||||
"canonicalModel": "claude-opus-5",
|
||||
"provider": "firstParty",
|
||||
"costBasis": "list"
|
||||
}
|
||||
},
|
||||
"result": "REQUEST_CHANGES",
|
||||
"raw_output": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v1.jsonl"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
Act as an independent reviewer of the R5 repair proposal in this repository. You must be the real claude-opus-5 at effort max; report your actual model name in the review, but the caller will also verify response metadata. Read /Users/vonng/.codex/worktrees/77ad/silo/docs/investigations/r5/plan-v1.md completely. Baseline SHA is 9ebe81c1b3611f9cc73e676b5b741c2be62c467a. Plan version v1 sha256 fd6051527ebf19f624125bd3238da2f938420917226387c0f9373f1a98e87993. No production repair has been implemented. Read docs/investigations/r5/baseline.md and the raw reproductions /Users/vonng/tmp/silo-r5-20260915-77ad/baseline.log and /Users/vonng/tmp/silo-r5-20260915-77ad/baseline-extended.log, plus /Users/vonng/tmp/silo-r5-20260915-77ad/baseline_test.go. Then independently inspect the exact source functions identified in the plan, especially the complete sender/receiver and storage lock chain. R4 owns object-api-options.go and will supply its separate KMS timestamp fix. Do not edit any files.
|
||||
|
||||
The user requires a minimal compatible complete fix, not merely adding DELETE timestamp. Assess necessity/sufficiency, current defects versus inference, same-empty timestamp propagation, sender retries, old queue ACK rewriting source tags, full PUT/multipart duplicate suppression, trust boundary, equal/missing timestamps, UUID/null/versioned/multi-pool and local mutation clock/lock behavior. Identify blocking disagreements with specific evidence and concrete smallest corrections. Explicitly say APPROVE or REQUEST_CHANGES for this exact v1 hash and list accepted/nonblocking/required changes. Do not claim consensus if any blocking issue remains. Be exact about limitations and whether proposed test coverage can establish the scope. Use the permitted Read/Grep/Glob tools for source verification. Return a substantive review, not just a summary.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Response to actual Opus v1 review
|
||||
|
||||
The original review is retained verbatim in opus-v1-review.md, with actual model identity/usage in opus-v1-metadata.json and the full stream in the raw evidence directory. Result was REQUEST_CHANGES, five blockers. No implementation was started and no consensus is implied by the response below.
|
||||
|
||||
## Blocking items
|
||||
|
||||
| Item | Disposition for v2 |
|
||||
|---|---|
|
||||
| R1 empty/no-revision ModTime fallback | Accepted. Never synthesize a revision for empty tags without one. Keep only existing nonempty ModTime fallback. Add no-revision wire regression. |
|
||||
| R2 force metadata only for empty values | Disagree with the proposed restriction; accept the I/O cost warning. Same nonempty values can carry different revisions: X@T1, delete@T2, re-add X@T3. Skipping T3 lets delayed delete T2 incorrectly win. A recorded revision requires delivery for either value. v2 explicitly accepts extra COPY per scheduled/resync invocation. queueReplicationHeal already skips Completed unless resync requested; replicateObject only requeues Failed. Thus the predicate is permanently conservative, but it does not create perpetual background work. Avoiding a new HEAD protocol is the smaller implementation. Re-review required. |
|
||||
| R3 actual metadata COPY request shape | Accepted after inspecting the pinned minio-go Core.CopyObject/copyObjectDo. getCopyObjMetadata supplies only tagging REPLACE; the SDK adds no metadata directive. Update provenance and test both actual SDK shape and peers using metadata REPLACE. |
|
||||
| R4 local revision inversion | Accepted and strengthened for uniform multi-pool persistence. er.PutObjectTags advances a valid supplied revision beyond stored time under lock. z.PutObjectTags computes one value beyond every addressed copy before writing, so the response, ordinary source read and all copies agree. Per-pool-only guards can produce different times; mergedPoolObjectInfo is not every ordinary read path, so relying on later merge is insufficient for a precise source revision. Direct calls without valid supplied revisions keep old semantics. |
|
||||
| R5 duplicate suppression wording | Accepted. Explicitly use strictly-newer-than-stored, with existing olderThan semantics. Preserve client preconditions; only trusted REPLICA source timestamps can relax version/ETag duplicate suppression. Document possible data re-upload cost. |
|
||||
|
||||
## Nonblocking items
|
||||
|
||||
- Equal times: document stored-wins consistency across COPY and storage, including null/unqualified requests.
|
||||
- Invalid COPY sender timestamp: fail the metadata send with Failed, as PUT option construction does; no silent fallback.
|
||||
- Tag trust versus replica trust: preserve existing predicates, document production supplies both. No permission relaxation.
|
||||
- KMS nuance: agree PUT/multipart depend on R4, but disagree that metadata COPY never depends on R4. Destination bucket defaults and globalAutoEncryption inject KMS before copyDstOpts at object-handlers.go 1425–1435. R4 independently reproduced all three explicit/default/auto entrypoints. Do not adopt the inaccurate broader exclusion. Combined tests required.
|
||||
- Ordinary whole-object replacement/no-version clocks: document unchanged semantics. This plan addresses local tagging mutation and selected per-hop replicated version updates; it does not create a new conflict model for independent unversioned content overwrites.
|
||||
- Tag-filtered target eligibility: document the pre-existing scope limitation; no selection/rule protocol redesign in R5. Final result must not claim arbitrary configuration convergence.
|
||||
- ACK: additionally reproduced on both real storage backends in baseline-ack.log. The source revives `key=queued` with the deletion's timestamp after old queue event completion. Remove the stale assignment; storage preserves current state.
|
||||
- Storage lock recheck: reuse and keep existing error behavior. The tests establish per-hop behavior, not a production multi-site deployment or physical clock-skew experiment.
|
||||
|
||||
## Added evidence
|
||||
|
||||
`/Users/vonng/tmp/silo-r5-20260915-77ad/matrix_test.go` contains temporary signed HTTP UUID/null tests, exact SDK COPY wire capture, local timestamp inversion, multi-pool deletes and SSE-C rotation. The original matrix fails baseline as expected. `discussion-baseline.log` isolates R2's equal nonempty case, R3's real SDK shape, and R4's commit inversion. A short first compile missed a test import and was corrected; only the subsequent compile/run is behavioral evidence.
|
||||
|
||||
The v2 plan, not this commentary, is the next consensus target. Production diff remains empty.
|
||||
@@ -0,0 +1,111 @@
|
||||
Write is disabled in this session, so the review is delivered here in full.
|
||||
|
||||
# Independent review — R5 repair plan v1
|
||||
|
||||
**Reviewer model: `claude-opus-5` (Opus 5), effort max.** I read `docs/investigations/r5/plan-v1.md` completely, `docs/investigations/r5/baseline.md`, both raw logs, `baseline_test.go`, and then independently inspected the sender/receiver/storage chain at `9ebe81c1b`.
|
||||
|
||||
**VERDICT: REQUEST_CHANGES** for plan v1 sha256 `fd6051527ebf19f624125bd3238da2f938420917226387c0f9373f1a98e87993`. Five blocking corrections (R1–R5). **No consensus is claimed.**
|
||||
|
||||
Binding caveat, stated exactly: this session has read-only tools, so I did not execute `sha256sum`. I reviewed the file content at that path at the baseline SHA and cite the plan's own line numbers so you can bind findings to the hash.
|
||||
|
||||
---
|
||||
|
||||
## 1. Claims I confirmed from source (necessity, not inference)
|
||||
|
||||
| Plan | Claim | Evidence | Verdict |
|
||||
|---|---|---|---|
|
||||
| L14 | PUT tagging stamps only when replication is selected; DELETE never stamps | `cmd/object-handlers.go:3762-3768`, `:3865-3870` | **correct** (and PUT uses two separate `UTCNow()` calls, 3765/3767) |
|
||||
| L14 | both write under the existing object lock; multi-pool updates the addressed version in each copy | `cmd/erasure-object.go:2272-2337`; `cmd/erasure-server-pool.go:3033-3075` | **correct** |
|
||||
| L15 | `putReplicationOpts` stamps only inside nonempty `UserTags`; multipart clears SourceMTime | `cmd/bucket-replication.go:849-863`, `:1776` | **correct** |
|
||||
| L16 | metadata COPY carries an explicit empty tag; ModTime default only for nonempty | `:747`, `:1692-1700`; SDK `copyObjectDo:262-264` writes the map verbatim | **correct** |
|
||||
| L18 | PUT / multipart initiation parse but never persist the source stamp | `cmd/object-api-options.go:473`; no writer anywhere in `cmd/` | **correct** (matches `baseline-extended.log:19-26`) |
|
||||
| L18 | multipart completion already rechecks the persisted upload under the object lock | `cmd/erasure-multipart.go:1161-1190` | **correct** |
|
||||
| L19 | `getReplicationAction` compares values/counts, not ordering time | `cmd/bucket-replication.go:1000-1005` | **correct** |
|
||||
| L20 | `checkPreconditionsPUT` skips matching version/ETag for non-SSE-C replicas | `cmd/object-handlers-common.go:233-247` | **correct**; and senders treat 412 as delivered (`:1466`; multipart `:1786-1788` returns `nil`) |
|
||||
| L21 | ACK callback copies stale `ri.UserTags` | `cmd/bucket-replication.go:1272-1274` | **correct, and worse than stated** |
|
||||
| L23 | `reconcileStoredObjectTags` gates as described | `cmd/erasure-server-pool-consistency.go:232-243` | **correct** |
|
||||
|
||||
Two amplifiers the plan does not name, both strengthening it:
|
||||
|
||||
- The ACK callback writes stale tags **without** a timestamp. The revived tag set therefore inherits the *deletion's newer* revision and propagates downstream as authoritative. Removal is the right fix and is sufficient: `er.PutObjectMetadata` preserves `fi.Metadata`'s tag key (`cmd/erasure-object.go:2260`) and `updatePoolMetadata` falls back to merged `UserTags` (`cmd/erasure-server-pool-consistency.go:194-214`).
|
||||
- The `encMetadata` merge at `cmd/object-handlers.go:1903` restores every reserved key snapshotted at `:1655-1659`; the guard at `:1855-1864` covers only the two Object Lock stamps. Tag revision is genuinely exposed, so L47 is justified.
|
||||
|
||||
---
|
||||
|
||||
## 2. Blocking disagreements
|
||||
|
||||
### R1 — Do not synthesize a ModTime revision for objects with no tags and no revision
|
||||
**Where:** L35 ("otherwise object ModTime (also for empty legacy objects)") composed with L49 ("persist a nonzero parsed trusted source timestamp").
|
||||
|
||||
**Evidence:** `PutObjectOptions.Header()` emits the header whenever `TaggingTimestamp` is non-zero (SDK `api-put-object.go:236-238`). If L35 moves selection outside the nonempty branch *and* defaults to ModTime, every replicated object — including every object that has never carried a tag — ships a non-zero stamp, and L49 persists it. Every object on the destination then owns a tag revision. Composed with L39 (recorded revision ⇒ force metadata replication), **every object at the next hop always selects metadata replication.** It also contradicts L10 ("not a reason to change the storage format") and L57 ("we do not invent historical deletion times").
|
||||
|
||||
**Smallest correction:** send a stamp only when `objInfo.UserTags != ""` **or** a recorded revision exists. That keeps the tombstone case (empty + revision — the entire point), keeps the existing nonempty ModTime fallback, and drops only empty + no-revision, which L57 already declares unrecoverable. This makes §B consistent with §D.
|
||||
|
||||
### R2 — Bound the forced metadata replication in `getReplicationAction`
|
||||
**Where:** L39.
|
||||
|
||||
**Evidence:** the destination's revision is invisible to HEAD, so the condition never becomes false. Any object carrying a revision never returns `replicateNone` again: every heal, MRF retry and `ExistingObjectReplicationType` resync re-COPIES its metadata, rewriting `xl.meta` on the destination (and, multi-pool, running `retireReplicaCopies`) each pass. L39's "extra COPY only for already-scheduled work" understates a permanent non-convergence. Existing tests won't catch it — `newMatchingReplicationPair` (`cmd/bucket-replication_test.go:716-739`) carries no revision.
|
||||
|
||||
**Smallest correction:** fire only when `oi1.UserTags == ""` and a revision is recorded — exactly the empty-to-empty tombstone L19 names and `TestReviewR5SameEmptyTagsMustTransferTimestamp` asserts. Nonempty states are already caught by the existing value/count comparison at `:1003`. Then state the residual: tag-deleted objects still never converge to `replicateNone`.
|
||||
|
||||
### R3 — The production metadata COPY does not send `x-amz-metadata-directive: REPLACE`
|
||||
**Where:** L17.
|
||||
|
||||
**Evidence:** `getCopyObjMetadata` sets `x-amz-tagging-directive: REPLACE` (`:748`) but never the metadata directive, so `getCpObjMetadataFromHeader` takes the `defaultMeta` branch (`cmd/object-handlers.go:1143,1165-1170`). Therefore:
|
||||
1. "its REPLACE metadata map also loses the previous timestamp before comparison" is **false on the production path** — `defaultMeta` preserves the stored revision. It is true only for a peer that does send REPLACE.
|
||||
2. The empty tombstone is dropped for a *different* reason than the plan gives: `defaultMeta` carries the stored `X-Amz-Tagging` forward and the `objTags != ""` gate at `:1817` skips the overwrite. (Note `X-Amz-Tagging` is in `supportedHeaders`, `cmd/handler-utils.go:84`, so on the REPLACE path the empty value *does* arrive in the map — only the stamp is missing.)
|
||||
3. The reproduction sends `x-amz-metadata-directive: REPLACE` (`baseline_test.go:134`), so it **does not pin the production request shape.** The conclusion still holds (with a stale stored stamp the delayed COPY wins either way), but the evidence chain as written is not the one production executes.
|
||||
|
||||
**Smallest correction:** fix L17, and add a sender-shaped COPY case asserting against `getCopyObjMetadata` output rather than a hand-built header map.
|
||||
|
||||
### R4 — Missing monotonic guard on the local revision at commit
|
||||
**Where:** L31 explicitly asks the reviewer to decide. My answer: commit-time *generation* is not required; a commit-time monotonic *guard* is.
|
||||
|
||||
**Evidence:** `er.PutObjectTags` writes `fi.Metadata[x-amz-tagging]` and copies `opts.UserDefined` with **no ordering check** (`cmd/erasure-object.go:2328-2330`), and the handler mints the stamp *before* the namespace lock. R5 newly makes DELETE mint a revision, so a DELETE→PUT pair can invert — via lock queueing (`globalOperationTimeout` waits) or clock skew between the two nodes serving the two requests. Result: source holds `tags=X @ t_old`, replica holds the tombstone `@ t_new`. Every retransmit is then rejected by `reconcileStoredObjectTags` (`stamp.Before(incoming)` false), the sender still records **Completed**, and — with R2's rule — re-sends forever. Permanent, silent divergence: precisely the failure class R5 exists to remove, newly broadened by change A.
|
||||
|
||||
**Smallest correction:** in `er.PutObjectTags`, under the lock, if the incoming revision is not strictly after the stored one, advance it to stored + 1ns. Multi-pool is safe without a second site of change: `z.PutObjectTags` writes identical tags to all copies, so any per-pool stamp differences still merge to a consistent `(tags, newest stamp)` pair through `mergedPoolObjectInfo` (`cmd/erasure-server-pool-consistency.go:124-131`).
|
||||
|
||||
### R5 — Under-specified duplicate-suppression comparison
|
||||
**Where:** L51, "a valid newer source tag timestamp makes matching version/ETag insufficient".
|
||||
|
||||
**Evidence:** read naively as "non-zero source stamp ⇒ bypass", this disables the duplicate guard for *every* tagged replica write; for multipart it re-uploads all parts, since 412 at initiation is currently the cheap exit (`:1786-1788`). `TestReviewR5NewerTagsMustBypassContentDuplicate` already encodes the correct comparison (source stamp vs. `oi`'s stored stamp), but the prose does not.
|
||||
|
||||
**Smallest correction:** one sentence — "strictly newer than the destination's stored tag revision" — plus the cost note that even correctly scoped, this re-PUTs object data to deliver a tag-only change.
|
||||
|
||||
---
|
||||
|
||||
## 3. Accepted / non-blocking (state them; do not necessarily fix)
|
||||
|
||||
- **§A local generation semantics** (L29): accepted as sufficient, subject to R4. Use one `UTCNow()` for both stamps as proposed.
|
||||
- **§B ACK removal** (L41): necessary and sufficient; preservation verified on both the single-pool and pooled write-back paths.
|
||||
- **§C `encMetadata` reconciliation** (L47): accepted. Today's observable effect is fail-closed (update dropped) when `ReplicaLockReconcile` is on, and a mismatched `(new tags, old stamp)` pair when `VersionID == ""` — worth one sentence.
|
||||
- **Equal timestamps:** adopting `reconcileStoredObjectTags` in the handler silently flips the non-versioned COPY path from "incoming wins on equal" (`cmd/object-handlers.go:1824`, `!ondiskTimestamp.After(srcTimestamp)`) to "stored wins on equal". This is the right direction and removes a real handler/storage inconsistency, but it is a compat-visible change and belongs in §D.
|
||||
- **Missing/invalid timestamps:** the gate asymmetry is correct as the plan describes — invalid *stored* ⇒ incoming wins; invalid *incoming* with valid stored ⇒ stored wins. Note the metadata COPY sender swallows parse errors (`:1696-1699`, `if err == nil`); L35's fail-loud rule should cover that call site too.
|
||||
- **Trust boundary: sound.** Stamp honored only under `trustedReplication` (`cmd/object-api-options.go:390-396`); headers stripped otherwise (`cmd/replication-trust.go:96-112,139-143`); client-supplied reserved headers rejected wholesale (`cmd/generic-handlers.go:75-85`). One asymmetry to resolve deliberately: the tag decision keys on `dstOpts.ReplicationRequest` (trusted marker) while Object Lock keys on `replicaTrusted` (marker + REPLICA). Production sets both.
|
||||
- **SSE-KMS / R4 boundary — more precise than the plan:** the KMS early return (`cmd/object-api-options.go:431-461`) drops `ReplicationSourceTaggingTimestamp`, so R5's PUT/multipart persistence is a **silent no-op for KMS-header requests** until R4 lands. The metadata-COPY leg is **not** affected: the public SSE header is synthesized only in responses (`cmd/api-response.go:525-533`), so `getCopyObjMetadata` never forwards it and tag deletions still order correctly for SSE-KMS objects via COPY. Say this instead of only "KMS combined tests after R4".
|
||||
- **Ordinary PUT/multipart with tags writes no revision,** so §A's invariant has a hole. Mostly masked by versioning; exposed on null/suspended versions, where `ReplicaLockReconcile` is off anyway (`:1847`, `:2432`). Either stamp there too (~3 lines) or document.
|
||||
- **Tag-filtered replication rules never see a deletion:** `FilterTargetArns` runs on the *post*-deletion `UserTags` (`cmd/bucket-replication.go:1189-1193`; `scheduleReplication` is called with the post-write `oi` at `cmd/object-handlers.go:3879`), so a rule with a tag filter yields no target. Pre-existing, but it bounds any completeness claim.
|
||||
- **Sender retries are otherwise clean:** `replicateAll` re-reads current state (`:1517`), so retries carry the current revision; staleness was confined to the ACK callback. No new HEAD protocol — agreed.
|
||||
- **UUID / null / versioned / multi-pool:** `metadataPoolInfos`, `mergedPoolObjectInfo`, `retireReplicaCopies` and the `nullVersionID` normalization (`:136-138`, `:1162-1168`) do carry tag state correctly; I found no additional loss path there beyond R4's clock inversion.
|
||||
|
||||
---
|
||||
|
||||
## 4. Can the proposed coverage establish the scope?
|
||||
|
||||
**Can:** per-hop handler + storage behavior on real erasure disks (ErasureSD and 16-drive), multi-pool merge/retire through `consistencyPools` and the existing tag suite (`cmd/erasure-server-pool-tags_test.go`), sender option construction, and the ACK race with a deterministic interleave.
|
||||
|
||||
**Cannot:** cross-site convergence, real clock skew, real lock contention. Both `ExecObjectLayerAPITest` instances run in one process against one clock. L73's "Not production multi-site acceptance" should be sharpened to say the suite establishes **per-hop** correctness only.
|
||||
|
||||
**Must add:** (a) an untagged-object regression asserting **no** revision is synthesized (R1); (b) the sender-shaped metadata COPY (R3); (c) a DELETE→PUT inversion, by passing an older stamp directly at the storage call (R4); (d) equal-timestamp parity across the versioned and null-version COPY paths; (e) an SSE-KMS metadata COPY case marked blocked-on-R4. Also keep the three assertions inside `TestReviewR5DeleteThenDelayedTagUpdate` separable — once change A lands, the first passes and the later two silently depend on it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
- **Accepted:** §12.1/.2/.3/.5/.6/.7/.8 defect claims; §A generation model; §B ACK removal; §C `encMetadata` reconciliation, storage-recheck reuse, multipart persistence; §D R4 ownership and the no-migration position.
|
||||
- **Non-blocking:** equal-timestamp behavior change, trust asymmetry, ordinary-PUT revision hole, tag-filtered rules, swallowed parse error in the COPY sender.
|
||||
- **Required (blocking):** R1 ModTime fallback scope; R2 forced-replication scope; R3 metadata-directive correction + sender-shaped test; R4 monotonic guard at commit; R5 duplicate-suppression comparison wording.
|
||||
|
||||
Answering L75 directly: §A's existing local timestamp semantics are **not** sufficient without R4. §B's forced metadata synchronization is necessary but **wrongly scoped** (R1, R2); the stale ACK removal is necessary and correctly scoped. §C's duplicate exception is defensible but **under-specified** (R5). And yes — a path can still lose or revive the deletion revision: the DELETE→PUT local inversion (R4), plus the documented SSE-KMS PUT leg and tag-filtered-rule gaps.
|
||||
|
||||
I recommend a v2 addressing R1–R5, then re-review against the new hash. I made no edits.
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"model": "claude-opus-5",
|
||||
"effort": "max",
|
||||
"plan_version": "v2",
|
||||
"plan_sha256": "5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca",
|
||||
"baseline": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a",
|
||||
"actual_assistant_models": [
|
||||
"claude-opus-5"
|
||||
],
|
||||
"session_id": "1723246d-35c6-48de-9693-69f061427fcb",
|
||||
"is_error": false,
|
||||
"modelUsage": {
|
||||
"claude-haiku-4-5-20251001": {
|
||||
"inputTokens": 1578,
|
||||
"outputTokens": 19,
|
||||
"cacheReadInputTokens": 0,
|
||||
"cacheCreationInputTokens": 0,
|
||||
"webSearchRequests": 0,
|
||||
"costUSD": 0.001673,
|
||||
"contextWindow": 200000,
|
||||
"maxOutputTokens": 32000,
|
||||
"thinkingTokens": 0,
|
||||
"canonicalModel": "claude-haiku-4-5",
|
||||
"provider": "firstParty",
|
||||
"costBasis": "list"
|
||||
},
|
||||
"claude-opus-5": {
|
||||
"inputTokens": 28,
|
||||
"outputTokens": 30031,
|
||||
"cacheReadInputTokens": 709817,
|
||||
"cacheCreationInputTokens": 79637,
|
||||
"webSearchRequests": 0,
|
||||
"costUSD": 1.9021934999999999,
|
||||
"contextWindow": 1000000,
|
||||
"maxOutputTokens": 64000,
|
||||
"thinkingTokens": 23251,
|
||||
"canonicalModel": "claude-opus-5",
|
||||
"provider": "firstParty",
|
||||
"costBasis": "list"
|
||||
}
|
||||
},
|
||||
"result": "APPROVE_WITH_NONBLOCKING_NOTES",
|
||||
"blocking_items": 0,
|
||||
"raw_output": "/Users/vonng/tmp/silo-r5-20260915-77ad/opus-v2.jsonl"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
Independent real Opus 5 at effort max follow-up review. Production sources remain exactly 9ebe81c1b3611f9cc73e676b5b741c2be62c467a; no fix implemented. Consensus target: docs/investigations/r5/plan-v2.md sha256 5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca. Read that complete v2 plan, docs/investigations/r5/opus-v1-response.md, and your preserved docs/investigations/r5/opus-v1-review.md. Focus on the five prior blocking items and disposition; do not repeat a broad repository audit. Read precise source as needed to decide.
|
||||
|
||||
R1 accepted (no empty/no-revision fallback). R3 confirmed against pinned SDK; actual SDK wire test now in /Users/vonng/tmp/silo-r5-20260915-77ad/matrix_test.go and /Users/vonng/tmp/silo-r5-20260915-77ad/discussion-baseline.log. R4 accepted plus a uniform multi-pool guard before per-set write, since normal GetObjectInfo/GetObjectNInfo can return primary pool and not merged latest revision. R5 explicit strictly-newer-than-stored. R2 challenged: same nonempty value at T1 and T3 can hide an intervening delete at T2; empty-only forcing would drop re-addition T3 and allow delayed delete. We accept one metadata COPY per explicitly scheduled retry/heal/resync for explicit revisions. Completed queueReplicationHeal returns early unless resync (cmd/bucket-replication.go ~3758); replicateObject requeues only Failed (~1305). No endless background loop is introduced. A new HEAD revision protocol seems more complexity than justified; evaluate the concrete correctness/cost tradeoff. Both equal-empty and equal-nonempty baseline action tests in discussion-baseline.log expose loss.
|
||||
|
||||
Correct one nonblocking claim too: although COPY sender does not forward a public KMS header, destination bucket defaults/globalAutoEncryption apply BEFORE copyDstOpts (object-handlers.go 1425-1435), so COPY can still depend on R4. R4 has independently reproduced this. Plan documents tag-filter eligibility and unversioned content overwrite as pre-existing scope limits; per-hop guarantees only.
|
||||
|
||||
Return APPROVE / APPROVE_WITH_NONBLOCKING_NOTES / REQUEST_CHANGES for this EXACT v2 and hash, with explicit blocker count and per-item resolution. If R2 is still blocking, provide the smallest concrete alternative that passes the X@T1/delete@T2/X@T3/reordered-delete case, not an empty-only suggestion. Do not claim consensus with remaining blockers. Do not edit or attempt Write; only return text. Model identity will be verified from raw assistant metadata.
|
||||
@@ -0,0 +1,43 @@
|
||||
Write is disabled for this task by your instruction, so the review is returned in full as text. No files were created or edited.
|
||||
|
||||
# Independent follow-up review — R5 repair plan v2
|
||||
|
||||
**Reviewer model: `claude-opus-5` (Opus 5), effort max.** Read: `docs/investigations/r5/plan-v2.md` (complete), `opus-v1-response.md`, my preserved `opus-v1-review.md`, plus `/Users/vonng/tmp/silo-r5-20260915-77ad/matrix_test.go` (discussion cases) and `discussion-baseline.log`. Source re-read at `9ebe81c1b` only where an item's disposition depended on it.
|
||||
|
||||
**VERDICT: APPROVE_WITH_NONBLOCKING_NOTES** for `docs/investigations/r5/plan-v2.md`, consensus target sha256 `5a782acf3f285b23d1ae43a73481c4eb772a9a6d917fc5a550ecfc7cbf7446ca`. **Blocking items remaining: 0 (zero).** All five v1 blockers are resolved; R2 I withdraw as a blocker on the evidence below.
|
||||
|
||||
Same binding caveat as v1, unchanged: this session has Glob/Grep/Read only, so I did not execute `sha256sum`. I reviewed the file at that path in this worktree at the stated base and cite its line numbers so findings bind to the hash.
|
||||
|
||||
## Per-item resolution
|
||||
|
||||
| Item | Status | Basis |
|
||||
|---|---|---|
|
||||
| **R1** empty/no-revision ModTime fallback | **Resolved** | Plan L35 is exactly my v1 correction: revision-when-recorded, existing nonempty ModTime fallback retained, empty + no revision sends nothing. §B and §D (L61) are now consistent. |
|
||||
| **R2** forced metadata replication scope | **Resolved — my correction was wrong; I withdraw the blocker** | See below. |
|
||||
| **R3** production COPY request shape | **Resolved, with wire evidence** | `TestTaggingProductionCopyWireShape` (matrix_test.go:436-459) drives the real `minio.Core.CopyObject` against an httptest peer: `metadata-directive="" tagging-directive="REPLACE"` plus the tagging timestamp header (log line 6). L17 now states both shapes and requires covering a peer that sends metadata REPLACE. |
|
||||
| **R4** local revision inversion | **Resolved and correctly strengthened** | L31's uniform multi-pool requirement is necessary, and **my v1 note was wrong** — see correction 2. `TestLocalTaggingCommitCannotRegressRevision` encodes max-across-pools + 1ns in the response and every copy; it fails baseline (log line 9: stamp stays `01:00:00Z`). |
|
||||
| **R5** duplicate-suppression wording | **Resolved** | L51 is explicit: valid source revision *strictly newer than the destination's stored tag revision*, `olderThan` semantics, client preconditions preserved, re-upload cost documented. |
|
||||
|
||||
## R2 adjudication — why I withdraw it
|
||||
|
||||
**My proposed restriction was incorrect.** `TestTaggingRepeatedValueNeedsRevisionDelivery` (matrix_test.go:422-435) runs both rows; baseline returns `replicateNone` for equal `""` **and** for equal `"key=same"` with a revision an hour newer (log lines 2-3). An empty-only condition fixes only the first row, so it does not repair X@T1 / delete@T2 / X@T3 with a reordered delete. The scenario is reachable: `er.PutObjectTags` never touches `fi.ModTime` (`cmd/erasure-object.go:2328-2332`), so the full-copy gate `oi1.ModTime.Unix() != oi2.LastModified.Unix()` (`:975-981`) never fires on tagging-only changes and the value comparison at `:1003` is decisive; concurrent workers for the same object are not serialized by revision, and at `:1600-1625` a `replicateNone` result is force-marked Completed, making the loss permanent and silent.
|
||||
|
||||
**My cost rationale is refuted by source, not merely by assertion.** `queueReplicationHeal` returns at `cmd/bucket-replication.go:3768` for `Completed && VersionPurgeStatus.Empty() && !mustResync()`; `replicateObject` requeues only non-Completed at `:1306`. I also checked the feedback path I would have raised in its place: `mustReplicate` returns an empty decision for an incoming replication request (`:270-272`), so a forced COPY cannot schedule a new event at the destination — no active-active ping-pong. And the blast radius is **narrower than the plan claims**: `ObjectReplicationType` dispatches to `ri.replicateObject` (`:1233-1237`), which never calls `getReplicationAction`, so the extra COPY applies only to Metadata/Heal/ExistingObject types.
|
||||
|
||||
**Concrete tradeoff against a HEAD revision protocol.** The sender already extends the HEAD (`sOpts.Set(xhttp.AmzTagDirective, "ACCESS")`, `:1595`), so the idea is not absurd — but the pinned SDK's `extractObjMetadata` (`minio-go@v7.3.1-0.20260910142817.../utils.go:232-277`) preserves only the whitelist plus `x-amz-meta-`/`X-Minio-Meta-`; any `x-minio-internal-*` response header is discarded. Exposing the revision therefore needs (a) a new target-side response header, in a client-visible namespace or behind an SDK whitelist change, (b) a sender-side read path, and (c) a fallback for peers that do not answer — and that fallback is the forced COPY anyway. Strictly more code, a new cross-version wire contract, and the same worst case. The plan's choice is right; L39 already states the residual cost honestly.
|
||||
|
||||
## Corrections to my own v1 non-blocking claims
|
||||
|
||||
1. **KMS / COPY (as you flagged).** My v1 line — "the metadata-COPY leg is **not** affected" — is wrong. The sender indeed forwards no public SSE header, but `CopyObjectHandler` applies the destination bucket's SSE config and `globalAutoEncryption` to `r.Header` at `cmd/object-handlers.go:1428-1433`, *before* `copyDstOpts` → `putOptsFromReq` → `putOpts` → `putOptsFromHeaders`, whose `crypto.S3KMS.IsRequested(hdr)` branch (`cmd/object-api-options.go:431-461`) returns an ObjectOptions carrying the legal-hold and retention timestamps but **not** `ReplicationSourceTaggingTimestamp`. So COPY does depend on R4 whenever the destination bucket has default KMS or auto-encryption is on. Plan L59 states this correctly; do not adopt my broader exclusion.
|
||||
2. **Multi-pool merge.** My v1 R4 note claimed per-pool stamp differences merge safely via `mergedPoolObjectInfo`. They do not on ordinary reads: `z.GetObjectInfo` → `getLatestObjectInfoWithIdx` (`cmd/erasure-server-pool.go:1121`, `:1032-1072`) returns one pool's `ObjectInfo`, sorted by ModTime with a lowest-index tiebreak — and ModTime is identical across copies for tagging changes. `mergedPoolObjectInfo` is reached only from `replicaObjectInfo` and `updatePoolMetadata` (`cmd/erasure-server-pool-consistency.go:135-147`, `:169-174`). Since the replication sender reads through `GetObjectNInfo`, it can emit a stale primary-pool revision. L31's uniform value is required.
|
||||
|
||||
## Non-blocking notes for v2
|
||||
|
||||
- **Tighten the cost statement** in L39 to Metadata/Heal/ExistingObject types only (`ObjectReplicationType` bypasses the predicate). It makes the accepted cost smaller and the test targets sharper.
|
||||
- **Add a termination regression** asserting `mustReplicate` yields no decision for the incoming forced COPY (`:270-272`). That property, not the scanner gates alone, is what makes the rule terminating under bidirectional configurations.
|
||||
- **Assert ModTime invariance across tagging** (`:2328-2332`). If a future change ever bumped ModTime on tagging, the equal-value skip disappears and the whole cost calculus shifts; a one-line assertion pins the premise.
|
||||
- **Multi-pool guard mechanics.** `z.PutObjectTags` returns `copies[0]`'s result (`cmd/erasure-server-pool.go:3059-3067`) and is bypassed entirely for `SinglePool()` (`:3035-3036`). Keep the guard in `er.PutObjectTags` as the invariant holder, have it advance only when the passed value is not strictly newer than that set's stored value, and have the response report what was actually written. Transient per-set divergence from a direct-to-set write outside the pool lock re-converges on the next z-level write; say so rather than implying it cannot happen.
|
||||
- **`replicateNone` branch side effect.** Revisioned objects now reach `applyAction` instead of the force-Completed path at `:1614-1624`, so a failed COPY becomes Failed + MRF rather than a phantom Completed, and `rinfo.ReplicationAction` flips `none`→`metadata`. Right direction; cover the metrics path once.
|
||||
- **Sender parse asymmetry.** The receiver fails closed on a malformed tag timestamp (`cmd/object-api-options.go:421-424`) while the COPY sender swallows it (`cmd/bucket-replication.go:1695-1700`). L35/L37 covers this; just confirm the COPY call site is in scope, since it is a different function from `putReplicationOpts`.
|
||||
|
||||
I made no edits, launched no agents, and executed nothing. No implementation is proposed or requested here, so I am not calling ExitPlanMode.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user