mirror of
https://github.com/rustfs/rustfs.git
synced 2026-11-11 12:43:54 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8125128d05 | |||
| ff84f2b5bf | |||
| def5418168 | |||
| 85f5cc9ead | |||
| 75292c6224 | |||
| 16e3071f84 | |||
| 04bcb57e6a | |||
| 31e1e42ad7 | |||
| 0e402b2534 | |||
| db39cd5634 | |||
| 89d01aef4f | |||
| 86c3395d07 | |||
| c9743f166e | |||
| 50707a610c | |||
| ba3e0e1546 | |||
| 95e128336d | |||
| 8b7bbdb17d | |||
| 2780bba67b |
@@ -1,43 +1,38 @@
|
||||
---
|
||||
name: rustfs-release-publish
|
||||
description: Publish a RustFS version through Console, release-branch CI, preview acceptance, human confirmation, and final publication. Use only for an explicit release request.
|
||||
description: Publish a RustFS version through frozen Linux candidate acceptance, desktop completion, manifest approval, byte-preserving publication and post-release follow-up. Use only for an explicit release request.
|
||||
---
|
||||
# RustFS Release
|
||||
|
||||
Require the exact final SemVer target before version edits or publication. If unspecified, inspect current releases/tags and ask with concrete candidates; do not guess the channel or bump. A named bump type resolves to an exact version that still needs confirmation. Report the confirmed target verbatim; resolve any later mismatch before proceeding. Independent read-only preflight may continue.
|
||||
Require the exact final SemVer target before version edits or publication. If unspecified, inspect releases/tags and ask with concrete candidates; do not guess the channel or bump. Reuse the user's confirmed target and authorized delivery scope. Independent read-only preflight may continue.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Cargo stores `<target>`, never `-preview.N`. Before publication, installation defaults retain the previous available deliverable. Final and preview binaries embed their own tag names, so version/asset names differ despite identical source.
|
||||
- Use annotated tags without `v`. Preview names are exactly `<target>-preview.<digits>`; choose the next unused iteration after fetching tags. Preview Releases are prereleases, never Latest; publish only versioned assets/exact Docker tags, never latest assets, R2, Helm, moving Docker channels, or installation/banner updates.
|
||||
- `release/<target>` owns the candidate. Retain its history, backport only reviewed fixes, and never merge main wholesale or force-push it. Pin `PREVIEW_HASH` to its exact commit with complete passing CI. Preview and final tags both point there; never tag a later main/branch HEAD or insert another bump.
|
||||
- Phase failures block dependents. Keep preview Releases until final publication and notes generation finish; CI removes Releases while retaining tags. Never move an existing preview or final tag.
|
||||
- Cargo and every candidate executable contain the final product version. `<target>-preview.<run_number>` is an immutable candidate identifier, not a public tag or displayed binary version.
|
||||
- Freeze the exact source, reviewed workflow recipe, toolchain, Console, CLI, dependency lock, previous stable version and validation plan. Newer unrelated main or Console releases do not change those inputs.
|
||||
- Retain `release/<target>` history when a release branch is used; backport only reviewed fixes. Pin `CANDIDATE_SHA` to its exact CI-validated commit. Never force-push a release branch or move a public tag.
|
||||
- Build Linux first. Complete field acceptance before macOS/Windows and packaging. Full certification and final acceptance are required before publication; a partial Linux PASS grants no release authority.
|
||||
- Promote the accepted bytes. Final publication never recompiles binaries or rebuilds packages/images. Infrastructure retries reuse successful artifacts; changed source or inputs require a new candidate and affected acceptance.
|
||||
- Phase failures block dependents. No latest/default-channel update before versioned delivery. Use the [pipeline runbook](../../../docs/operations/release-pipeline.md) for executable entry points, locks and recovery.
|
||||
|
||||
## Read by Phase
|
||||
|
||||
Read only the current phase's procedure; retain completed evidence across turns. Follow root verification, Git/PR, and authorization rules.
|
||||
## Read by phase
|
||||
|
||||
| Phase | Procedure |
|
||||
|---|---|
|
||||
| 0 | Clean tree, fetch main/tags, verify GitHub access, then complete the mandatory [Console release gate](references/console-gate.md). A green Console build alone is insufficient. |
|
||||
| 1–2 | [Prepare/reuse the release candidate and run exact-SHA branch CI](references/release-candidate.md). Source version changes use `rustfs-release-version-bump` with authorized delivery scope. |
|
||||
| 3 | [Verify the preview build, assets, channels, and notes](references/preview-release.md). |
|
||||
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
|
||||
| Confirmation | Complete the gate below and end the turn. |
|
||||
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
|
||||
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website; reconcile main and remove the completed release branch](references/post-release-updates.md) only after complete publication. |
|
||||
| 0 | Clean tree, fetch main/tags, verify access, then complete the mandatory [Console gate](references/console-gate.md). |
|
||||
| 1–2 | [Prepare the exact source and run branch CI](references/release-candidate.md). Version edits use `rustfs-release-version-bump`. |
|
||||
| 3 | [Build and verify the Linux candidate](references/preview-release.md). |
|
||||
| 4–5 | [Run artifact, field, Console and rc acceptance](references/preview-acceptance.md), then complete desktop/package/image/chart validation. |
|
||||
| Confirmation | Report the complete manifest and obtain the confirmation below. |
|
||||
| 6 | [Publish the accepted manifest](references/final-release.md). |
|
||||
| 7 | [Reconcile advisories, milestones, installation references, website and release branch](references/post-release-updates.md). |
|
||||
|
||||
## Human Confirmation
|
||||
## Human confirmation
|
||||
|
||||
After Phases 3–5 pass, recheck that the release branch still equals `PREVIEW_HASH` and that the current Console asset tag/digest match the preview's recorded build inputs. Report the target, branch, preview tag/hash, branch CI run, preview Release URL, accepted Console asset identity/result, and rc matrix. Explicitly ask whether to publish the final tag and end the turn without creating/pushing it.
|
||||
After all acceptance passes, report the target, source SHA, source CI run, Linux and completion run URLs, final manifest SHA-256, accepted Console/CLI identities, compatibility/performance results and backlog report. Explicitly ask whether to publish this exact manifest and end the turn without dispatching publication.
|
||||
|
||||
Only a new affirmative user reply to that exact report authorizes Phase 6. The original release request, earlier confirmation, silence, or automated follow-up does not count. Confirmation binds the target, preview tag, hash, and accepted build inputs; a changed value or repeated/failed acceptance cycle invalidates it. Revalidate and obtain fresh confirmation before continuing.
|
||||
Only a new affirmative reply to that exact report authorizes Phase 6. An earlier general release request, silence or automatic continuation does not count. Reuse confirmation for retries of that same manifest; do not ask again merely because a destination failed. A changed source, input, manifest or acceptance result invalidates it and requires renewed acceptance/confirmation.
|
||||
|
||||
## Recovery
|
||||
## Recovery and reporting
|
||||
|
||||
- Before the final tag exists, land fixes on main, backport to the candidate, verify target versions, and restart branch CI/acceptance. Use a new preview iteration once a preview was tagged; main may advance independently.
|
||||
- If only an external build input changed, record it, create a new preview iteration even when the source SHA is unchanged, and repeat the affected build and artifact acceptance before fresh confirmation. Reuse source checks whose inputs remain valid.
|
||||
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
|
||||
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting unpublished candidate branches or preview tags requires an explicit decision. Completed release branches follow Phase 7 cleanup; keep release tags.
|
||||
|
||||
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; advisory reconciliation results and pending disclosure decisions; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
|
||||
Resume failed infrastructure jobs without rebuilding successful units. Fix product defects on main, backport when needed, and start a new candidate. After a final tag exists, retry the same bytes or use a new patch version; never move the tag. Do not conflate implemented, CI-passed, field-accepted, approved, published and post-release-complete states. Report incomplete work and deviations with evidence; a tag or green build alone is not a completed release.
|
||||
|
||||
@@ -4,7 +4,7 @@ Read during Phase 0, before changing RustFS version files or tags. Follow the pa
|
||||
|
||||
### Console release gate
|
||||
|
||||
Complete this gate before changing any RustFS version file or creating any RustFS tag. RustFS `build.yml` downloads the asset returned by `repos/rustfs/console/releases/latest`, so a successful Console build alone is insufficient.
|
||||
Complete this gate before changing any RustFS version file or creating any RustFS tag. Candidate creation resolves `repos/rustfs/console/releases/latest` once and freezes the exact asset for every build, so a successful Console build alone is insufficient. After freezing, later Console releases do not silently change or invalidate the accepted inputs.
|
||||
|
||||
1. Read the latest published Console tag and compare it with Console `main`:
|
||||
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
# Final Release Publication
|
||||
# Final release publication
|
||||
|
||||
Read only after fresh user confirmation of the preview acceptance report. The [parent confirmation gate](../SKILL.md#human-confirmation) applies.
|
||||
Read only after fresh confirmation of the complete candidate report. The [parent confirmation gate](../SKILL.md#human-confirmation) applies.
|
||||
|
||||
## Phase 6 — Publish the final tag on the validated commit
|
||||
Dispatch `release-publish.yml` on main with the successful completion run ID and approved `release-manifest.json` SHA-256. Do not create the tag manually. The workflow revalidates source, bytes and trusted certification, then creates the annotated final tag at `CANDIDATE_SHA`. A recipe change fails closed and requires a new candidate; unrelated main changes do not require a rebuild.
|
||||
|
||||
No second source version bump. Before creating the final tag, recheck the remote `release/<target>` HEAD equals `PREVIEW_HASH` and the current Console release tag/asset digest still match the accepted preview inputs. If either changed after confirmation, the approval no longer applies; validate the changed inputs with a new preview and obtain fresh confirmation. The final tag goes on the exact commit the preview validated:
|
||||
Require both versioned publication and channel publication jobs to succeed. Verify the final tag's commit and manifest identity; all six platform ZIPs and compatibility aliases; DEB/RPM, checksums, SBOM, provenance and evidence bundle; both multi-architecture image variants in Docker Hub, GHCR and Quay; and the accepted Helm chart in the chart repository. Download a host-compatible final artifact and confirm its digest is exactly the accepted candidate digest and its version/source output agrees.
|
||||
|
||||
```bash
|
||||
git fetch origin --tags
|
||||
git rev-parse "<preview-tag>^{commit}" # must equal PREVIEW_HASH — abort if not
|
||||
git tag -a "<target>" -m "Release <target>" "$PREVIEW_HASH"
|
||||
git push origin "<target>"
|
||||
```
|
||||
For stable releases verify GitHub Latest, R2 download aliases and `latest.json` unless a newer stable release already owns those channels. Prereleases must remain GitHub prereleases and leave stable pointers unchanged. Verify generated release notes compare against the frozen previous stable release, excluding internal previews.
|
||||
|
||||
- CI rebuilds from the same source with a different tag name, so the binary self-reports `<target>`. Other inputs can change: `build.yml` reads Console `releases/latest` during each build. Compare the actual Console tag, URL, and verified digest in every final build job with the accepted preview record, and inspect available build provenance for other relevant changes. A pre-tag check cannot prevent a later input change. On a mismatch or missing evidence, repeat affected acceptance against the final artifacts and report the limitation; failed acceptance leaves release validation incomplete even if publication already succeeded. Never move the final tag to conceal a failure.
|
||||
- Verify the final tag's complete publication path: all matrix and release jobs green; `gh release view "<target>"` shows the full versioned and `-latest` asset set plus checksums, SBOM, and provenance; Docker and Helm workflows succeed; `latest.json` points to `<target>`. A stable target must have `isPrerelease=false` and `isLatest=true`. An alpha/beta/rc target must have `isPrerelease=true`; GitHub does not permit prereleases to be Latest, but the project `latest.json` still advances to the final non-preview target.
|
||||
- Verify the final Release body contains `## What's Changed` and a Full Changelog link. When `PREVIOUS_DELIVERABLE` exists, the link MUST be `https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<target>` and the baseline MUST equal the preview Release baseline; for example, both `1.0.0-beta.12-preview.1` and `1.0.0-beta.12` compare from `1.0.0-beta.11`.
|
||||
- Verify the preview cleanup: `cleanup-preview-releases` must succeed and `gh release view "<preview-tag>"` must then report `release not found` for every preview iteration of this target. Each surviving preview tag must still resolve to its own recorded commit; the accepted preview resolves to `PREVIEW_HASH`. If the job failed, delete the leftover Releases manually with `gh release delete "<preview-tag>" --yes` and report it.
|
||||
- Verify a host-compatible final artifact's checksum and `./rustfs --version`; it must report `<target>` and the expected source SHA. This identity check does not replace affected acceptance when build inputs changed.
|
||||
On infrastructure failure retry the same workflow/seal; matching destinations are reused. Never clobber an asset, force a tag, rebuild the image or silently accept a changed digest. A source fix after the final tag requires a new patch version. Cross-registry writes are not atomic: report the unfinished destination rather than claiming publication complete.
|
||||
|
||||
Never pass `--cleanup-tag` when removing preview Releases. Remove them manually only if `cleanup-preview-releases` failed, after final publication and notes generation. Record deleted Releases and surviving tags.
|
||||
New candidates create no preview Releases to clean up. For a legacy preview release, preserve its tag and remove its Release only after final delivery and notes are verified. Do not pass `--cleanup-tag`. Complete the [post-release follow-up](post-release-updates.md) separately.
|
||||
|
||||
@@ -24,7 +24,7 @@ Reconcile advisories after every completed final release, including Console-only
|
||||
|
||||
1. Re-read the published deliverable state before edits or a retry. If a newer release has superseded this target, do not downgrade installation references or the website announcement. Report the superseding release and leave those defaults intact.
|
||||
2. Invoke `rustfs-release-version-bump` with stage `post-release`, the exact target, and the authorized delivery scope. Prepare the follow-up on current main; do not edit or move either validated release tag. Cargo versions may already be preparing the next target and must remain untouched.
|
||||
3. Verify the exact Docker image manifest, Release download links, RPM source archive, and Helm repository entry before publishing the installation PR. The chart workflow overrides chart/app versions from the final tag, pins the source checkout to that release, and waits for its default Docker image before packaging.
|
||||
3. Verify the exact Docker image manifest, Release download links, RPM source archive, and Helm repository entry before publishing the installation PR. The chart is packaged and tested before sealing; publication uploads those same bytes only after versioned Docker images are available.
|
||||
4. Report the installation PR and its merge state separately from the completed release. Reuse existing merge authorization and repository checks; do not infer new merge authority from this reference.
|
||||
|
||||
## rustfs.com top banner
|
||||
@@ -54,4 +54,4 @@ Use the existing announcement configuration from [website PR #112](https://githu
|
||||
1. Compare the exact published tag with current main. Audit every release-side commit since their merge base, including inherited backports. An ahead count is a history difference, not proof of missing changes. Use ancestry and patch equivalence, then inspect current code and regression evidence for squashed, adapted, superseded, or reverted changes. Record each commit as present, superseded, release-only, or missing; a positive `git cherry` result alone does not prove a missing fix.
|
||||
2. Bring missing shared changes to main through a reviewed PR and the required checks before closing the follow-up. Preserve main's later changes; decide version bumps and pinned Console configuration separately. If a history merge is needed, use a merge commit and verify the release commit is an ancestor of main; squash does not establish that ancestry. Do not merge main into the published release branch or move its tags. Report unresolved changes instead of claiming reconciliation is complete.
|
||||
3. After publication and authorized follow-ups are complete, delete the remote `release/<target>` branch. Immediately before deletion, verify its HEAD still equals the published tag's commit and no open PR targets or originates from it. Preserve a branch with newer work or open PRs and report the blocker. Use a deletion with an expected-old-SHA check to avoid removing a concurrently advanced branch.
|
||||
4. Verify the remote branch is absent and the final and preview tags still resolve to their recorded commits. Remove a local release branch only if it is task-owned, unchecked out, and has no unpreserved work. Record cleanup separately from artifact publication; an explicitly authorized deletion before reconciliation does not make unresolved main changes complete.
|
||||
4. Verify the remote branch is absent and the final tag and any legacy preview tags still resolve to their recorded commits. Remove a local release branch only if it is task-owned, unchecked out, and has no unpreserved work. Record cleanup separately from artifact publication; an explicitly authorized deletion before reconciliation does not make unresolved main changes complete.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Preview Artifact Acceptance
|
||||
# Candidate Artifact Acceptance
|
||||
|
||||
Read after Phase 3 succeeds. Complete every check below before the parent skill's manual confirmation gate. These checks cover the downloaded artifact, embedded Console, and latest rc client.
|
||||
Read after Phase 3 succeeds. Complete every check below before the parent skill's manual confirmation gate. These checks cover the downloaded artifact, embedded Console, and the rc release frozen in the candidate inputs.
|
||||
|
||||
## Phase 4 — Run the artifact locally, verify the console
|
||||
|
||||
Use a unique task-owned scratch directory. Select the versioned archive matching the host OS, architecture, and runtime, download its checksum file, and verify the archive before extraction. Do not substitute a different build when a matching artifact is unavailable; report that acceptance gap. The shell example below assumes the verified executable is at `RUSTFS_BINARY` (`rustfs.exe` on Windows), `SCRATCH` is the task directory, and `S3_PORT` and `CONSOLE_PORT` are distinct available ports. Use equivalent native commands when the shell differs.
|
||||
Use a unique task-owned scratch directory. Select the versioned archive matching the acceptance host, and verify it against the sealed manifest before extraction. Before desktop completion, use the Linux acceptance environment rather than rebuilding for the local desktop. Do not substitute a different build when a matching artifact is unavailable; report that acceptance gap. The shell example below assumes the verified executable is at `RUSTFS_BINARY` (`rustfs.exe` on Windows), `SCRATCH` is the task directory, and `S3_PORT` and `CONSOLE_PORT` are distinct available ports. Use equivalent native commands when the shell differs.
|
||||
|
||||
Bind both listeners to loopback. Choose task-only credentials, set loopback proxy exclusions for the server/client processes, and record the server PID. A port probe does not reserve a port; retry startup with fresh ports on a bind conflict rather than connecting to an existing service.
|
||||
|
||||
@@ -24,16 +24,16 @@ PREVIEW_SERVER_PID=$!
|
||||
|
||||
Checks (all must pass):
|
||||
|
||||
- `"$RUSTFS_BINARY" --version` reports the preview tag name and the short SHA of `PREVIEW_HASH`. Reporting `<target>` without the `-preview.N` suffix means the build did not embed the tag — treat as FAIL and investigate before proceeding.
|
||||
- `"$RUSTFS_BINARY" --version` reports the exact final `<target>` and full `CANDIDATE_SHA`. A preview suffix, another version or another source is a failure.
|
||||
- Wait with a bounded readiness deadline while checking the recorded server process remains alive; `curl --noproxy '*' -fsS "$S3_ENDPOINT/health/ready"` must return ready from this process's listener.
|
||||
- Startup log shows the embedded console being served (this was the regression that `fix(release): require embedded console assets` guards).
|
||||
- Open `CONSOLE_ENDPOINT` in the browser and log in with the task credentials. The dashboard must render without JS console errors; create a bucket, upload a file, download it back (byte-identical), and delete the object and bucket. Keep the server running for Phase 5.
|
||||
|
||||
## Phase 5 — Validate with the latest rc client
|
||||
## Phase 5 — Validate with the pinned rc client
|
||||
|
||||
`rc` is the RustFS CLI client from <https://github.com/rustfs/cli>.
|
||||
|
||||
- Resolve the latest release with `gh api repos/rustfs/cli/releases/latest --jq .tag_name`; verify the local client's version or download and verify a host-compatible client into the task directory. Set `RC_BIN` to that executable and record its version/digest. Avoid changing the user's installed client for this check.
|
||||
- Read the CLI release and asset digests from `inputs.json`; verify the local client's version or download a host-compatible asset from that exact release into the task directory. If the host is a desktop, resolve its asset within the same pinned CLI release and record the digest; do not independently follow `latest`. Set `RC_BIN` to that executable and record its version/digest. Avoid changing the user's installed client for this check.
|
||||
- Inspect the client's current help for an isolated configuration option and use it when supported. Otherwise choose a unique `PREVIEW_ALIAS` and confirm it is absent before creating it; never overwrite an existing alias. Use task-owned bucket/user names and input/output files. The names below are safe only inside the newly started isolated server.
|
||||
- Point the client at `S3_ENDPOINT` with loopback proxy exclusions and run the command matrix, recording PASS/FAIL per command:
|
||||
|
||||
@@ -60,3 +60,9 @@ cmp <local-file> "$SCRATCH/out"
|
||||
```
|
||||
|
||||
- Any FAIL blocks acceptance. On success or failure, stop only the recorded task process and remove only task-created aliases/data after retaining the needed failure evidence. Do not erase an existing client configuration or unrelated server data.
|
||||
|
||||
## Field acceptance and desktop completion
|
||||
|
||||
Before accepting the Linux manifest, record Scanner/Heal, multi-pool, memory, major features, high-concurrency 503, performance and previous-version integrity/upgrade results in backlog. Preserve the release-specific agreed limits and baseline; a PASS label without artifact identities or test results does not satisfy acceptance.
|
||||
|
||||
Dispatch `release-complete.yml` only after that report passes, using the exact Linux manifest digest and backlog URL. Require native macOS/Windows checks, package extraction, image startup/scans, chart tests and complete certification to pass. Complete any desktop Console/client checks applicable to the release against these same archives. Record the completion run and final manifest SHA-256 before entering the parent confirmation gate. Partial Linux acceptance does not authorize final publication.
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
# Preview Release Verification
|
||||
# Linux candidate verification
|
||||
|
||||
Read for Phase 3 after the candidate passed exact-SHA CI. The [parent release invariants](../SKILL.md) apply.
|
||||
Read for Phase 3 after the source passed exact-SHA CI. The [parent invariants](../SKILL.md) apply; commands and recovery live in the [pipeline runbook](../../../../docs/operations/release-pipeline.md).
|
||||
|
||||
## Preview channels
|
||||
Dispatch `release-candidate.yml` on main with the confirmed final version and `CANDIDATE_SHA`. Record the run ID, frozen `inputs.json`, every Linux archive digest and `linux-manifest.json` SHA-256. Require the four Linux builds, native startup/S3 checks, previous-release upgrade matrix and Linux seal to succeed. Missing, skipped, cancelled or failed required work is incomplete acceptance.
|
||||
|
||||
- Use `<target>-preview.N` for every target, e.g. `1.0.0-beta.10-preview.3` or `1.1.0-preview.1`.
|
||||
- The canonical suffix is exactly `-preview.<digits>`. `build.yml` recognizes it before alpha/beta/rc classification and routes it to the preview-only path; any other tag containing `-preview` fails closed instead of being treated as a release.
|
||||
- A preview Release MUST be published with `isPrerelease=true` and `isLatest=false`. Any `*-latest` preview asset or preview-triggered `latest.json`, R2, Helm, or moving Docker channel tag is a pipeline failure. Docker images may use the exact preview version tag, including its existing variant suffixes.
|
||||
- Preview Releases are cleaned up by the `cleanup-preview-releases` job after `publish-release` succeeds for the deliverable tag. It deletes every Release whose tag is exactly `<target>-preview.<digits>` and never passes `--cleanup-tag`, so the tags survive.
|
||||
Verify the frozen Console identity equals the one accepted in Phase 0. Every matrix job consumes that same digest and exact toolchain. The binary must report the final product version and source commit; its candidate ID is separate. No public tag, Release, registry tag, R2 release path, Helm repository or latest pointer should change during this stage.
|
||||
|
||||
## Phase 3 — CI and preview Release verification
|
||||
|
||||
- Find and watch the tag build: `gh run list --workflow build.yml --branch "<preview-tag>" --limit 1` then `gh run watch <run-id>`. Every build matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64).
|
||||
- Confirm the preview tag resolves to the Phase 2 CI-validated `PREVIEW_HASH`. If the release branch advances during acceptance, repeat Phases 2–5 with a new preview tag.
|
||||
- Confirm the Release publication jobs (`create-release`, `upload-release-assets`, and `publish-release`) succeed while `update-latest-version` is skipped.
|
||||
- Record the inputs actually consumed by each build job: source SHA and the embedded Console release tag, asset URL, and verified SHA256 from the download logs. The workflow reads Console `releases/latest` separately for each build; confirm the matrix used the Console asset accepted in Phase 0. A missing identity or differing asset blocks acceptance until resolved. Retain available toolchain and build provenance with the artifact checksums; the same source SHA alone does not establish equivalent builds.
|
||||
- Verify `gh release view "<preview-tag>" --json isPrerelease,assets,url`: `isPrerelease` must be `true`, and the Release must contain all 6 versioned platform zips, checksums, SBOM, and provenance with no `-latest` assets. Confirm `gh api repos/{owner}/{repo}/releases/latest --jq .tag_name` does not return `<preview-tag>`.
|
||||
- Record `PREVIOUS_DELIVERABLE`, selected from published Releases by `publishedAt` after excluding the current tag and every `-preview.N` tag. Verify `gh release view "<preview-tag>" --json body --jq .body` contains `## What's Changed` and, when `PREVIOUS_DELIVERABLE` exists, `**Full Changelog**: https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<preview-tag>`. For a repository with no previous deliverable, verify a Full Changelog link exists and record the GitHub baseline fallback.
|
||||
- Confirm Helm is skipped. If preview Docker images are published, confirm they use only exact preview tags (and variant suffixes), with no `latest`, `alpha`, `beta`, or `rc` channel updates. Preview validation covers the built RustFS binaries, embedded console, and rc compatibility; it does not authorize advancing any installation default.
|
||||
|
||||
## Release notes
|
||||
|
||||
- When a previous deliverable exists, GitHub Release notes for the preview and final tags MUST use it as their shared comparison baseline: the most recently published non-preview Release before the target. Internal `-preview.N` Releases are explicitly excluded from that selection, even when they point at the same commit as the final tag — cleanup runs after the notes are generated, so the preview Release is still present and would otherwise be picked as the baseline. If no previous deliverable exists, omit `previous_tag_name` and record that GitHub's default baseline fallback was used.
|
||||
- Generated Release notes carry a workflow-management marker so retries can repair them. Before manually curating a generated body, remove that marker; unmarked non-placeholder notes are preserved by later workflow runs.
|
||||
A green Linux run is ready for field acceptance, not publication. The partial certification must have `publish_allowed: false`. Retain the exact Linux manifest for the next stage; a new source/build-input candidate must not inherit the old approval. A newer unrelated main commit does not invalidate this evidence.
|
||||
|
||||
@@ -16,22 +16,13 @@ Read for Phases 1–2. The [parent release invariants](../SKILL.md) and Console
|
||||
- Only when neither the release branch, an unfinished preview tag, nor current main already has `<target>`: if main is at a later version, select and verify a historical commit containing `<target>` rather than downgrading main; block if none exists. Otherwise invoke the `rustfs-release-version-bump` skill with stage `prepare`, the final `<target>` (NOT a preview version), and full GitHub flow (commit/push/PR).
|
||||
- If a bump PR was needed, get it merged into main and record its merge commit. For a new release branch when main already has `<target>`, identify the main commit that introduced those versions. Phase 2 chooses a release candidate containing that commit; an existing release branch keeps its own history and a newer main HEAD is not required.
|
||||
|
||||
Do not wait for the moving latest main HEAD to become green. The release-branch CI in Phase 2 validates the chosen commit before a preview tag is created.
|
||||
Do not wait for the moving latest main HEAD to become green. The release-branch CI in Phase 2 validates the chosen commit before a candidate build is dispatched.
|
||||
|
||||
## Phase 2 — Pin and validate the release candidate, then publish the preview tag
|
||||
## Phase 2 — Pin and validate the release source
|
||||
|
||||
- Use `release/<target>`. On the first attempt, create it from a selected main commit containing the version bump and intended changes; it need not be the latest main HEAD. On a restart, retain the existing branch and backport only reviewed fixes from main. Verify the branch's Cargo versions still equal `<target>`. If adopting an existing preview, run this branch CI before continuing its acceptance; reuse the tag only when it points to the validated SHA.
|
||||
- The current `ci.yml` runs automatically on main pushes, not release-branch pushes. Dispatch it explicitly with `gh workflow run ci.yml --ref "release/<target>"`. Locate that dispatch run, verify its `head_sha` equals the release branch's remote HEAD, and require the full expected CI job set to pass, including `Quick Checks`, `Test and Lint`, and `End-to-End Tests (full merge gate)`. Treat a missing or skipped required lane as incomplete. A successful PR check or CI run for a different SHA does not qualify. If the branch advances or a run is cancelled, validate the new SHA again. Do not tag a candidate with failing or incomplete CI.
|
||||
- Use `release/<target>`. On the first attempt, create it from a selected main commit containing the version bump and intended changes; it need not be the latest main HEAD. On a restart, retain the existing branch and backport only reviewed fixes from main. Verify the branch's Cargo versions still equal `<target>`. A legacy preview can anchor source selection, but its tag and old artifacts do not constitute a candidate in the new protocol; freeze and verify a new candidate after the release recipe is aligned.
|
||||
- The current `ci.yml` runs automatically on main pushes, not release-branch pushes. Dispatch it explicitly with `gh workflow run ci.yml --ref "release/<target>"`. Locate that dispatch run, verify its `head_sha` equals the release branch's remote HEAD, and require the full expected CI job set to pass, including `Quick Checks`, `Test and Lint`, and `End-to-End Tests (full merge gate)`. Treat a missing or skipped required lane as incomplete. A successful PR check or CI run for a different SHA does not qualify. If the branch advances or a run is cancelled, validate the new SHA again. Do not dispatch a candidate with failing or incomplete CI.
|
||||
- Prefer protecting `release/*` against force pushes and deletion and requiring reviewed backport PRs. Check live rulesets rather than assuming the main ruleset covers release branches; report a missing release-branch rule as a gap.
|
||||
- Set `PREVIEW_HASH` to the CI-validated release-branch commit and recheck the remote branch HEAD immediately before tagging. Report the branch, commit, and CI run URL; do not derive `PREVIEW_HASH` from a later `origin/main` fetch. When adopting an existing preview tag at this SHA, skip tag creation and continue Phase 3.
|
||||
|
||||
```bash
|
||||
git tag -a "<preview-tag>" -m "Release <preview-tag>" "$PREVIEW_HASH"
|
||||
git push origin "<preview-tag>"
|
||||
```
|
||||
|
||||
Pushing the tag triggers `.github/workflows/build.yml` ("Build and Release"); `docker.yml` chains off it via `workflow_run`.
|
||||
|
||||
The preview run builds versioned artifacts and publishes them in a GitHub prerelease. Docker may publish exact preview image tags. Latest-channel, R2, and Helm publication must be skipped; installation references and the website announcement stay unchanged.
|
||||
|
||||
On a restart after a backport, re-run release-branch CI before setting the new `PREVIEW_HASH`. Use the next unused preview iteration when the validated commit differs from an existing preview tag; never move an existing tag.
|
||||
- Set `CANDIDATE_SHA` to the CI-validated release-branch commit. Record the branch, exact commit and CI run URL. The new candidate protocol starts from a commit whose release recipe matches the reviewed main dispatcher; backport the recipe before freezing an older release branch if needed.
|
||||
- Dispatch the Linux candidate as described in [candidate verification](preview-release.md). Do not create a preview or final tag here. The final executable already contains `<target>`; publication later promotes those same bytes.
|
||||
- After a source or build-input change, run the affected source checks and create a new candidate. Reuse the frozen candidate after an infrastructure failure. Do not restart acceptance for unrelated main advances.
|
||||
|
||||
@@ -4,18 +4,18 @@ description: Prepare an exact RustFS Cargo version or align installation referen
|
||||
---
|
||||
# Release Version Files
|
||||
|
||||
Require an exact target; ask if missing or ambiguous. Reject targets containing `-preview`: those are tag-only. Infer `post-release` only from an explicit request or parent workflow; otherwise use `prepare`. Delivery defaults to local edit/verify; commit, push, PR, and merge follow existing authorization and [Git rules](../../references/pull-requests.md).
|
||||
Require an exact target; ask if missing or ambiguous. Reject targets containing `-preview`: those identify candidates, not product versions or new public tags. Infer `post-release` only from an explicit request or parent workflow; otherwise use `prepare`. Delivery defaults to local edit/verify; commit, push, PR, and merge follow existing authorization and [Git rules](../../references/pull-requests.md).
|
||||
|
||||
| Stage | Files and constraints |
|
||||
|---|---|
|
||||
| `prepare` | Only `Cargo.toml` and `Cargo.lock`: workspace package, internal dependency, and workspace member versions become `<target>`. External dependencies stay unchanged. |
|
||||
| `post-release` | Only `README.md`, `README_ZH.md`, `flake.nix`, `helm/rustfs/Chart.yaml`, and `rustfs.spec`. Cargo may already target the next release; leave it untouched. |
|
||||
|
||||
Before installation edits, verify the exact non-preview Release, downloadable assets/source archive, pullable `rustfs/rustfs:<target>` image manifest, and published Helm chart. Block on missing artifacts. Do not downgrade defaults if a newer deliverable superseded the target. Preview preparation retains the previous published installation references; follow-up commits never replace either validated tag.
|
||||
Before installation edits, verify the exact non-preview Release, downloadable assets/source archive, pullable `rustfs/rustfs:<target>` image manifest, and published Helm chart. Block on missing artifacts. Do not downgrade defaults if a newer deliverable superseded the target. Candidate preparation retains the previous published installation references; follow-up commits never replace validated tags or sealed artifacts.
|
||||
|
||||
Packaging policy:
|
||||
|
||||
- Docker image tags have no `v` prefix. Derive chart/app versions with `scripts/helm_chart_version.sh <target>` (`beta.N -> 0.N.0`; other versions unchanged). Helm CI derives versions from the final tag, so no early chart bump is required.
|
||||
- Docker image tags have no `v` prefix. Derive chart/app versions with `scripts/helm_chart_version.sh <target>`; both equal the final product SemVer, including prerelease suffixes. Helm CI uses the frozen candidate version before sealing, so no early installation chart bump is required. Previously published chart versions remain unchanged.
|
||||
- RPM `Version` is numeric; `Release` is the prerelease suffix or `1` for stable. Expanded `Source0` and the unpack directory must resolve to the exact target including its suffix. Add the changelog entry using current git identity, date, and target; never copy an example identity/date.
|
||||
- Changing these mappings requires explicit confirmation.
|
||||
|
||||
|
||||
@@ -71,10 +71,15 @@ s3s-footprint-check: ## Check the s3s dependency footprint ratchet stays frozen
|
||||
./scripts/check_s3s_footprint.sh
|
||||
|
||||
.PHONY: test-count-ratchet-check
|
||||
test-count-ratchet-check: ## Check no workspace crate lost tests or gained #[ignore] against the committed baseline
|
||||
test-count-ratchet-check: ## Check no workspace crate fell below its test floor or rose above its #[ignore] ceiling in the committed baseline
|
||||
@echo "🧪 Checking per-crate test-count ratchet..."
|
||||
./scripts/check_test_count_ratchet.sh
|
||||
|
||||
.PHONY: test-seams-check
|
||||
test-seams-check: ## Check tests outside an operation's owner call its use-case only through rustfs/src/app/test_seams
|
||||
@echo "🧪 Checking cross-owner test seams..."
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_seams.py
|
||||
|
||||
.PHONY: fips-wording-check
|
||||
fips-wording-check: ## Check docs and crates/kms do not over-claim crypto capabilities
|
||||
@echo "📣 Checking cryptographic capability wording guard..."
|
||||
|
||||
@@ -20,11 +20,11 @@ planning-docs-check: ## Check that no planning-type documents are committed
|
||||
./scripts/check_no_planning_docs.sh
|
||||
|
||||
.PHONY: pre-commit
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check test-seams-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
@echo "✅ All pre-commit checks passed!"
|
||||
|
||||
.PHONY: pre-pr
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check test-seams-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
@echo "✅ All pre-PR checks passed!"
|
||||
|
||||
.PHONY: dev-check
|
||||
|
||||
@@ -22,6 +22,14 @@ TEST_THREADS ?= 1
|
||||
# differ from CI and .config/nextest.toml test-groups will NOT apply.
|
||||
|
||||
.PHONY: script-tests
|
||||
.PHONY: release-package-tests
|
||||
release-package-tests: ## Run release package integration on Linux with fpm, dpkg and rpm installed
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_packages.py
|
||||
|
||||
.PHONY: release-image-tests
|
||||
release-image-tests: ## Run OCI integration with Docker, Go and skopeo; set RELEASE_IMAGE_VARIANT and RELEASE_IMAGE_OUTPUT
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_images.py
|
||||
|
||||
script-tests: ## Run shell script tests
|
||||
@echo "Running script tests..."
|
||||
trap 'status=$$?; printf "ERROR: script-tests failed (exit %s): %s\n" "$$status" "$$BASH_COMMAND" >&2; exit "$$status"' ERR
|
||||
@@ -53,12 +61,17 @@ script-tests: ## Run shell script tests
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_check_doc_paths.py
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
./scripts/check_s3s_footprint.sh --self-test
|
||||
./scripts/test_check_test_count_ratchet.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/s3_error_to_s3_types.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_lifecycle_transition_api_test.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_seams.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_download_release_asset.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_reference_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_btf_prototypes.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) -m unittest discover -s scripts/release -p "test_*.py"
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_migration_gate_evidence.py
|
||||
@@ -79,8 +92,10 @@ script-tests: ## Run shell script tests
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_validation_runner.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_retain_validation_evidence.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_timing_report.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_junit_diff.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_runner_tools.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_reference_parity.py
|
||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Pinned RustFS release used as the "reference" target of parity lanes
|
||||
# (.github/workflows/reference-parity.yml). This is the single place that names
|
||||
# the reference release; .github/actions/reference-binary resolves, downloads and
|
||||
# verifies it, and fails on a missing pin, a missing asset or a digest mismatch.
|
||||
#
|
||||
# Bumping the reference is a reviewed event: every per-test difference between
|
||||
# the candidate and the reference is measured against this exact build.
|
||||
#
|
||||
# Each sha256 is the value published in the release's SHA256SUMS asset, which
|
||||
# matches the digest GitHub reports for the same asset. The action checks both.
|
||||
|
||||
schema = 1
|
||||
repository = "rustfs/rustfs"
|
||||
version = "1.0.5"
|
||||
tag = "1.0.5"
|
||||
|
||||
[platforms.linux-x86_64-gnu]
|
||||
asset = "rustfs-linux-x86_64-gnu-v1.0.5.zip"
|
||||
sha256 = "5addae2f74756bf80104d0975e6347cda843bc2daffdfcd1f0cb80118b95cbd0"
|
||||
executable = "rustfs"
|
||||
@@ -0,0 +1,24 @@
|
||||
# Targets of the s3-tests reference parity lane (.github/workflows/reference-parity.yml).
|
||||
#
|
||||
# Exactly one target uses `binary = "reference"` (the release pinned in
|
||||
# .config/reference-binary.toml). Every other target uses `binary = "candidate"`
|
||||
# (the binary built from the branch under test) and is compared per node id
|
||||
# against the reference. All targets run the same harness, test list and server
|
||||
# configuration; `env` adds only the target's own RUSTFS_* settings.
|
||||
#
|
||||
# Adding a target is one more [[targets]] table, for example:
|
||||
#
|
||||
# [[targets]]
|
||||
# name = "candidate-gateway"
|
||||
# binary = "candidate"
|
||||
# env = { RUSTFS_S3_STACK = "gateway" }
|
||||
|
||||
schema = 1
|
||||
|
||||
[[targets]]
|
||||
name = "reference"
|
||||
binary = "reference"
|
||||
|
||||
[[targets]]
|
||||
name = "candidate-legacy"
|
||||
binary = "candidate"
|
||||
@@ -0,0 +1,12 @@
|
||||
# Tolerated per-test differences of the s3-tests reference parity lane.
|
||||
#
|
||||
# One entry per line, four fields separated by " | ":
|
||||
#
|
||||
# <target> | <pytest node id> | <tracking issue URL> | <reason>
|
||||
#
|
||||
# <target> is a candidate name from .config/reference-parity-targets.toml, the
|
||||
# node id is exactly as the lane prints it (s3tests/functional/test_s3.py::...),
|
||||
# and the issue URL is https://github.com/rustfs/<repo>/issues/<number>.
|
||||
#
|
||||
# Same semantics as scripts/junit_diff.py --allow: an entry whose difference no
|
||||
# longer occurs is stale and fails the lane, so remove it when it is fixed.
|
||||
@@ -1,44 +1,93 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"plan_id": "rustfs-release-validation",
|
||||
"description": "Versioned certification plan for every public RustFS release exit (rustfs/backlog#2662, R01). preview, prerelease and stable all require this explicit plan. Scripts/release/release_gate.py fail-closes publication on any missing, duplicate, stale, substituted or non-PASS required item.",
|
||||
"release_types": ["preview", "prerelease", "stable"],
|
||||
"description": "Certification for immutable candidates. All shipped platforms require native identity and startup; stable and prerelease require the pinned upgrade matrix. Linux-only acceptance never authorizes publication.",
|
||||
"release_types": [
|
||||
"preview",
|
||||
"prerelease",
|
||||
"stable"
|
||||
],
|
||||
"targets": [
|
||||
{"os": "linux", "arch": "x86_64", "abi": "musl"},
|
||||
{"os": "linux", "arch": "aarch64", "abi": "musl"},
|
||||
{"os": "linux", "arch": "x86_64", "abi": "gnu"},
|
||||
{"os": "linux", "arch": "aarch64", "abi": "gnu"},
|
||||
{"os": "macos", "arch": "aarch64", "abi": "none"},
|
||||
{"os": "windows", "arch": "x86_64", "abi": "msvc"}
|
||||
{
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"abi": "musl"
|
||||
},
|
||||
{
|
||||
"os": "linux",
|
||||
"arch": "aarch64",
|
||||
"abi": "musl"
|
||||
},
|
||||
{
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
{
|
||||
"os": "linux",
|
||||
"arch": "aarch64",
|
||||
"abi": "gnu"
|
||||
},
|
||||
{
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"abi": "none"
|
||||
},
|
||||
{
|
||||
"os": "windows",
|
||||
"arch": "x86_64",
|
||||
"abi": "msvc"
|
||||
}
|
||||
],
|
||||
"targets_basis": "Platform matrix of .github/workflows/build.yml (prepare-platform-matrix). gnu and macOS targets build with the pyroscope feature; musl and Windows targets build with no extra features.",
|
||||
"features": ["pyroscope"],
|
||||
"trusted_workflows": [
|
||||
{"repository": "rustfs/s3chaos", "workflow_id": "release-gate.yml"}
|
||||
"features": [
|
||||
"pyroscope"
|
||||
],
|
||||
"trusted_workflows_basis": "The shared evidence envelope producer lives in rustfs/s3chaos (G01, fault/release_gate.rs) and its plan/live split workflow is .github/workflows/release-gate.yml there. Only results from these identities may enter a release certification; PR-uploaded JSON never gains release authority. R02 must extend this list when additional verifier workflows land, never widen it to untrusted sources.",
|
||||
"trusted_workflows": [
|
||||
{
|
||||
"repository": "rustfs/rustfs",
|
||||
"workflow_id": "release-candidate.yml"
|
||||
},
|
||||
{
|
||||
"repository": "rustfs/rustfs",
|
||||
"workflow_id": "release-complete.yml"
|
||||
}
|
||||
],
|
||||
"trusted_workflows_basis": "Receipts are produced by the reviewed main workflows and fetched by exact run ID with GitHub artifact digest verification. Candidate inputs freeze the harness commit separately from product source.",
|
||||
"lanes": [
|
||||
{
|
||||
"lane": "identity",
|
||||
"harness_repository": "rustfs/s3chaos",
|
||||
"harness_sha": "9a48f55c1d6d58ce63a8fb1ea7317e024f32f11d",
|
||||
"harness_basis": "Fixed G01 evidence-envelope producer revision (rustfs/s3chaos PR #102 head recorded in rustfs/backlog#2658). R02 re-pins this when the live verifier ships.",
|
||||
"harness_repository": "rustfs/rustfs",
|
||||
"harness_sha": "candidate.workflow_sha",
|
||||
"harness_basis": "The exact workflow commit is frozen in candidate inputs; no mutable branch is accepted as a harness identity.",
|
||||
"timeout_seconds": 1800,
|
||||
"environment_capabilities": ["process-exec"],
|
||||
"cases": ["artifact-identity", "startup"],
|
||||
"environment_capabilities": [
|
||||
"process-exec"
|
||||
],
|
||||
"cases": [
|
||||
"artifact-identity",
|
||||
"startup"
|
||||
],
|
||||
"applies_to": {
|
||||
"release_types": ["preview", "prerelease", "stable"],
|
||||
"release_types": [
|
||||
"preview",
|
||||
"prerelease",
|
||||
"stable"
|
||||
],
|
||||
"platforms": "all"
|
||||
},
|
||||
"applies_basis": "Artifact identity and startup checks are mandatory for every release type, preview included, on every shipped target (rustfs/backlog#2662 frozen design item 5)."
|
||||
},
|
||||
{
|
||||
"lane": "upgrade",
|
||||
"harness_repository": "rustfs/s3chaos",
|
||||
"harness_sha": "9a48f55c1d6d58ce63a8fb1ea7317e024f32f11d",
|
||||
"harness_basis": "Same fixed envelope producer as the identity lane; the workflow SHA and the product build SHA are bound separately and need not be equal.",
|
||||
"harness_repository": "rustfs/rustfs",
|
||||
"harness_sha": "candidate.workflow_sha",
|
||||
"harness_basis": "The exact workflow commit is frozen in candidate inputs; no mutable branch is accepted as a harness identity.",
|
||||
"timeout_seconds": 5400,
|
||||
"environment_capabilities": ["process-exec", "previous-release-artifact"],
|
||||
"environment_capabilities": [
|
||||
"process-exec",
|
||||
"previous-release-artifact"
|
||||
],
|
||||
"cases": [
|
||||
"direct-upgrade",
|
||||
"mixed-version-upgrade",
|
||||
@@ -49,8 +98,17 @@
|
||||
],
|
||||
"cases_basis": "Case IDs of the pinned previous-release matrix in .github/workflows/e2e-upgrade.yml (source.version/asset/sha256 and the case list mapped there).",
|
||||
"applies_to": {
|
||||
"release_types": ["prerelease", "stable"],
|
||||
"platforms": [{"os": "linux", "arch": "x86_64", "abi": "gnu"}]
|
||||
"release_types": [
|
||||
"prerelease",
|
||||
"stable"
|
||||
],
|
||||
"platforms": [
|
||||
{
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"abi": "gnu"
|
||||
}
|
||||
]
|
||||
},
|
||||
"applies_basis": "preview releases use the pre-declared smaller set (identity lane only). The upgrade lane is only exercised on linux-x86_64-gnu, the platform of the pinned source assets."
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
# One row per workspace member: <package>, tests, ignored, tab-separated.
|
||||
# tests counts #[test], #[tokio::test], #[tokio::test(...)] and #[test_case(...)]
|
||||
# attribute lines under the package directory; ignored counts #[ignore] and
|
||||
# #[ignore = "..."]. Both columns are exact: CI fails when a crate's tests fall
|
||||
# below or rise above its row, or when its ignored count changes. Raise a row in
|
||||
# the PR that adds tests; lowering one is a reviewed statement that tests were
|
||||
# intentionally removed. Regenerate with:
|
||||
# #[ignore = "..."]. The tests column is a floor and the ignored column is a
|
||||
# ceiling: CI fails when a crate's tests fall below its row or its ignored count
|
||||
# rises above it. Extra tests and fewer ignores pass; --update tightens the rows.
|
||||
# Lowering a tests row or raising an ignored row is a reviewed statement that
|
||||
# tests were intentionally removed or ignored; justify it in the PR description.
|
||||
# Regenerate with:
|
||||
# scripts/check_test_count_ratchet.sh --update
|
||||
e2e_test 1026 20
|
||||
rustfs 6438 64
|
||||
rustfs 6440 64
|
||||
rustfs-audit 70 0
|
||||
rustfs-checksums 26 0
|
||||
rustfs-checksums 28 1
|
||||
rustfs-common 36 0
|
||||
rustfs-concurrency 87 0
|
||||
rustfs-config 43 0
|
||||
@@ -23,6 +25,7 @@ rustfs-data-usage 58 0
|
||||
rustfs-ecstore 7858 2
|
||||
rustfs-extension-schema 23 0
|
||||
rustfs-filemeta 361 0
|
||||
rustfs-gateway-adapter 74 0
|
||||
rustfs-heal 1128 3
|
||||
rustfs-heal-contracts 8 0
|
||||
rustfs-iam 415 0
|
||||
@@ -43,7 +46,7 @@ rustfs-policy 758 0
|
||||
rustfs-protocols 770 1
|
||||
rustfs-protos 75 0
|
||||
rustfs-replication 209 0
|
||||
rustfs-rio 223 0
|
||||
rustfs-rio 225 1
|
||||
rustfs-rio-v2 34 6
|
||||
rustfs-s3-client 71 0
|
||||
rustfs-s3-ops 8 0
|
||||
@@ -59,6 +62,6 @@ rustfs-targets 612 26
|
||||
rustfs-test-utils 1 0
|
||||
rustfs-tls-runtime 44 0
|
||||
rustfs-trusted-proxies 72 0
|
||||
rustfs-utils 251 0
|
||||
rustfs-utils 253 1
|
||||
rustfs-zip 30 0
|
||||
zstd 11 0
|
||||
|
||||
@@ -101,6 +101,10 @@ runs:
|
||||
shell: bash
|
||||
run: ./scripts/check_test_count_ratchet.sh
|
||||
|
||||
- name: Check cross-owner tests use test seams
|
||||
shell: bash
|
||||
run: python3 ./scripts/check_test_seams.py
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
shell: bash
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: "Reference RustFS binary"
|
||||
description: "Download the pinned reference RustFS release, verify its sha256 and expose the executable path. A missing pin, missing asset or digest mismatch fails the step."
|
||||
|
||||
inputs:
|
||||
pin-file:
|
||||
description: "Pin file naming the reference release (repository-relative)"
|
||||
required: false
|
||||
default: ".config/reference-binary.toml"
|
||||
platform:
|
||||
description: "Platform table in the pin file"
|
||||
required: false
|
||||
default: "linux-x86_64-gnu"
|
||||
destination:
|
||||
description: "Directory that receives the executable; defaults to $RUNNER_TEMP/reference-binary"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
path:
|
||||
description: "Absolute path of the verified executable"
|
||||
value: ${{ steps.unpack.outputs.path }}
|
||||
version:
|
||||
description: "Pinned reference version"
|
||||
value: ${{ steps.pin.outputs.version }}
|
||||
sha256:
|
||||
description: "Pinned and verified archive sha256"
|
||||
value: ${{ steps.pin.outputs.sha256 }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Resolve reference pin
|
||||
id: pin
|
||||
shell: bash
|
||||
env:
|
||||
PIN_TOOL: ${{ github.action_path }}/reference_binary.py
|
||||
PIN_FILE: ${{ inputs.pin-file }}
|
||||
PIN_PLATFORM: ${{ inputs.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
resolved="$(python3 "$PIN_TOOL" resolve --pin "$PIN_FILE" --platform "$PIN_PLATFORM")"
|
||||
printf '%s\n' "$resolved"
|
||||
printf '%s\n' "$resolved" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download and verify reference archive
|
||||
id: download
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ASSET_DOWNLOADER: ${{ github.action_path }}/../setup/download_release_asset.py
|
||||
REF_REPOSITORY: ${{ steps.pin.outputs.repository }}
|
||||
REF_TAG: ${{ steps.pin.outputs.tag }}
|
||||
REF_ASSET: ${{ steps.pin.outputs.asset }}
|
||||
REF_SHA256: ${{ steps.pin.outputs.sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="$RUNNER_TEMP/reference-archive/$REF_ASSET"
|
||||
# Fails unless the release lists exactly one asset of this name whose
|
||||
# published digest equals the pin and whose downloaded bytes match it.
|
||||
python3 "$ASSET_DOWNLOADER" --repository "$REF_REPOSITORY" --tag "$REF_TAG" \
|
||||
--asset "$REF_ASSET" --output "$archive" --sha256 "$REF_SHA256"
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Unpack reference executable
|
||||
id: unpack
|
||||
shell: bash
|
||||
env:
|
||||
PIN_TOOL: ${{ github.action_path }}/reference_binary.py
|
||||
REF_ARCHIVE: ${{ steps.download.outputs.archive }}
|
||||
REF_SHA256: ${{ steps.pin.outputs.sha256 }}
|
||||
REF_EXECUTABLE: ${{ steps.pin.outputs.executable }}
|
||||
REF_DESTINATION: ${{ inputs.destination }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
destination="${REF_DESTINATION:-$RUNNER_TEMP/reference-binary}"
|
||||
unpacked="$(python3 "$PIN_TOOL" unpack --archive "$REF_ARCHIVE" --sha256 "$REF_SHA256" \
|
||||
--executable "$REF_EXECUTABLE" --destination "$destination")"
|
||||
path="${unpacked#path=}"
|
||||
test -x "$path"
|
||||
echo "Reference executable: $path"
|
||||
"$path" --version
|
||||
echo "path=$path" >> "$GITHUB_OUTPUT"
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""Resolve and unpack the pinned reference RustFS release binary.
|
||||
|
||||
`resolve` reads the pin file and prints the selected platform's identity as
|
||||
`key=value` lines (GitHub step-output format). `unpack` re-verifies the
|
||||
downloaded archive digest against the pin and extracts exactly one executable.
|
||||
Every missing or malformed input is a failure; nothing is skipped.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
import zipfile
|
||||
|
||||
REPOSITORY = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*")
|
||||
TAG = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.+-]*")
|
||||
PLATFORM = re.compile(r"[a-z0-9][a-z0-9_-]*")
|
||||
ASSET = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.+-]*\.zip")
|
||||
SHA256 = re.compile(r"[0-9a-f]{64}")
|
||||
EXECUTABLE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*")
|
||||
MAX_EXECUTABLE_BYTES = 1024 * 1024 * 1024
|
||||
|
||||
|
||||
class PinError(Exception):
|
||||
"""A pin, archive or argument that cannot be trusted."""
|
||||
|
||||
|
||||
def field(table, key, pattern, where):
|
||||
value = table.get(key)
|
||||
if not isinstance(value, str) or not pattern.fullmatch(value):
|
||||
raise PinError(f"{where}: missing or invalid '{key}'")
|
||||
return value
|
||||
|
||||
|
||||
def resolve(pin_path, platform):
|
||||
if not PLATFORM.fullmatch(platform or ""):
|
||||
raise PinError("invalid platform name")
|
||||
try:
|
||||
data = tomllib.loads(Path(pin_path).read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise PinError(f"{pin_path}: reference pin file is missing") from exc
|
||||
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
|
||||
raise PinError(f"{pin_path}: cannot read reference pin: {exc}") from exc
|
||||
if data.get("schema") != 1 or type(data.get("schema")) is not int:
|
||||
raise PinError(f"{pin_path}: unsupported pin schema")
|
||||
identity = {
|
||||
"repository": field(data, "repository", REPOSITORY, pin_path),
|
||||
"version": field(data, "version", TAG, pin_path),
|
||||
"tag": field(data, "tag", TAG, pin_path),
|
||||
}
|
||||
platforms = data.get("platforms")
|
||||
if not isinstance(platforms, dict) or not platforms:
|
||||
raise PinError(f"{pin_path}: no pinned platforms")
|
||||
entry = platforms.get(platform)
|
||||
if not isinstance(entry, dict):
|
||||
raise PinError(f"{pin_path}: no pin for platform '{platform}'")
|
||||
where = f"{pin_path} [platforms.{platform}]"
|
||||
identity["asset"] = field(entry, "asset", ASSET, where)
|
||||
identity["sha256"] = field(entry, "sha256", SHA256, where)
|
||||
identity["executable"] = field(entry, "executable", EXECUTABLE, where)
|
||||
if identity["version"] not in identity["asset"]:
|
||||
raise PinError(f"{where}: asset name does not carry version {identity['version']}")
|
||||
unknown = set(entry) - {"asset", "sha256", "executable"}
|
||||
if unknown:
|
||||
raise PinError(f"{where}: unknown keys {sorted(unknown)}")
|
||||
return identity
|
||||
|
||||
|
||||
def file_sha256(path):
|
||||
with open(path, "rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def unpack(archive, sha256, executable, destination):
|
||||
if not SHA256.fullmatch(sha256 or "") or not EXECUTABLE.fullmatch(executable or ""):
|
||||
raise PinError("invalid expected digest or executable name")
|
||||
try:
|
||||
actual = file_sha256(archive)
|
||||
except OSError as exc:
|
||||
raise PinError(f"{archive}: reference archive is missing or unreadable") from exc
|
||||
if actual != sha256:
|
||||
raise PinError(f"{archive}: sha256 {actual} does not match the pinned digest")
|
||||
try:
|
||||
with zipfile.ZipFile(archive) as bundle:
|
||||
matches = [info for info in bundle.infolist() if info.filename == executable]
|
||||
if len(matches) != 1:
|
||||
raise PinError(f"{archive}: must contain exactly one top-level '{executable}'")
|
||||
info = matches[0]
|
||||
if info.is_dir() or not 0 < info.file_size <= MAX_EXECUTABLE_BYTES:
|
||||
raise PinError(f"{archive}: '{executable}' is not a plausible executable")
|
||||
destination = Path(destination)
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
target = destination / executable
|
||||
with bundle.open(info) as source, open(target, "wb") as sink:
|
||||
while chunk := source.read(1024 * 1024):
|
||||
sink.write(chunk)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise PinError(f"{archive}: not a valid zip archive") from exc
|
||||
target.chmod(0o755)
|
||||
return target
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
sub = parser.add_subparsers(dest="mode", required=True)
|
||||
res = sub.add_parser("resolve")
|
||||
res.add_argument("--pin", required=True)
|
||||
res.add_argument("--platform", required=True)
|
||||
unp = sub.add_parser("unpack")
|
||||
unp.add_argument("--archive", required=True)
|
||||
unp.add_argument("--sha256", required=True)
|
||||
unp.add_argument("--executable", required=True)
|
||||
unp.add_argument("--destination", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if args.mode == "resolve":
|
||||
for key, value in resolve(args.pin, args.platform).items():
|
||||
print(f"{key}={value}")
|
||||
else:
|
||||
print(f"path={unpack(args.archive, args.sha256, args.executable, args.destination)}")
|
||||
except PinError as exc:
|
||||
print(f"reference binary: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -350,10 +350,10 @@
|
||||
"roles": [
|
||||
"periodic",
|
||||
"manual",
|
||||
"release"
|
||||
"reusable"
|
||||
],
|
||||
"events": [
|
||||
"push",
|
||||
"workflow_call",
|
||||
"schedule",
|
||||
"workflow_dispatch"
|
||||
],
|
||||
@@ -367,7 +367,7 @@
|
||||
"monitor": "workflow",
|
||||
"max_age_hours": 36,
|
||||
"result_owner": "build.yml",
|
||||
"disposition": "retained independent entry",
|
||||
"disposition": "Builds artifacts only; public release writes belong to release-publish.yml.",
|
||||
"failure_owner": "workflow",
|
||||
"origin": "baseline",
|
||||
"alert_contract": "scheduled-validation"
|
||||
@@ -745,14 +745,13 @@
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/docker.yml",
|
||||
"name": "Docker Images",
|
||||
"name": "Build candidate images",
|
||||
"roles": [
|
||||
"manual",
|
||||
"release"
|
||||
"release",
|
||||
"reusable"
|
||||
],
|
||||
"events": [
|
||||
"workflow_run",
|
||||
"workflow_dispatch"
|
||||
"workflow_call"
|
||||
],
|
||||
"cadence": "event",
|
||||
"timezone": "UTC",
|
||||
@@ -761,7 +760,7 @@
|
||||
"policy": "active",
|
||||
"monitor": "none",
|
||||
"result_owner": "docker.yml",
|
||||
"disposition": "retained independent entry",
|
||||
"disposition": "Builds artifacts only; public release writes belong to release-publish.yml.",
|
||||
"monitor_reason": "Event-driven or operator-requested work has no periodic freshness obligation.",
|
||||
"origin": "baseline"
|
||||
},
|
||||
@@ -870,6 +869,33 @@
|
||||
"failure_owner": "watchdog",
|
||||
"origin": "baseline"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/reference-parity.yml",
|
||||
"name": "Reference Parity",
|
||||
"roles": [
|
||||
"pr",
|
||||
"periodic",
|
||||
"manual"
|
||||
],
|
||||
"events": [
|
||||
"pull_request",
|
||||
"schedule",
|
||||
"workflow_dispatch"
|
||||
],
|
||||
"cadence": "nightly",
|
||||
"timezone": "UTC",
|
||||
"schedule": [
|
||||
"37 1 * * *"
|
||||
],
|
||||
"owner": "@overtrue",
|
||||
"policy": "active",
|
||||
"monitor": "workflow",
|
||||
"max_age_hours": 36,
|
||||
"result_owner": "reference-parity.yml",
|
||||
"disposition": "s3-tests per-node-id parity of branch candidates against the pinned reference release",
|
||||
"failure_owner": "watchdog",
|
||||
"origin": "added"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/functional-chain-health.yml",
|
||||
"name": "Functional chain health",
|
||||
@@ -954,14 +980,13 @@
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/helm-package.yml",
|
||||
"name": "Publish helm chart to artifacthub",
|
||||
"name": "Build candidate Helm chart",
|
||||
"roles": [
|
||||
"manual",
|
||||
"release"
|
||||
"release",
|
||||
"reusable"
|
||||
],
|
||||
"events": [
|
||||
"workflow_run",
|
||||
"workflow_dispatch"
|
||||
"workflow_call"
|
||||
],
|
||||
"cadence": "event",
|
||||
"timezone": "UTC",
|
||||
@@ -970,7 +995,7 @@
|
||||
"policy": "active",
|
||||
"monitor": "none",
|
||||
"result_owner": "helm-package.yml",
|
||||
"disposition": "retained independent entry",
|
||||
"disposition": "Builds artifacts only; public release writes belong to release-publish.yml.",
|
||||
"monitor_reason": "Event-driven or operator-requested work has no periodic freshness obligation.",
|
||||
"origin": "baseline"
|
||||
},
|
||||
@@ -996,6 +1021,26 @@
|
||||
"policy_reason": "Preserve operator-disabled state; enabling requires a separate policy decision.",
|
||||
"origin": "baseline"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/manual-main-packages.yml",
|
||||
"name": "Manual main packages",
|
||||
"roles": [
|
||||
"manual"
|
||||
],
|
||||
"events": [
|
||||
"workflow_dispatch"
|
||||
],
|
||||
"cadence": "event",
|
||||
"timezone": "UTC",
|
||||
"schedule": [],
|
||||
"owner": "@overtrue",
|
||||
"policy": "active",
|
||||
"monitor": "none",
|
||||
"result_owner": "manual-main-packages.yml",
|
||||
"disposition": "Packages the origin/main tip as workflow artifacts only. No GitHub Release or download-channel writes.",
|
||||
"monitor_reason": "Operator-requested packaging has no periodic freshness obligation.",
|
||||
"origin": "manual-main-packages"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/minio-interop.yml",
|
||||
"name": "minio-interop",
|
||||
@@ -1182,14 +1227,13 @@
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/package.yml",
|
||||
"name": "Package DEB/RPM",
|
||||
"name": "Build candidate packages",
|
||||
"roles": [
|
||||
"manual",
|
||||
"release"
|
||||
"release",
|
||||
"reusable"
|
||||
],
|
||||
"events": [
|
||||
"workflow_run",
|
||||
"workflow_dispatch"
|
||||
"workflow_call"
|
||||
],
|
||||
"cadence": "event",
|
||||
"timezone": "UTC",
|
||||
@@ -1198,7 +1242,7 @@
|
||||
"policy": "active",
|
||||
"monitor": "none",
|
||||
"result_owner": "package.yml",
|
||||
"disposition": "retained independent entry",
|
||||
"disposition": "Builds artifacts only; public release writes belong to release-publish.yml.",
|
||||
"monitor_reason": "Event-driven or operator-requested work has no periodic freshness obligation.",
|
||||
"origin": "baseline"
|
||||
},
|
||||
@@ -1727,6 +1771,69 @@
|
||||
"disposition": "retained independent entry",
|
||||
"monitor_reason": "Event-driven or operator-requested work has no periodic freshness obligation.",
|
||||
"origin": "baseline"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/release-candidate.yml",
|
||||
"name": "Release candidate (Linux)",
|
||||
"roles": [
|
||||
"manual",
|
||||
"release"
|
||||
],
|
||||
"events": [
|
||||
"workflow_dispatch"
|
||||
],
|
||||
"cadence": "event",
|
||||
"timezone": "UTC",
|
||||
"schedule": [],
|
||||
"owner": "@overtrue",
|
||||
"policy": "active",
|
||||
"monitor": "none",
|
||||
"result_owner": "release-candidate.yml",
|
||||
"disposition": "Explicit immutable candidate stage",
|
||||
"monitor_reason": "Operator-requested release stage has no periodic freshness obligation.",
|
||||
"origin": "release-candidate-migration"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/release-complete.yml",
|
||||
"name": "Release candidate (complete)",
|
||||
"roles": [
|
||||
"manual",
|
||||
"release"
|
||||
],
|
||||
"events": [
|
||||
"workflow_dispatch"
|
||||
],
|
||||
"cadence": "event",
|
||||
"timezone": "UTC",
|
||||
"schedule": [],
|
||||
"owner": "@overtrue",
|
||||
"policy": "active",
|
||||
"monitor": "none",
|
||||
"result_owner": "release-complete.yml",
|
||||
"disposition": "Explicit immutable candidate stage",
|
||||
"monitor_reason": "Operator-requested release stage has no periodic freshness obligation.",
|
||||
"origin": "release-candidate-migration"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/release-publish.yml",
|
||||
"name": "Publish accepted release",
|
||||
"roles": [
|
||||
"manual",
|
||||
"release"
|
||||
],
|
||||
"events": [
|
||||
"workflow_dispatch"
|
||||
],
|
||||
"cadence": "event",
|
||||
"timezone": "UTC",
|
||||
"schedule": [],
|
||||
"owner": "@overtrue",
|
||||
"policy": "active",
|
||||
"monitor": "none",
|
||||
"result_owner": "release-publish.yml",
|
||||
"disposition": "Explicit immutable candidate stage",
|
||||
"monitor_reason": "Operator-requested release stage has no periodic freshness obligation.",
|
||||
"origin": "release-candidate-migration"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Billing facts
|
||||
|
||||
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
|
||||
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, including the `macos-latest-xlarge` release lane used here, and standard runners in private repositories.
|
||||
|
||||
Every other label in this repository is capacity RustFS pays for. ARC pods and lab machines accrue cost while the machine exists. A Ubicloud label is metered for the minutes a job holds its VM:
|
||||
|
||||
@@ -13,6 +13,7 @@ Every other label in this repository is capacity RustFS pays for. ARC pods and l
|
||||
|---|---|---|---|
|
||||
| `ubuntu-latest` | nobody | GitHub-hosted, Ubuntu 24.04, 4 vCPU, 16 GB RAM, about 14 GB of free disk. Ships a C toolchain, `docker`, `gh`, `curl`, `wget`, `jq`, Python. Can `mount` tmpfs and loop devices; io_uring works | the default for every job |
|
||||
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
|
||||
| `macos-latest-xlarge` | RustFS | GitHub-hosted larger Apple Silicon runner | macOS release builds, deferred until Linux acceptance for candidates |
|
||||
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
|
||||
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
|
||||
| `ubicloud-standard-16-arm` | RustFS | Ubicloud VM, alias of `ubicloud-standard-16-arm-ubuntu-2404`: Ubuntu 24.04 arm64, 16 vCPU, 48 GB, 300 GB disk. Reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds (`linux-aarch64-musl`, `linux-aarch64-gnu`) |
|
||||
@@ -32,7 +33,7 @@ A job runs on `ubuntu-latest` unless it has one of these reasons, and the reason
|
||||
|
||||
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
|
||||
|
||||
The functional chain's `prepare` and `complete-chain` jobs, the performance runner availability check, and runner hygiene use `ubuntu-latest`: they inspect APIs or artifacts, not the lab hosts. The actual functional and performance suites remain on their lab runners. Functional-chain health collection uses `ubuntu-latest` so a lab outage cannot also stop its independent monitor. Docker image packaging also uses `ubuntu-latest` for both musl and glibc variants; it downloads release binaries rather than compiling the workspace, and still sets up QEMU and Buildx explicitly.
|
||||
The functional chain's `prepare` and `complete-chain` jobs, the performance runner availability check, and runner hygiene use `ubuntu-latest`: they inspect APIs or artifacts, not the lab hosts. The actual functional and performance suites remain on their lab runners. Functional-chain health collection uses `ubuntu-latest` so a lab outage cannot also stop its independent monitor. Docker image packaging also uses `ubuntu-latest` for both musl and glibc variants; it wraps verified candidate binaries rather than compiling the workspace, and still sets up QEMU and Buildx explicitly.
|
||||
|
||||
Moving these jobs off AKS reduces capacity demand, not necessarily the Azure bill immediately. VM costs fall only when nodes scale down or smaller capacity replaces them.
|
||||
|
||||
@@ -52,7 +53,7 @@ A hosted runner is also the only choice for a few things the pods cannot do: `mo
|
||||
These are the patterns that have cost real money here without validating anything more:
|
||||
|
||||
- An `if: always()` summary or alert job on a self-hosted label inside a `workflow_run`-triggered workflow. The upstream workflow completes many times a day, the real jobs skip, and the summary still starts a pod each time. Gate it on the upstream job having run, and keep it on `ubuntu-latest`.
|
||||
- Per-push workflows on `main`. A merge triggers the full `ci.yml` matrix, `cache-warm.yml` and the `build.yml` development build; with tens of merges a day that is as much self-hosted time as all pull requests together. Anything that does not verify the merged tree itself belongs on a schedule or behind a `paths` filter.
|
||||
- Per-push workflows on `main`. A merge triggers the applicable `ci.yml` matrix and cache warming; `build.yml` development builds are scheduled or manual; with tens of merges a day that is as much self-hosted time as all pull requests together. Anything that does not verify the merged tree itself belongs on a schedule or behind a `paths` filter.
|
||||
- `apt-get install` of tools the hosted image already ships. It is only needed on the minimal pod images.
|
||||
- A throttled `CARGO_BUILD_JOBS` on a 14-CPU pod. The limit exists for link-storm reasons documented in `ci.yml`; measure before raising it, and measure before lowering it further.
|
||||
- Tiny jobs on ARC pods. Each one pays roughly a minute of pod start-up before its first step; a compile-free job that takes ten seconds costs more to schedule than to run.
|
||||
@@ -66,7 +67,7 @@ gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
|
||||
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
|
||||
```
|
||||
|
||||
Only rows whose label starts with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
|
||||
Rows using `macos-latest-xlarge` or a label starting with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-` use paid capacity. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
|
||||
|
||||
## Checklist for a new or moved job
|
||||
|
||||
@@ -76,3 +77,9 @@ Only rows whose label starts with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-`
|
||||
- A self-hosted `runs-on` carries a comment naming its reason from the placement rule.
|
||||
- Every `./.github/actions/setup` call states `cache-save-if`, and a key read from one pool is not written from the other.
|
||||
- Moving a job does not change its check name, so the `main` ruleset and `docs/testing/ci-gates.md` stay as they are; renaming it does, and then that document's change checklist applies.
|
||||
|
||||
## Release stages
|
||||
|
||||
The [candidate release pipeline](../../docs/operations/release-pipeline.md) builds Linux first and defers paid desktop capacity until acceptance. Daily development builds also select Linux; use manual platform selection for desktop development artifacts. All six platforms remain required before publication. The pipeline records and promotes the exact tested bytes. Builder workflows have no public-release credentials. Keep periodic development builds, PR verification and field-test chains separate when their contracts differ; do not re-enable disabled workflows as part of a source-file migration.
|
||||
|
||||
Larger runners remain billed for public repositories; see [GitHub runner billing](https://docs.github.com/en/actions/concepts/runners/larger-runners#billing). Actual spending comes from organization billing, not runner labels or an estimated build duration.
|
||||
|
||||
+149
-8
@@ -23,9 +23,16 @@ on:
|
||||
- 'deny.toml'
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/**'
|
||||
- 'scripts/release/create_or_update_release.sh'
|
||||
- 'scripts/release/package_service_scripts.sh'
|
||||
- 'scripts/release/package_versions.sh'
|
||||
- 'scripts/release/**'
|
||||
- 'scripts/security/generate_release_supply_chain_assets.py'
|
||||
- '.config/release-validation-plan.json'
|
||||
- '.github/scheduled-validations.json'
|
||||
- 'scripts/test_helm_release_workflow.py'
|
||||
- 'scripts/test_release_packages.py'
|
||||
- 'scripts/test_release_images.py'
|
||||
- 'Dockerfile*'
|
||||
- 'entrypoint.sh'
|
||||
- 'bootstrap/**'
|
||||
- 'scripts/test_package_service_scripts.sh'
|
||||
- 'scripts/test_package_versions.sh'
|
||||
- 'scripts/test_docker_workflow.py'
|
||||
@@ -41,9 +48,16 @@ on:
|
||||
- 'deny.toml'
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/**'
|
||||
- 'scripts/release/create_or_update_release.sh'
|
||||
- 'scripts/release/package_service_scripts.sh'
|
||||
- 'scripts/release/package_versions.sh'
|
||||
- 'scripts/release/**'
|
||||
- 'scripts/security/generate_release_supply_chain_assets.py'
|
||||
- '.config/release-validation-plan.json'
|
||||
- '.github/scheduled-validations.json'
|
||||
- 'scripts/test_helm_release_workflow.py'
|
||||
- 'scripts/test_release_packages.py'
|
||||
- 'scripts/test_release_images.py'
|
||||
- 'Dockerfile*'
|
||||
- 'entrypoint.sh'
|
||||
- 'bootstrap/**'
|
||||
- 'scripts/test_package_service_scripts.sh'
|
||||
- 'scripts/test_package_versions.sh'
|
||||
- 'scripts/test_docker_workflow.py'
|
||||
@@ -132,12 +146,38 @@ jobs:
|
||||
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
release_changed: ${{ steps.scope.outputs.release_changed }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Scope native packaging checks to release changes
|
||||
id: scope
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os, re, subprocess
|
||||
event, base = os.environ['GITHUB_EVENT_NAME'], os.environ.get('BASE_SHA', '')
|
||||
changed = event == 'workflow_dispatch'
|
||||
if event in ('push', 'pull_request'):
|
||||
if not re.fullmatch(r'[0-9a-f]{40}', base) or base == '0' * 40:
|
||||
changed = True
|
||||
else:
|
||||
paths = subprocess.check_output(['git', 'diff', '--name-only', base, os.environ['GITHUB_SHA']], text=True).splitlines()
|
||||
pattern = r'^(scripts/release/|scripts/test_release_|Dockerfile|entrypoint.sh$|bootstrap/|\.dockerignore$|\.config/release-validation-plan.json$|\.github/workflows/(audit|build|docker|package|helm-package|release-[^.]+)\.yml$)'
|
||||
changed = any(re.match(pattern, path) for path in paths)
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
|
||||
output.write(f'release_changed={str(changed).lower()}\n')
|
||||
PY
|
||||
|
||||
- name: Report unpinned GitHub Actions
|
||||
run: ./scripts/security/check_workflow_pins.sh --enforce
|
||||
|
||||
@@ -150,8 +190,35 @@ jobs:
|
||||
- name: Check checkouts clear their credentials
|
||||
run: ./scripts/security/check_persist_credentials.sh
|
||||
|
||||
- name: Check preview release workflow policy
|
||||
run: ./scripts/security/check_preview_release_workflow.sh
|
||||
- name: Check release candidate and promotion contracts
|
||||
run: python3 -m unittest discover -s scripts/release -p 'test_*.py'
|
||||
|
||||
- name: Prepare artifact transport rehearsal
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/release-transport"
|
||||
printf '{"purpose":"transport contract, not release evidence"}\n' > "$RUNNER_TEMP/release-transport/transport.json"
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-transport-contract-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/release-transport/transport.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Verify actual GitHub artifact metadata and downloaded bytes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, 'scripts/release')
|
||||
import candidate as c
|
||||
run = c.github(f"repos/{c.REPOSITORY}/actions/runs/{os.environ['GITHUB_RUN_ID']}")
|
||||
destination = Path(os.environ['RUNNER_TEMP']) / 'release-transport-downloaded'
|
||||
c.download_artifact(run, f"release-transport-contract-{os.environ['GITHUB_RUN_ATTEMPT']}", destination)
|
||||
c.require(c.read_json(destination / 'transport.json') == {'purpose': 'transport contract, not release evidence'}, 'artifact round trip changed')
|
||||
PY
|
||||
|
||||
- name: Check tier evidence workflow isolation
|
||||
run: ./scripts/security/check_tier_artifact_workflow.sh
|
||||
@@ -162,6 +229,80 @@ jobs:
|
||||
- name: Check package service lifecycle contract
|
||||
run: ./scripts/test_package_service_scripts.sh
|
||||
|
||||
release-package-contract:
|
||||
name: Release package contract
|
||||
needs: workflow-pin-report
|
||||
if: needs.workflow-pin-report.outputs.release_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install real package managers
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ruby ruby-dev build-essential rpm fakeroot cpio
|
||||
sudo gem install fpm --version 1.17.0 --no-document
|
||||
- name: Exercise accepted and substituted payloads through both recipes
|
||||
run: python3 scripts/test_release_packages.py
|
||||
|
||||
release-image-contract:
|
||||
name: Release image contract (${{ matrix.variant }})
|
||||
needs: workflow-pin-report
|
||||
if: needs.workflow-pin-report.outputs.release_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant: [musl, gnu]
|
||||
env:
|
||||
RELEASE_IMAGE_VARIANT: ${{ matrix.variant }}
|
||||
NO_PROXY: 127.0.0.1,localhost,::1
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install OCI tools
|
||||
run: |
|
||||
echo "RELEASE_IMAGE_OUTPUT=$RUNNER_TEMP/image-contract" >> "$GITHUB_ENV"
|
||||
echo 'no_proxy=127.0.0.1,localhost,::1' >> "$GITHUB_ENV"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y skopeo
|
||||
go version
|
||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- name: Exercise real OCI packaging, startup, registry retry and conflicts
|
||||
run: python3 scripts/test_release_images.py
|
||||
- name: Exercise amd64 OCI archive scanning
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
|
||||
env:
|
||||
TRIVY_PLATFORM: linux/amd64
|
||||
with:
|
||||
input: ${{ runner.temp }}/image-contract-scan/image-${{ matrix.variant }}-amd64.tar
|
||||
format: sarif
|
||||
output: ${{ runner.temp }}/image-contract/scan-${{ matrix.variant }}-amd64.sarif
|
||||
exit-code: "0"
|
||||
- name: Exercise arm64 OCI archive scanning
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
|
||||
env:
|
||||
TRIVY_PLATFORM: linux/arm64
|
||||
with:
|
||||
input: ${{ runner.temp }}/image-contract-scan/image-${{ matrix.variant }}-arm64.tar
|
||||
format: sarif
|
||||
output: ${{ runner.temp }}/image-contract/scan-${{ matrix.variant }}-arm64.sarif
|
||||
exit-code: "0"
|
||||
- name: Verify scan receipts before recording the archive
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, 'scripts/release')
|
||||
import deliver, test_candidate
|
||||
deliver.image_record(test_candidate.inputs(), os.environ['RELEASE_IMAGE_VARIANT'], Path(os.environ['RELEASE_IMAGE_OUTPUT']))
|
||||
PY
|
||||
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+183
-764
File diff suppressed because it is too large
Load Diff
+71
-751
@@ -1,779 +1,99 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Docker Images Workflow
|
||||
#
|
||||
# This workflow builds Docker images using pre-built binaries from the build workflow.
|
||||
#
|
||||
# Trigger Types:
|
||||
# 1. workflow_run: Automatically triggered when "Build and Release" workflow completes
|
||||
# 2. workflow_dispatch: Manual trigger for standalone Docker builds
|
||||
#
|
||||
# Key Features:
|
||||
# - Only triggers when Linux builds (x86_64 + aarch64) are successful
|
||||
# - Independent of macOS/Windows build status
|
||||
# - Uses workflow_run event for precise control
|
||||
# - Only builds Docker images for releases and prereleases (development builds are skipped)
|
||||
|
||||
name: Docker Images
|
||||
|
||||
# Permissions needed for workflow_run event and Docker registry access
|
||||
# Copyright 2026 RustFS Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
name: Build candidate images
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
candidate_manifest:
|
||||
type: string
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
# Automatically triggered when build workflow completes
|
||||
workflow_run:
|
||||
workflows: [ "Build and Release" ]
|
||||
types: [ completed ]
|
||||
# Manual trigger with same parameters for consistency
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: "Push images to registries"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
version:
|
||||
description: "Version to build (latest, v1.0.0, v1.0.0-alpha1, or v1.0.1-preview.1)"
|
||||
required: false
|
||||
default: "latest"
|
||||
type: string
|
||||
force_rebuild:
|
||||
description: "Force rebuild even if binary exists (useful for testing)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
TRIGGERING_EVENT: ${{ github.event.workflow_run.event }}
|
||||
DOCKERHUB_USERNAME: rustfs
|
||||
CARGO_TERM_COLOR: always
|
||||
REGISTRY_DOCKERHUB: rustfs/rustfs
|
||||
REGISTRY_GHCR: ghcr.io/${{ github.repository }}
|
||||
REGISTRY_QUAY: quay.io/rustfs/rustfs
|
||||
DOCKER_PLATFORMS: linux/amd64,linux/arm64
|
||||
|
||||
actions: read
|
||||
jobs:
|
||||
# Check if we should build Docker images.
|
||||
# Short-circuit at job level so per-merge development builds (workflow_run
|
||||
# from a push to main) skip the whole run without spawning a checkout job:
|
||||
# images are only published for tag builds — build.yml push triggers cover
|
||||
# only main and tags, so a non-main head_branch is a tag name — and for
|
||||
# manual dispatch.
|
||||
build-check:
|
||||
name: Docker Build Check
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch != 'main')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
should_push: ${{ steps.check.outputs.should_push }}
|
||||
build_type: ${{ steps.check.outputs.build_type }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
short_sha: ${{ steps.check.outputs.short_sha }}
|
||||
is_prerelease: ${{ steps.check.outputs.is_prerelease }}
|
||||
create_latest: ${{ steps.check.outputs.create_latest }}
|
||||
source_ref: ${{ steps.check.outputs.source_ref }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# For workflow_run events, checkout the specific commit that triggered the workflow
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Check build conditions
|
||||
id: check
|
||||
env:
|
||||
# dispatch inputs via env, not `${{ }}` interpolation: they are
|
||||
# free-form strings and would otherwise be evaluated by bash.
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
INPUT_PUSH_IMAGES: ${{ github.event.inputs.push_images }}
|
||||
INPUT_FORCE_REBUILD: ${{ github.event.inputs.force_rebuild }}
|
||||
run: |
|
||||
should_build=false
|
||||
should_push=false
|
||||
build_type="none"
|
||||
version=""
|
||||
short_sha=""
|
||||
is_prerelease=false
|
||||
create_latest=false
|
||||
source_ref="$GITHUB_SHA"
|
||||
|
||||
# Pre-GA policy: until the first stable (vX.Y.Z) tag exists, every
|
||||
# prerelease (alpha/beta/rc) also moves `latest`, so users pulling
|
||||
# `latest` get the newest test build. Once a stable tag is published
|
||||
# this returns false and `latest` follows stable releases only.
|
||||
prerelease_moves_latest() {
|
||||
local stable_tags
|
||||
stable_tags=$(git ls-remote --tags --refs origin 2>/dev/null \
|
||||
| awk '{print $2}' \
|
||||
| grep -E '^refs/tags/v?[0-9]+\.[0-9]+\.[0-9]+$' || true)
|
||||
if [[ -z "$stable_tags" ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo "ℹ️ Stable release tag(s) already exist; prereleases no longer update latest"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
# Triggered by build workflow completion
|
||||
echo "🔗 Triggered by build workflow completion"
|
||||
|
||||
# Check if the triggering workflow was successful
|
||||
# If the workflow succeeded, it means ALL builds (including Linux x86_64 and aarch64) succeeded
|
||||
if [[ "$CONCLUSION" == "success" ]]; then
|
||||
echo "✅ Build workflow succeeded, all builds including Linux are successful"
|
||||
should_build=true
|
||||
should_push=true
|
||||
else
|
||||
echo "❌ Build workflow failed (conclusion: $CONCLUSION), skipping Docker build"
|
||||
should_build=false
|
||||
fi
|
||||
|
||||
# Extract version info from commit message or use commit SHA
|
||||
# Use Git to generate consistent short SHA (ensures uniqueness like build.yml)
|
||||
short_sha=$(git rev-parse --short "$HEAD_SHA")
|
||||
source_ref="$HEAD_SHA"
|
||||
|
||||
# Determine build type based on triggering workflow event and ref
|
||||
triggering_event="$TRIGGERING_EVENT"
|
||||
head_branch="$HEAD_BRANCH"
|
||||
|
||||
echo "🔍 Analyzing triggering workflow:"
|
||||
echo " 📋 Event: $triggering_event"
|
||||
echo " 🌿 Head branch: $head_branch"
|
||||
echo " 📎 Head SHA: $HEAD_SHA"
|
||||
|
||||
# Check if this was triggered by a tag push
|
||||
if [[ "$triggering_event" == "push" ]]; then
|
||||
# For tag pushes, head_branch will be like "refs/tags/v1.0.0" or just "v1.0.0"
|
||||
if [[ "$head_branch" == refs/tags/* ]]; then
|
||||
# Extract tag name from refs/tags/TAG_NAME
|
||||
tag_name="${head_branch#refs/tags/}"
|
||||
version="$tag_name"
|
||||
elif [[ "$head_branch" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||
# Direct tag name like "v1.0.0" or "1.0.0-alpha.1"
|
||||
version="$head_branch"
|
||||
elif [[ "$head_branch" == "main" ]]; then
|
||||
# Regular branch push to main
|
||||
build_type="development"
|
||||
version="dev-${short_sha}"
|
||||
should_build=false
|
||||
echo "⏭️ Skipping Docker build for development version (main branch push)"
|
||||
else
|
||||
# Other branch push
|
||||
build_type="development"
|
||||
version="dev-${short_sha}"
|
||||
should_build=false
|
||||
echo "⏭️ Skipping Docker build for development version (branch: $head_branch)"
|
||||
fi
|
||||
|
||||
# If we extracted a version (tag), determine release type
|
||||
if [[ -n "$version" ]] && [[ "$version" != "dev-${short_sha}" ]]; then
|
||||
# Remove 'v' prefix if present for consistent version format
|
||||
if [[ "$version" == v* ]]; then
|
||||
version="${version#v}"
|
||||
fi
|
||||
|
||||
if [[ "$version" =~ -preview\.[0-9]+$ ]]; then
|
||||
build_type="preview"
|
||||
is_prerelease=true
|
||||
echo "🔍 Building Docker image for preview: $version (version tags only)"
|
||||
elif [[ "$version" == *"-preview"* ]]; then
|
||||
echo "❌ Invalid preview tag: $version (expected suffix: -preview.<number>)" >&2
|
||||
exit 1
|
||||
elif [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then
|
||||
build_type="prerelease"
|
||||
is_prerelease=true
|
||||
# Pre-GA policy: prereleases update latest until the first stable tag exists.
|
||||
if prerelease_moves_latest; then
|
||||
create_latest=true
|
||||
echo "🧪 Building Docker image for prerelease: $version (creating latest tag)"
|
||||
else
|
||||
echo "🧪 Building Docker image for prerelease: $version"
|
||||
fi
|
||||
else
|
||||
build_type="release"
|
||||
create_latest=true
|
||||
echo "🚀 Building Docker image for release: $version"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Non-push events
|
||||
build_type="development"
|
||||
version="dev-${short_sha}"
|
||||
should_build=false
|
||||
echo "⏭️ Skipping Docker build for development version (event: $triggering_event)"
|
||||
fi
|
||||
|
||||
echo "🔄 Build triggered by workflow_run:"
|
||||
echo " 📋 Conclusion: $CONCLUSION"
|
||||
echo " 🌿 Branch: $HEAD_BRANCH"
|
||||
echo " 📎 SHA: $HEAD_SHA"
|
||||
echo " 🎯 Event: $TRIGGERING_EVENT"
|
||||
|
||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
# Manual trigger
|
||||
input_version="$INPUT_VERSION"
|
||||
version="${input_version}"
|
||||
should_push="$INPUT_PUSH_IMAGES"
|
||||
should_build=true
|
||||
|
||||
# Get short SHA
|
||||
short_sha=$(git rev-parse --short HEAD)
|
||||
|
||||
echo "🎯 Manual Docker build triggered:"
|
||||
echo " 📋 Requested version: $input_version"
|
||||
echo " 🔧 Force rebuild: $INPUT_FORCE_REBUILD"
|
||||
echo " 🚀 Push images: $should_push"
|
||||
|
||||
case "$input_version" in
|
||||
"latest")
|
||||
build_type="release"
|
||||
create_latest=true
|
||||
echo "🚀 Building with latest stable release version"
|
||||
;;
|
||||
*-preview*)
|
||||
if [[ ! "$input_version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?-preview\.[0-9]+$ ]]; then
|
||||
echo "❌ Invalid preview version: $input_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
build_type="preview"
|
||||
is_prerelease=true
|
||||
version="${input_version#v}"
|
||||
echo "🔍 Building with preview version: $version (version tags only)"
|
||||
;;
|
||||
# Prerelease versions (must match first, more specific)
|
||||
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
||||
build_type="prerelease"
|
||||
is_prerelease=true
|
||||
# Pre-GA policy: prereleases update latest until the first stable tag exists.
|
||||
if prerelease_moves_latest; then
|
||||
create_latest=true
|
||||
echo "🧪 Building with prerelease version: $input_version (creating latest tag)"
|
||||
else
|
||||
echo "🧪 Building with prerelease version: $input_version"
|
||||
fi
|
||||
;;
|
||||
# Release versions (match after prereleases, more general)
|
||||
v[0-9]*|[0-9]*.*.*)
|
||||
build_type="release"
|
||||
create_latest=true
|
||||
echo "📦 Building with specific release version: $input_version"
|
||||
;;
|
||||
*)
|
||||
# Invalid version for Docker build
|
||||
should_build=false
|
||||
echo "❌ Invalid version for Docker build: $input_version"
|
||||
echo "⚠️ Only release versions (latest, v1.0.0, 1.0.0) and prereleases (v1.0.0-alpha1, 1.0.0-beta2) are supported"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$should_build" == true && "$input_version" != "latest" ]]; then
|
||||
tag_ref="refs/tags/$input_version"
|
||||
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
|
||||
if [[ "$input_version" == v* ]]; then
|
||||
tag_ref="refs/tags/${input_version#v}"
|
||||
else
|
||||
tag_ref="refs/tags/v$input_version"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
|
||||
echo "❌ Release tag not found for Docker build: $input_version"
|
||||
exit 1
|
||||
fi
|
||||
source_ref="$tag_ref"
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
echo "should_build=$should_build"
|
||||
echo "should_push=$should_push"
|
||||
echo "build_type=$build_type"
|
||||
echo "version=$version"
|
||||
echo "short_sha=$short_sha"
|
||||
echo "is_prerelease=$is_prerelease"
|
||||
echo "create_latest=$create_latest"
|
||||
echo "source_ref=$source_ref"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "🐳 Docker Build Summary:"
|
||||
echo " - Should build: $should_build"
|
||||
echo " - Should push: $should_push"
|
||||
echo " - Build type: $build_type"
|
||||
echo " - Version: $version"
|
||||
echo " - Short SHA: $short_sha"
|
||||
echo " - Is prerelease: $is_prerelease"
|
||||
echo " - Create latest: $create_latest"
|
||||
echo " - Source ref: $source_ref"
|
||||
|
||||
# Consume the trusted release certification for the exact version this
|
||||
# workflow wraps and verify the certified input digests. A manual dispatch
|
||||
# without that certification fails here and pushes no official tag; only
|
||||
# results from the trusted verification sources carry release authority.
|
||||
release-certification:
|
||||
name: Release Certification
|
||||
needs: build-check
|
||||
if: needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.build-check.outputs.source_ref }}
|
||||
|
||||
- name: Download and verify release certification
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_VALIDATION_TOKEN || github.token }}
|
||||
TRIGGERING_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
VERSION: ${{ needs.build-check.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p certification
|
||||
run_id="$TRIGGERING_RUN_ID"
|
||||
if [[ -z "$run_id" ]]; then
|
||||
run_id="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch="$VERSION" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' || true)"
|
||||
fi
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "No certified Build and Release run found for ${VERSION}; failing closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! gh run download "$run_id" -n release-certification -D certification; then
|
||||
echo "Build run ${run_id} published no release-certification artifact; failing closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact-sha256 "$(jq -r '.deliverables[0].sha256' certification/candidate.json)" \
|
||||
--artifact-name "$(jq -r '.deliverables[0].name' certification/candidate.json)"
|
||||
|
||||
# Input digest binding: every zip this image wraps must be the
|
||||
# certified byte-for-byte content, never a mutable latest pointer.
|
||||
while IFS=$'\t' read -r name location expected; do
|
||||
echo "Verifying certified input ${name}"
|
||||
actual="$(curl -fsSL "$location" | sha256sum | awk '{print $1}')"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "Certified input ${name} does not match its recorded digest" >&2
|
||||
exit 1
|
||||
fi
|
||||
done < <(jq -r '.deliverables[] | select(.name | contains("linux")) | [.name, .location, .sha256] | @tsv' certification/candidate.json)
|
||||
|
||||
# Build multi-arch Docker images
|
||||
# Strategy: Build images using pre-built binaries from dl.rustfs.com
|
||||
# Supports both release and dev channel binaries based on build context
|
||||
# Only runs when should_build is true (which includes workflow success check)
|
||||
build-docker:
|
||||
name: Build Docker Images
|
||||
needs: [ build-check, release-certification ]
|
||||
if: needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: musl
|
||||
file: Dockerfile
|
||||
suffix: ""
|
||||
- variant: glibc
|
||||
- variant: gnu
|
||||
file: Dockerfile.glibc
|
||||
suffix: "-glibc"
|
||||
|
||||
env:
|
||||
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
|
||||
VARIANT: ${{ matrix.variant }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ fromJson(inputs.candidate_manifest).source_sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.build-check.outputs.source_ref }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ secrets.GHCR_USERNAME }}
|
||||
password: ${{ secrets.GHCR_PASSWORD }}
|
||||
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_PASSWORD }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Extract metadata and generate tags
|
||||
id: meta
|
||||
- name: Reuse completed image
|
||||
id: reuse
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/release/deliver.py reuse --kind image --target "$VARIANT"
|
||||
- name: Install OCI tools
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
run: |
|
||||
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
||||
VERSION="${{ needs.build-check.outputs.version }}"
|
||||
CREATE_LATEST="${{ needs.build-check.outputs.create_latest }}"
|
||||
VARIANT_SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
# Convert version format for Dockerfile compatibility. The former
|
||||
# DOCKER_CHANNEL was "release" down every branch and was passed as a
|
||||
# build-arg no Dockerfile declares, so it is gone.
|
||||
case "$VERSION" in
|
||||
"latest")
|
||||
DOCKER_RELEASE="latest"
|
||||
;;
|
||||
v*)
|
||||
# For versioned releases (v1.0.0), remove 'v' prefix for Dockerfile
|
||||
DOCKER_RELEASE="${VERSION#v}"
|
||||
;;
|
||||
*)
|
||||
# For other versions, pass as-is
|
||||
DOCKER_RELEASE="${VERSION}"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "docker_release=$DOCKER_RELEASE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "🐳 Docker build parameters:"
|
||||
echo " - Original version: $VERSION"
|
||||
echo " - Docker RELEASE: $DOCKER_RELEASE"
|
||||
|
||||
# Generate tags based on build type
|
||||
# Only support release and prerelease builds (no development builds)
|
||||
TAG_BASE="${VERSION}${VARIANT_SUFFIX}"
|
||||
TAGS="${{ env.REGISTRY_DOCKERHUB }}:$TAG_BASE,${{ env.REGISTRY_GHCR }}:$TAG_BASE,${{ env.REGISTRY_QUAY }}:$TAG_BASE"
|
||||
|
||||
# Add latest when requested (stable releases, and prereleases before GA)
|
||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_GHCR }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_QUAY }}:latest${VARIANT_SUFFIX}"
|
||||
fi
|
||||
|
||||
# Always add the channel tag for prereleases, independent of latest
|
||||
if [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
# Prerelease channel tags (alpha, beta, rc)
|
||||
if [[ "$VERSION" == *"alpha"* ]]; then
|
||||
CHANNEL="alpha"
|
||||
elif [[ "$VERSION" == *"beta"* ]]; then
|
||||
CHANNEL="beta"
|
||||
elif [[ "$VERSION" == *"rc"* ]]; then
|
||||
CHANNEL="rc"
|
||||
fi
|
||||
|
||||
if [[ -n "$CHANNEL" ]]; then
|
||||
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:${CHANNEL}${VARIANT_SUFFIX},${{ env.REGISTRY_GHCR }}:${CHANNEL}${VARIANT_SUFFIX},${{ env.REGISTRY_QUAY }}:${CHANNEL}${VARIANT_SUFFIX}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output tags
|
||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Staging identity for this run's promotion: official tags are only
|
||||
# created after the staged image verifies against the certification,
|
||||
# and they point at this same digest (no rebuild-after-verify).
|
||||
STAGING_TAG="staging-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}${VARIANT_SUFFIX}"
|
||||
STAGING_TAGS="${{ env.REGISTRY_DOCKERHUB }}:${STAGING_TAG},${{ env.REGISTRY_GHCR }}:${STAGING_TAG},${{ env.REGISTRY_QUAY }}:${STAGING_TAG}"
|
||||
echo "staging_tags=$STAGING_TAGS" >> "$GITHUB_OUTPUT"
|
||||
echo "staging_tag=$STAGING_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Generate labels
|
||||
LABELS="org.opencontainers.image.title=RustFS"
|
||||
LABELS="$LABELS,org.opencontainers.image.description=RustFS distributed object storage system"
|
||||
LABELS="$LABELS,org.opencontainers.image.version=$VERSION"
|
||||
SOURCE_REVISION="$(git rev-parse HEAD)"
|
||||
LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"
|
||||
LABELS="$LABELS,org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}"
|
||||
LABELS="$LABELS,org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
LABELS="$LABELS,org.opencontainers.image.build-type=$BUILD_TYPE"
|
||||
|
||||
echo "labels=$LABELS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "🐳 Generated Docker tags:"
|
||||
echo "$TAGS" | tr ',' '\n' | sed 's/^/ - /'
|
||||
echo "📋 Build type: $BUILD_TYPE"
|
||||
echo "🔖 Version: $VERSION"
|
||||
|
||||
- name: Build and push Docker image
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y skopeo
|
||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
- name: Supply accepted binaries and pinned CLI
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/release/deliver.py image-prepare --target "$VARIANT"
|
||||
- name: Build OCI archive without registry writes
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
||||
push: ${{ needs.build-check.outputs.should_push == 'true' }}
|
||||
# Push to staging tags only: official tags are created by the
|
||||
# certification-gated promotion step below from this same digest.
|
||||
tags: ${{ steps.meta.outputs.staging_tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# No layer cache. This build compiles nothing — it downloads a
|
||||
# release zip and runs apk/apt — so the cache could only save the
|
||||
# minute or two those take, while creating a correctness problem: with
|
||||
# RELEASE=latest the binary URL is resolved by curl *inside* a RUN
|
||||
# layer, and the layer key does not include what that resolved to. A
|
||||
# rebuild at the same RELEASE value (dispatch with version=latest, or
|
||||
# a re-run of the same version) would hit the old layer and ship the
|
||||
# previous release's binary. mode=max also consumed the same 10GB
|
||||
# Actions cache quota the Rust lanes are fighting over.
|
||||
#
|
||||
# Only RELEASE is passed: it is the sole build-arg the Dockerfiles
|
||||
# declare besides TARGETARCH. BUILDTIME, VERSION, BUILD_TYPE, REVISION
|
||||
# and CHANNEL were never read by any stage (and BUILDTIME's $(date ...)
|
||||
# was a literal here, not a shell substitution). BUILD_DATE and VCS_REF
|
||||
# are declared by the Dockerfiles but deliberately left unset —
|
||||
# supplying them would change the published image labels.
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: false
|
||||
provenance: false
|
||||
outputs: type=oci,dest=delivery/image-${{ matrix.variant }}.tar
|
||||
build-args: |
|
||||
RELEASE=${{ steps.meta.outputs.docker_release }}
|
||||
provenance: true
|
||||
sbom: true
|
||||
# Add retry mechanism by splitting the build process
|
||||
no-cache: false
|
||||
pull: true
|
||||
|
||||
# A later-built wrapper: the staged image must complete this platform's
|
||||
# own binary identity verification before any official tag is written.
|
||||
# It may not inherit an unverified package's conclusion.
|
||||
- name: Verify staged image against certification
|
||||
if: needs.build-check.outputs.should_push == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_VALIDATION_TOKEN || github.token }}
|
||||
TRIGGERING_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
VERSION: ${{ needs.build-check.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p certification
|
||||
run_id="$TRIGGERING_RUN_ID"
|
||||
if [[ -z "$run_id" ]]; then
|
||||
run_id="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch="$VERSION" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' || true)"
|
||||
fi
|
||||
gh run download "$run_id" -n release-certification -D certification
|
||||
|
||||
STAGING_REF="${{ env.REGISTRY_DOCKERHUB }}:staging-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}${{ matrix.suffix }}"
|
||||
case "${{ matrix.variant }}" in
|
||||
musl) libc="musl" ;;
|
||||
glibc) libc="gnu" ;;
|
||||
esac
|
||||
for platform in linux/amd64 linux/arm64; do
|
||||
case "$platform" in
|
||||
linux/amd64) arch_sub="x86_64" ;;
|
||||
linux/arm64) arch_sub="aarch64" ;;
|
||||
esac
|
||||
docker pull --platform "$platform" "$STAGING_REF"
|
||||
container="$(docker create --platform "$platform" "$STAGING_REF")"
|
||||
docker cp "$container:/usr/bin/rustfs" "$RUNNER_TEMP/staged-rustfs"
|
||||
docker rm "$container" >/dev/null
|
||||
actual="$(sha256sum "$RUNNER_TEMP/staged-rustfs" | awk '{print $1}')"
|
||||
expected="$(jq -r --arg needle "linux-${arch_sub}-${libc}" \
|
||||
'[.deliverables[] | select(.name | contains($needle)) | .binary_sha256][0] // empty' \
|
||||
certification/candidate.json)"
|
||||
if [[ -z "$expected" || "$actual" != "$expected" ]]; then
|
||||
echo "Staged image binary for ${platform} does not match the certified binary digest" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact "$RUNNER_TEMP/staged-rustfs" \
|
||||
--artifact-role binary
|
||||
done
|
||||
|
||||
- name: Promote staged image to official tags
|
||||
if: needs.build-check.outputs.should_push == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STAGING_REF="${{ env.REGISTRY_DOCKERHUB }}:staging-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}${{ matrix.suffix }}"
|
||||
IFS=',' read -r -a official_tags <<< "${{ steps.meta.outputs.tags }}"
|
||||
for tag in "${official_tags[@]}"; do
|
||||
docker buildx imagetools create -t "$tag" "$STAGING_REF"
|
||||
echo "Promoted $tag -> $STAGING_REF"
|
||||
done
|
||||
|
||||
# Downstream consumers (Helm) verify this record instead of inheriting
|
||||
# an unverified wrapper's conclusion.
|
||||
- name: Record image promotion
|
||||
if: needs.build-check.outputs.should_push == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_VALIDATION_TOKEN || github.token }}
|
||||
TRIGGERING_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
VERSION: ${{ needs.build-check.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p certification
|
||||
run_id="$TRIGGERING_RUN_ID"
|
||||
if [[ -z "$run_id" ]]; then
|
||||
run_id="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch="$VERSION" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' || true)"
|
||||
fi
|
||||
gh run download "$run_id" -n release-certification -D certification
|
||||
STAGING_REF="${{ env.REGISTRY_DOCKERHUB }}:staging-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}${{ matrix.suffix }}"
|
||||
digest="$(docker buildx imagetools inspect "$STAGING_REF" --format '{{json .Manifest}}' | jq -r '.digest')"
|
||||
candidate_sha="$(python3 -c "import json,hashlib,sys; sys.path.insert(0,'scripts/release'); import release_gate; print(release_gate.canonical_sha256(json.load(open('certification/candidate.json'))))")"
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg digest "$digest" \
|
||||
--arg staging_ref "$STAGING_REF" \
|
||||
--arg candidate_sha256 "$candidate_sha" \
|
||||
--arg tags "${{ steps.meta.outputs.tags }}" \
|
||||
--slurpfile candidate certification/candidate.json \
|
||||
'{schema_version: 1, version: $version, image_digest: $digest, staging_ref: $staging_ref,
|
||||
promoted_tags: ($tags | split(",")), candidate_sha256: $candidate_sha256,
|
||||
binary_sha256s: ($candidate[0].deliverables | map(.binary_sha256) | unique)}' \
|
||||
> image-promotion-record.json
|
||||
|
||||
- name: Upload image promotion record
|
||||
if: needs.build-check.outputs.should_push == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: image-promotion-record${{ matrix.suffix }}
|
||||
path: image-promotion-record.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
# Note: Manifest creation is no longer needed as we only build one variant
|
||||
# Multi-arch manifests are automatically created by docker/build-push-action
|
||||
|
||||
# Report-only container image vulnerability scan
|
||||
scan-docker-image:
|
||||
name: Scan Docker Images
|
||||
needs: [ build-check, build-docker ]
|
||||
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: musl
|
||||
suffix: ""
|
||||
- variant: glibc
|
||||
suffix: "-glibc"
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
# docker/login-action v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ secrets.GHCR_USERNAME }}
|
||||
password: ${{ secrets.GHCR_PASSWORD }}
|
||||
|
||||
- name: Scan image with Trivy
|
||||
# aquasecurity/trivy-action v0.36.0
|
||||
BINARY_SOURCE=supplied
|
||||
RELEASE=${{ fromJson(inputs.candidate_manifest).version }}
|
||||
VCS_REF=${{ fromJson(inputs.candidate_manifest).source_sha }}
|
||||
- name: Verify wrapped binaries and startup on both architectures
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
run: python3 scripts/release/deliver.py image-smoke --target "$VARIANT"
|
||||
- name: Scan amd64 image before sealing
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
|
||||
env:
|
||||
TRIVY_PLATFORM: linux/amd64
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY_GHCR }}:${{ needs.build-check.outputs.version }}${{ matrix.suffix }}
|
||||
input: delivery-scan/image-${{ matrix.variant }}-amd64.tar
|
||||
format: sarif
|
||||
output: trivy-${{ matrix.variant }}.sarif
|
||||
ignore-unfixed: true
|
||||
vuln-type: os,library
|
||||
severity: CRITICAL,HIGH
|
||||
output: delivery/scan-${{ matrix.variant }}-amd64.sarif
|
||||
exit-code: "0" # Keep the repository's report-only vulnerability policy.
|
||||
- name: Scan arm64 image before sealing
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
|
||||
env:
|
||||
TRIVY_PLATFORM: linux/arm64
|
||||
with:
|
||||
input: delivery-scan/image-${{ matrix.variant }}-arm64.tar
|
||||
format: sarif
|
||||
output: delivery/scan-${{ matrix.variant }}-arm64.sarif
|
||||
exit-code: "0"
|
||||
|
||||
# Surface findings in the Security tab; an artifact alone is write-only
|
||||
# reporting nobody reviews.
|
||||
- name: Upload scan results to code scanning
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
- name: Record verified OCI identity
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
run: python3 scripts/release/deliver.py image-record --target "$VARIANT"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.variant }}.sarif
|
||||
category: container-image-${{ matrix.variant }}
|
||||
|
||||
- name: Upload container scan report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: container-image-scan-${{ matrix.variant }}
|
||||
path: trivy-${{ matrix.variant }}.sarif
|
||||
name: image-${{ matrix.variant }}
|
||||
path: delivery/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
# Docker build summary
|
||||
docker-summary:
|
||||
name: Docker Build Summary
|
||||
needs: [ build-check, build-docker ]
|
||||
if: always() && needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Docker build completion summary
|
||||
run: |
|
||||
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
||||
VERSION="${{ needs.build-check.outputs.version }}"
|
||||
CREATE_LATEST="${{ needs.build-check.outputs.create_latest }}"
|
||||
|
||||
echo "🐳 Docker build completed successfully!"
|
||||
echo "📦 Build type: $BUILD_TYPE"
|
||||
echo "🔢 Version: $VERSION"
|
||||
echo "🚀 Strategy: Images using pre-built binaries (release channel only)"
|
||||
echo ""
|
||||
|
||||
case "$BUILD_TYPE" in
|
||||
"preview")
|
||||
echo "🔍 Preview Docker image has been built with ${VERSION} tags"
|
||||
echo "⏭️ Preview images do not update latest or prerelease channels"
|
||||
;;
|
||||
"release")
|
||||
echo "🚀 Release Docker image has been built with ${VERSION} tags"
|
||||
echo "✅ This image is ready for production use"
|
||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
echo "🏷️ Latest tag has been created for stable release"
|
||||
fi
|
||||
;;
|
||||
"prerelease")
|
||||
echo "🧪 Prerelease Docker image has been built with ${VERSION} tags"
|
||||
echo "⚠️ This is a prerelease image - use with caution"
|
||||
# Prereleases move latest until the first stable tag exists (pre-GA policy).
|
||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
echo "🏷️ Latest tag has been created for prerelease: $VERSION"
|
||||
else
|
||||
echo "🚫 Latest tag NOT created for prerelease"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "❌ Unexpected build type: $BUILD_TYPE"
|
||||
;;
|
||||
esac
|
||||
compression-level: 0
|
||||
|
||||
@@ -1,229 +1,26 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Publish helm chart to artifacthub
|
||||
|
||||
# Copyright 2026 RustFS Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
name: Build candidate Helm chart
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ "Build and Release" ]
|
||||
types: [ completed ]
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version to publish, e.g. 1.0.0-beta.1 or v1.0.0-beta.1"
|
||||
required: true
|
||||
default: "1.0.0-beta.1"
|
||||
candidate_manifest:
|
||||
type: string
|
||||
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
actions: read
|
||||
jobs:
|
||||
# Consume the same trusted release certification as every other publication
|
||||
# exit and verify the image promotion record of the Docker workflow. The
|
||||
# chart wraps no binary of its own, so it may only reference an image whose
|
||||
# platform verification already passed; it must not inherit an unverified
|
||||
# package's conclusion. A run without that evidence fails closed here and
|
||||
# never reaches the chart repository.
|
||||
release-certification:
|
||||
package:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.raw_tag }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ fromJson(inputs.candidate_manifest).source_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Normalize release version
|
||||
id: version
|
||||
env:
|
||||
RAW_INPUT: ${{ github.event.inputs.version }}
|
||||
RAW_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
RAW="$RAW_INPUT"
|
||||
else
|
||||
RAW="$RAW_BRANCH"
|
||||
fi
|
||||
case "$RAW" in
|
||||
refs/tags/*) RAW_TAG="${RAW#refs/tags/}" ;;
|
||||
*) RAW_TAG="$RAW" ;;
|
||||
esac
|
||||
echo "raw_tag=$RAW_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download and verify release certification and image record
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_VALIDATION_TOKEN || github.token }}
|
||||
TRIGGERING_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RAW_TAG: ${{ steps.version.outputs.raw_tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p certification image-record
|
||||
|
||||
build_run="$TRIGGERING_RUN_ID"
|
||||
if [[ -z "$build_run" ]]; then
|
||||
build_run="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch="$RAW_TAG" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' || true)"
|
||||
fi
|
||||
if [[ -z "$build_run" ]]; then
|
||||
echo "No certified Build and Release run found for ${RAW_TAG}; failing closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! gh run download "$build_run" -n release-certification -D certification; then
|
||||
echo "Build run ${build_run} published no release-certification artifact; failing closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact-sha256 "$(jq -r '.deliverables[0].sha256' certification/candidate.json)" \
|
||||
--artifact-name "$(jq -r '.deliverables[0].name' certification/candidate.json)"
|
||||
|
||||
docker_run="$(gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/workflows/docker.yml/runs" \
|
||||
-f branch="$RAW_TAG" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' || true)"
|
||||
if [[ -z "$docker_run" ]] || ! gh run download "$docker_run" -n image-promotion-record -D image-record; then
|
||||
echo "No image promotion record for ${RAW_TAG}; a chart may not wrap an unverified image" >&2
|
||||
exit 1
|
||||
fi
|
||||
candidate_sha="$(python3 -c "import json,hashlib,sys; sys.path.insert(0,'scripts/release'); import release_gate; print(release_gate.canonical_sha256(json.load(open('certification/candidate.json'))))")"
|
||||
record_sha="$(jq -r '.candidate_sha256 // empty' image-record/image-promotion-record.json)"
|
||||
if [[ "$record_sha" != "$candidate_sha" ]]; then
|
||||
echo "Image promotion record does not belong to this certification" >&2
|
||||
exit 1
|
||||
fi
|
||||
overlap="$(jq -r --slurpfile candidate certification/candidate.json \
|
||||
'[.binary_sha256s[]] - ($candidate[0].deliverables | map(.binary_sha256)) | length' \
|
||||
image-record/image-promotion-record.json)"
|
||||
if [[ "$overlap" -ne 0 ]]; then
|
||||
echo "Image promotion record carries binary digests outside the certification" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-helm-package:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [ release-certification ]
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
|
||||
(
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
contains(github.event.workflow_run.head_branch, '.') &&
|
||||
!contains(github.event.workflow_run.head_branch, '-preview')
|
||||
)
|
||||
|
||||
outputs:
|
||||
raw_tag: ${{ steps.version.outputs.raw_tag }}
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
chart_version: ${{ steps.version.outputs.chart_version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout release source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || (startsWith(inputs.version, 'refs/tags/') && inputs.version || format('refs/tags/{0}', inputs.version)) }}
|
||||
persist-credentials: false
|
||||
|
||||
# Both inputs reach the shell through env rather than `${{ }}`
|
||||
# interpolation. A git ref name may contain `$(...)` — anything without a
|
||||
# space is a legal tag — and interpolation pastes it into the script
|
||||
# verbatim, where bash would run it. Reading "$RAW_INPUT" instead makes it
|
||||
# data.
|
||||
- name: Normalize release version
|
||||
id: version
|
||||
env:
|
||||
RAW_INPUT: ${{ github.event.inputs.version }}
|
||||
RAW_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
set -eux
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
RAW="$RAW_INPUT"
|
||||
else
|
||||
RAW="$RAW_BRANCH"
|
||||
fi
|
||||
|
||||
case "$RAW" in
|
||||
refs/tags/*)
|
||||
RAW_TAG="${RAW#refs/tags/}"
|
||||
;;
|
||||
*)
|
||||
RAW_TAG="$RAW"
|
||||
;;
|
||||
esac
|
||||
|
||||
./scripts/helm_chart_version.sh "$RAW_TAG"
|
||||
|
||||
# Docker and Helm share the binary-build trigger. Do not publish a chart
|
||||
# until the image selected by its default appVersion is pullable.
|
||||
- name: Wait for release image
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
APP_VERSION: ${{ steps.version.outputs.app_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] ||
|
||||
[[ "$APP_VERSION" == *-preview* ]]; then
|
||||
echo "Invalid deliverable image version: $APP_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for attempt in {1..60}; do
|
||||
if token=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 20 \
|
||||
'https://auth.docker.io/token?service=registry.docker.io&scope=repository:rustfs/rustfs:pull' \
|
||||
| jq -er '.token // empty') &&
|
||||
curl --fail --silent --show-error --head --connect-timeout 10 --max-time 20 \
|
||||
--header "Authorization: Bearer $token" \
|
||||
--header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \
|
||||
"https://registry-1.docker.io/v2/rustfs/rustfs/manifests/$APP_VERSION" > /dev/null; then
|
||||
echo "Release image is available: rustfs/rustfs:$APP_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for rustfs/rustfs:$APP_VERSION ($attempt/60)"
|
||||
if (( attempt < 60 )); then
|
||||
sleep 15
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Release image is unavailable; refusing to publish Helm chart" >&2
|
||||
exit 1
|
||||
|
||||
- name: Replace chart version and app version
|
||||
env:
|
||||
CHART_VERSION: ${{ steps.version.outputs.chart_version }}
|
||||
APP_VERSION: ${{ steps.version.outputs.app_version }}
|
||||
run: |
|
||||
set -eux
|
||||
sed -i -E "s/^version:.*/version: \"${CHART_VERSION}\"/" helm/rustfs/Chart.yaml
|
||||
sed -i -E "s/^appVersion:.*/appVersion: \"${APP_VERSION}\"/" helm/rustfs/Chart.yaml
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
||||
|
||||
- name: Install yq
|
||||
- uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
||||
- name: Install pinned yq
|
||||
run: |
|
||||
set -euo pipefail
|
||||
yq_version="4.54.1"
|
||||
@@ -238,66 +35,43 @@ jobs:
|
||||
echo "$tools_dir" >> "$GITHUB_PATH"
|
||||
"$tools_dir/yq" --version
|
||||
|
||||
- name: Test Helm Chart Templates
|
||||
run: ./scripts/test_helm_templates.sh
|
||||
|
||||
- name: Package Helm Chart
|
||||
run: |
|
||||
set -eux
|
||||
cp helm/README.md helm/rustfs/
|
||||
helm package ./helm/rustfs \
|
||||
--destination helm/rustfs/ \
|
||||
--version "${{ steps.version.outputs.chart_version }}"
|
||||
|
||||
- name: Upload helm package as artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: helm-package
|
||||
path: |
|
||||
helm/rustfs/*.tgz
|
||||
helm/rustfs/README.md
|
||||
retention-days: 1
|
||||
|
||||
publish-helm-package:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [ build-helm-package ]
|
||||
if: needs.build-helm-package.result == 'success'
|
||||
|
||||
steps:
|
||||
- name: Checkout helm package repo
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
# persist-credentials-exempt: this checkout's token IS the push credential —
|
||||
# the job git-pushes to rustfs/helm below. Clearing it breaks chart publishing.
|
||||
repository: rustfs/helm
|
||||
token: ${{ secrets.RUSTFS_HELM_PACKAGE }}
|
||||
|
||||
- name: Download helm package
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: helm-package
|
||||
path: ./
|
||||
|
||||
- name: Set up helm
|
||||
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
||||
|
||||
- name: Generate index
|
||||
run: helm repo index . --url https://charts.rustfs.com
|
||||
|
||||
# app_version is derived from the triggering tag name, and this job holds
|
||||
# the cross-repository push token with rustfs/helm already checked out —
|
||||
# the worst place in the repo to paste an attacker-influenced string into
|
||||
# a shell line. Passed through env so bash treats it as data.
|
||||
- name: Push helm package and index file
|
||||
- name: Test and package the exact application version
|
||||
id: chart
|
||||
env:
|
||||
GIT_USERNAME: ${{ secrets.USERNAME }}
|
||||
GIT_EMAIL: ${{ secrets.EMAIL_ADDRESS }}
|
||||
APP_VERSION: ${{ needs.build-helm-package.outputs.app_version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
|
||||
run: |
|
||||
set -eux
|
||||
git config --global user.name "${GIT_USERNAME}"
|
||||
git config --global user.email "${GIT_EMAIL}"
|
||||
git add .
|
||||
git commit -m "Update rustfs helm package with ${APP_VERSION}." || echo "No changes to commit"
|
||||
git push origin main
|
||||
python3 - <<'PYCHART'
|
||||
import sys, subprocess, shutil
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, 'scripts/release')
|
||||
import candidate as c, pipeline
|
||||
inputs = pipeline.manifest_env()
|
||||
current = pipeline.current_run('release-complete.yml')
|
||||
if any(a['name'] == 'helm-package' for a in c.artifact_list(current['id'])):
|
||||
c.download_artifact(current, 'helm-package', 'chart')
|
||||
record = c.read_json('chart/chart.json')
|
||||
c.require(record['inputs_sha256'] == c.digest(inputs), 'chart belongs to another candidate')
|
||||
c.emit_output('reused', 'true')
|
||||
else:
|
||||
chart = Path('helm/rustfs/Chart.yaml')
|
||||
import re
|
||||
value = chart.read_text()
|
||||
for key in ('version', 'appVersion'):
|
||||
value, count = re.subn(rf'(?m)^{key}:.*$', f'{key}: "{inputs["version"]}"', value)
|
||||
c.require(count == 1, 'ambiguous chart metadata')
|
||||
chart.write_text(value)
|
||||
subprocess.run(['bash', 'scripts/test_helm_templates.sh'], check=True)
|
||||
shutil.copyfile('helm/README.md', 'helm/rustfs/README.md')
|
||||
subprocess.run(['helm', 'package', 'helm/rustfs', '--destination', 'chart'], check=True)
|
||||
path = Path('chart') / f"rustfs-{inputs['version']}.tgz"
|
||||
c.write_json('chart/chart.json', {'version': inputs['version'], 'inputs_sha256': c.digest(inputs),
|
||||
'name': path.name, 'sha256': c.file_digest(path), 'size': path.stat().st_size})
|
||||
PYCHART
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: steps.chart.outputs.reused != 'true'
|
||||
with:
|
||||
name: helm-package
|
||||
path: chart/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Manual packaging of the current origin/main tip. Workflow artifacts only.
|
||||
# Dispatch uses this file from the selected branch. The build fetches
|
||||
# refs/heads/main and checks out that commit, not the dispatch ref or a tag.
|
||||
|
||||
name: Manual main packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
architecture:
|
||||
description: "Linux targets: x86 (x86_64 musl+gnu), arm (aarch64 musl+gnu), or all"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- x86
|
||||
- arm
|
||||
- all
|
||||
default: all
|
||||
version:
|
||||
description: "Binary and package version: MAJOR.MINOR.PATCH, or MAJOR.MINOR.PATCH-(alpha|beta|rc).N. No leading v."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Resolve main tip and version
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
source_sha: ${{ steps.pin.outputs.source_sha }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
toolchain: ${{ steps.inputs.outputs.toolchain }}
|
||||
console: ${{ steps.inputs.outputs.console }}
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Pin the tip of origin/main
|
||||
id: pin
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GIT_TERMINAL_PROMPT=0 git fetch --no-tags origin refs/heads/main
|
||||
sha=$(git rev-parse FETCH_HEAD)
|
||||
git checkout --detach "$sha"
|
||||
test "$(git rev-parse HEAD)" = "$sha"
|
||||
printf 'source_sha=%s\n' "$sha" >> "$GITHUB_OUTPUT"
|
||||
printf 'Pinned origin/main %s\n' "$sha"
|
||||
|
||||
- name: Validate package version
|
||||
id: version
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash scripts/release/package_manual_artifacts.sh validate \
|
||||
--version "$PACKAGE_VERSION" \
|
||||
--arch x86_64 \
|
||||
--libc gnu | tee "$RUNNER_TEMP/package-version.txt"
|
||||
printf 'version=%s\n' "$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install stable Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Resolve console assets and toolchain
|
||||
id: inputs
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
toolchain=$(rustc --version | awk '{print $2}')
|
||||
[[ "$toolchain" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
gh api repos/rustfs/console/releases/latest > console-release.json
|
||||
TOOLCHAIN="$toolchain" python3 - <<'PY'
|
||||
import json, os, sys
|
||||
sys.path.insert(0, "scripts/release")
|
||||
import candidate
|
||||
console = candidate.console_asset(candidate.read_json("console-release.json"))
|
||||
candidate.emit_output("console", json.dumps(console, separators=(",", ":")))
|
||||
candidate.emit_output("toolchain", os.environ["TOOLCHAIN"])
|
||||
print(f"toolchain={os.environ['TOOLCHAIN']}")
|
||||
print(f"console={console['tag']} {console['name']}")
|
||||
PY
|
||||
|
||||
- name: Select Linux targets
|
||||
id: matrix
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURE: ${{ inputs.architecture }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
matrix=$(bash scripts/release/package_manual_artifacts.sh matrix "$ARCHITECTURE")
|
||||
count=$(python3 -c 'import json,sys; print(len(json.loads(sys.argv[1])["include"]))' "$matrix")
|
||||
[[ "$count" -gt 0 ]]
|
||||
printf 'matrix=%s\n' "$matrix" >> "$GITHUB_OUTPUT"
|
||||
printf 'Selected %s target(s) for %s\n' "$count" "$ARCHITECTURE"
|
||||
|
||||
package:
|
||||
name: Package RustFS (${{ matrix.target_id }})
|
||||
needs: prepare
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
# x86_64 uses sm-standard-4, the official release compile pool: the workspace
|
||||
# release build needs that disk (placement rule: disk) and reads build.yml's
|
||||
# cache without writing it. aarch64 uses ubicloud-standard-16-arm so the
|
||||
# packaged binary and console smoke execute natively (placement rule: hardware).
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 210
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
RUSTUP_TOOLCHAIN: ${{ needs.prepare.outputs.toolchain }}
|
||||
RUSTFLAGS: "${{ matrix.rustflags }}"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout the pinned origin/main commit
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.source_sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Confirm the checkout is that main commit
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
|
||||
GIT_TERMINAL_PROMPT=0 git fetch --no-tags origin refs/heads/main
|
||||
git merge-base --is-ancestor "$SOURCE_SHA" FETCH_HEAD
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: ${{ needs.prepare.outputs.toolchain }}
|
||||
target: ${{ matrix.target }}
|
||||
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}${{ matrix.os == 'ubicloud-standard-16-arm' && '-ubicloud-arm' || '' }}
|
||||
# build.yml owns this key. This job only restores it.
|
||||
cache-save-if: 'false'
|
||||
install-cross-tools: ${{ matrix.cross }}
|
||||
install-build-packaging-tools: 'true'
|
||||
install-test-tools: 'false'
|
||||
|
||||
- name: Download static console assets
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CONSOLE_IDENTITY: ${{ needs.prepare.outputs.console }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PYCONSOLE'
|
||||
import json, os, pathlib, subprocess, zipfile
|
||||
value = json.loads(os.environ["CONSOLE_IDENTITY"])
|
||||
subprocess.run(["python3", ".github/actions/setup/download_release_asset.py",
|
||||
"--repository", "rustfs/console", "--tag", value["tag"], "--asset", value["name"],
|
||||
"--sha256", value["sha256"], "--output", "console.zip"], check=True)
|
||||
destination = pathlib.Path("rustfs/static")
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile("console.zip") as archive:
|
||||
for member in archive.infolist():
|
||||
path = pathlib.PurePosixPath(member.filename)
|
||||
if path.is_absolute() or ".." in path.parts or "\\" in member.filename:
|
||||
raise SystemExit("unsafe Console archive member")
|
||||
archive.extractall(destination)
|
||||
if not (destination / "index.html").is_file():
|
||||
raise SystemExit("Console archive has no index.html")
|
||||
PYCONSOLE
|
||||
rm -f console.zip
|
||||
|
||||
- name: Build RustFS
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFS_BUILD_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
BUILD_OS: ${{ matrix.build_os }}
|
||||
BUILD_FEATURES: ${{ matrix.features }}
|
||||
BUILD_GLIBC: ${{ matrix.glibc }}
|
||||
BUILD_CROSS: ${{ matrix.cross }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
touch rustfs/build.rs
|
||||
|
||||
feature_args=()
|
||||
if [[ -n "$BUILD_FEATURES" ]]; then
|
||||
feature_args=(--features "$BUILD_FEATURES")
|
||||
fi
|
||||
|
||||
# gnu targets pin glibc 2.31 through cargo-zigbuild (<target>.<glibc>).
|
||||
target_arg="$BUILD_TARGET"
|
||||
if [[ -n "$BUILD_GLIBC" ]]; then
|
||||
target_arg="${target_arg}.${BUILD_GLIBC}"
|
||||
fi
|
||||
|
||||
profile_args=()
|
||||
if [[ "$BUILD_TARGET" == *-linux-gnu && "$BUILD_FEATURES" == pyroscope ]]; then
|
||||
profile_args=(--config 'profile.release.strip="none"')
|
||||
fi
|
||||
|
||||
case "$BUILD_CROSS" in
|
||||
true)
|
||||
cargo zigbuild --release --locked --target "$target_arg" "${feature_args[@]}" "${profile_args[@]}" -p rustfs --bin rustfs
|
||||
;;
|
||||
false)
|
||||
cargo build --release --locked --target "$target_arg" "${feature_args[@]}" "${profile_args[@]}" -p rustfs --bin rustfs
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected cross flag: %q\n' "$BUILD_CROSS" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
bin="target/${BUILD_TARGET}/release/rustfs"
|
||||
version_output=$("$bin" --version)
|
||||
first=${version_output%%$'\n'*}
|
||||
if [[ "$first" != "rustfs ${RUSTFS_BUILD_VERSION}" ]]; then
|
||||
printf 'binary version line is %q, expected %q\n' "$first" "rustfs ${RUSTFS_BUILD_VERSION}" >&2
|
||||
printf '%s\n' "$version_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fxq "build target : ${BUILD_TARGET}" <<< "$version_output" || \
|
||||
! grep -Fxq "build os : ${BUILD_OS}" <<< "$version_output" || \
|
||||
! grep -Fxq "rust channel : stable-${BUILD_TARGET}" <<< "$version_output"; then
|
||||
printf 'release binary reports an unexpected compile target\n' >&2
|
||||
printf '%s\n' "$version_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$BUILD_GLIBC" ]]; then
|
||||
python3 scripts/release/check_glibc.py "$BUILD_GLIBC" "$bin"
|
||||
fi
|
||||
|
||||
- name: Generate the CPU symbol catalog
|
||||
if: endsWith(matrix.target, '-linux-gnu') && matrix.features == 'pyroscope'
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo metadata --locked --format-version 1 --filter-platform "$BUILD_TARGET" \
|
||||
--features rustfs/pyroscope > "$RUNNER_TEMP/cpu-catalog-metadata.json"
|
||||
features=$(jq -ce '
|
||||
(.packages[] | select(.name == "rustfs") | .id) as $id |
|
||||
.resolve.nodes[] | select(.id == $id) | .features | map(select(. != "default")) | sort
|
||||
' "$RUNNER_TEMP/cpu-catalog-metadata.json")
|
||||
release_dir="target/${BUILD_TARGET}/release"
|
||||
rm -f "$release_dir/rustfs.cpu-symbol-catalog.json" "$release_dir/rustfs.cpu-symbol-catalog.sha256"
|
||||
cargo run --locked -p rustfs-connect-symbol-catalog -- \
|
||||
"$release_dir/rustfs" "$(git rev-parse HEAD)" "$BUILD_TARGET" "$features" \
|
||||
"$release_dir/rustfs.cpu-symbol-catalog.json" > "$release_dir/rustfs.cpu-symbol-catalog.sha256"
|
||||
|
||||
- name: Install DEB and RPM tools
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ruby ruby-dev build-essential rpm fakeroot cpio
|
||||
sudo gem install fpm --version 1.17.0 --no-document
|
||||
|
||||
- name: Package zip, deb, and rpm
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
LIBC: ${{ matrix.libc }}
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
BUILD_FEATURES: ${{ matrix.features }}
|
||||
SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
binary="target/${BUILD_TARGET}/release/rustfs"
|
||||
extra=()
|
||||
if [[ "$BUILD_TARGET" == *-linux-gnu && "$BUILD_FEATURES" == pyroscope ]]; then
|
||||
extra=(
|
||||
--zip-extra "target/${BUILD_TARGET}/release/rustfs.cpu-symbol-catalog.json"
|
||||
--zip-extra "target/${BUILD_TARGET}/release/rustfs.cpu-symbol-catalog.sha256"
|
||||
)
|
||||
fi
|
||||
bash scripts/release/package_manual_artifacts.sh package \
|
||||
--version "$PACKAGE_VERSION" \
|
||||
--arch "$ARCH" \
|
||||
--libc "$LIBC" \
|
||||
--binary "$binary" \
|
||||
--output dist \
|
||||
"${extra[@]}"
|
||||
printf 'Packaged %s from origin/main %s\n' "$PACKAGE_VERSION" "$SOURCE_SHA"
|
||||
|
||||
- name: Verify the packaged binary
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
LIBC: ${{ matrix.libc }}
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
BUILD_OS: ${{ matrix.build_os }}
|
||||
BUILD_FEATURES: ${{ matrix.features }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
stem="rustfs-linux-${ARCH}-${LIBC}-v${PACKAGE_VERSION}"
|
||||
package_file="dist/${stem}.zip"
|
||||
[[ -f "$package_file" && -f "dist/${stem}.deb" && -f "dist/${stem}.rpm" ]]
|
||||
[[ "$(find dist -maxdepth 1 -type f | wc -l)" -eq 3 ]]
|
||||
archive_members=$(unzip -Z1 "$package_file" | sort)
|
||||
if [[ "$BUILD_TARGET" == *-linux-gnu && "$BUILD_FEATURES" == pyroscope ]]; then
|
||||
expected_members=$(printf '%s\n' rustfs rustfs.cpu-symbol-catalog.json rustfs.cpu-symbol-catalog.sha256 | sort)
|
||||
else
|
||||
expected_members=$(printf '%s\n' rustfs)
|
||||
fi
|
||||
if [[ "$archive_members" != "$expected_members" ]]; then
|
||||
printf 'Linux package does not contain the expected files\n' >&2
|
||||
printf '%s\n' "$archive_members" >&2
|
||||
exit 1
|
||||
fi
|
||||
binary_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$binary_dir"' EXIT
|
||||
unzip -q "$package_file" rustfs -d "$binary_dir"
|
||||
version_output=$("$binary_dir/rustfs" --version)
|
||||
first=${version_output%%$'\n'*}
|
||||
if [[ "$first" != "rustfs ${PACKAGE_VERSION}" ]]; then
|
||||
printf 'packaged binary version line is %q, expected %q\n' "$first" "rustfs ${PACKAGE_VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fxq "build target : ${BUILD_TARGET}" <<< "$version_output" || \
|
||||
! grep -Fxq "build os : ${BUILD_OS}" <<< "$version_output"; then
|
||||
printf 'packaged binary reports an unexpected compile target\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify packaged console
|
||||
if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
LIBC: ${{ matrix.libc }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_file="dist/rustfs-linux-${ARCH}-${LIBC}-v${PACKAGE_VERSION}.zip"
|
||||
|
||||
find_free_port() {
|
||||
python3 - <<'PY'
|
||||
import socket
|
||||
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
print(sock.getsockname()[1])
|
||||
PY
|
||||
}
|
||||
|
||||
package_dir=$(mktemp -d)
|
||||
data_dir=$(mktemp -d)
|
||||
log_file="${RUNNER_TEMP}/rustfs-console-smoke.log"
|
||||
server_pid=""
|
||||
trap '
|
||||
if [[ -n "${server_pid:-}" ]]; then
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
wait "$server_pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$package_dir" "$data_dir"
|
||||
' EXIT
|
||||
|
||||
unzip -q "$package_file" -d "$package_dir"
|
||||
api_port=$(find_free_port)
|
||||
console_port=$(find_free_port)
|
||||
console_url="http://127.0.0.1:${console_port}/rustfs/console/"
|
||||
|
||||
"$package_dir/rustfs" server \
|
||||
--address "127.0.0.1:${api_port}" \
|
||||
--console-enable \
|
||||
--console-address "127.0.0.1:${console_port}" \
|
||||
--access-key console-smoke \
|
||||
--secret-key console-smoke-secret \
|
||||
"$data_dir" >"$log_file" 2>&1 &
|
||||
server_pid=$!
|
||||
|
||||
for _ in {1..40}; do
|
||||
response=$(curl --silent --output /dev/null --write-out '%{http_code} %{content_type}' "$console_url" || true)
|
||||
if [[ "$response" == 200\ text/html* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
|
||||
printf 'Console endpoint did not return 200 text/html: %s\n' "$response" >&2
|
||||
cat "$log_file"
|
||||
exit 1
|
||||
|
||||
- name: Upload packages
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packages-${{ matrix.target_id }}-v${{ needs.prepare.outputs.version }}
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 30
|
||||
@@ -112,9 +112,11 @@ jobs:
|
||||
# GitHub-hosted: reliably provides a running Docker daemon + buildx +
|
||||
# python3. See the header note (ci-2) for why the self-hosted sm-standard-4
|
||||
# label was abandoned (no docker.sock on the pod its only run landed on).
|
||||
# sm-standard-2 has no docker.sock either (weekly runs failed at "Enable
|
||||
# buildx" from 2026-09-27 to 2026-11-08), so keep this job on ubuntu-latest.
|
||||
# TODO(ci-8): scheduled-failure alerting (auto-open issue on a red weekly
|
||||
# run) is added by the ci-8 composite action; do not implement it here.
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
|
||||
@@ -47,8 +47,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: oidc-keycloak-live-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: oidc-keycloak-live-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||
|
||||
jobs:
|
||||
oidc-keycloak-live:
|
||||
|
||||
+38
-825
@@ -1,840 +1,53 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Package Workflow - Build DEB/RPM packages
|
||||
#
|
||||
# This workflow builds DEB and RPM packages from pre-built Linux binaries
|
||||
# and uploads them to Cloudflare R2 and the GitHub release.
|
||||
#
|
||||
# Trigger:
|
||||
# - workflow_run: automatically package after "Build and Release" completes
|
||||
# for a release tag (the mac/windows/linux binaries are already uploaded
|
||||
# to the GitHub release before packaging starts)
|
||||
# - workflow_dispatch: manual fallback with a release tag and/or exact build run ID
|
||||
#
|
||||
# Flow:
|
||||
# 1. Resolve and validate the selected Build workflow run and source identity
|
||||
# 2. Download Linux binaries (x86_64-gnu, aarch64-gnu, x86_64-musl,
|
||||
# aarch64-musl) from build artifacts
|
||||
# 3. Build DEB packages for amd64 and arm64, gnu and musl variants
|
||||
# 4. Build RPM packages for x86_64 and aarch64, gnu and musl variants
|
||||
# 5. Upload all packages to Cloudflare R2 and the GitHub release
|
||||
|
||||
name: Package DEB/RPM
|
||||
|
||||
permissions:
|
||||
# contents: write is required to upload packages to the GitHub release
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
# Copyright 2026 RustFS Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
name: Build candidate packages
|
||||
on:
|
||||
# Follows the same pattern as docker.yml: run after the release build
|
||||
# workflow completes, so packaging is triggered only by release tags
|
||||
# (e.g. 1.0.0-rc.2, 1.0.0-rc.3), never by development builds.
|
||||
workflow_run:
|
||||
workflows: [ "Build and Release" ]
|
||||
types: [ completed ]
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to package (e.g. 1.0.0-beta.12). Leave empty for latest main build."
|
||||
required: false
|
||||
candidate_manifest:
|
||||
type: string
|
||||
build_run_id:
|
||||
description: "Build workflow run ID (when combined with tag, both must identify the same release commit)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.event.inputs.tag || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
jobs:
|
||||
# Resolve which build run to use and extract version info
|
||||
resolve:
|
||||
name: Resolve Build
|
||||
# Auto-trigger only from successful tag builds of "Build and Release".
|
||||
# Tag pushes arrive as event == push with head_branch != main (a
|
||||
# non-main push head_branch is the release tag name). Manual dispatch
|
||||
# stays available as a fallback for backfills and re-runs.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch != 'main')
|
||||
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
version: ${{ steps.resolve.outputs.version }}
|
||||
build_type: ${{ steps.resolve.outputs.build_type }}
|
||||
build_run_id: ${{ steps.resolve.outputs.build_run_id }}
|
||||
build_run_number: ${{ steps.resolve.outputs.build_run_number }}
|
||||
head_sha: ${{ steps.resolve.outputs.head_sha }}
|
||||
dev_sequence: ${{ steps.resolve.outputs.dev_sequence }}
|
||||
tag: ${{ steps.resolve.outputs.tag }}
|
||||
steps:
|
||||
- name: Resolve build run
|
||||
id: resolve
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
INPUT_RUN_ID: ${{ github.event.inputs.build_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
fail() {
|
||||
echo "❌ $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
TAG=""
|
||||
BUILD_RUN_ID=""
|
||||
case "$EVENT_NAME" in
|
||||
workflow_run)
|
||||
TAG="$HEAD_BRANCH"
|
||||
BUILD_RUN_ID="$WORKFLOW_RUN_ID"
|
||||
;;
|
||||
workflow_dispatch)
|
||||
TAG="$INPUT_TAG"
|
||||
BUILD_RUN_ID="$INPUT_RUN_ID"
|
||||
;;
|
||||
*) fail "unsupported event: $EVENT_NAME" ;;
|
||||
esac
|
||||
|
||||
# Validate and classify tags before using them in API paths or logs.
|
||||
semver_core='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
|
||||
prerelease_id='(alpha|beta|rc)\.(0|[1-9][0-9]*)'
|
||||
if [[ -n "$TAG" ]]; then
|
||||
if [[ "$TAG" =~ ^${semver_core}(-${prerelease_id})?-preview\.(0|[1-9][0-9]*)$ ]]; then
|
||||
BUILD_TYPE=preview
|
||||
elif [[ "$TAG" =~ ^${semver_core}-${prerelease_id}$ ]]; then
|
||||
BUILD_TYPE=prerelease
|
||||
elif [[ "$TAG" =~ ^${semver_core}$ ]]; then
|
||||
BUILD_TYPE=release
|
||||
else
|
||||
fail "tag is not a supported strict package version"
|
||||
fi
|
||||
else
|
||||
BUILD_TYPE=development
|
||||
fi
|
||||
|
||||
if [[ -n "$BUILD_RUN_ID" ]]; then
|
||||
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "build run ID must be a positive decimal integer"
|
||||
echo "Using selected build run: $BUILD_RUN_ID"
|
||||
elif [[ -n "$TAG" ]]; then
|
||||
echo "Looking for build run for tag: $TAG"
|
||||
BUILD_RUN_ID=$(gh api --method GET \
|
||||
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch="$TAG" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$BUILD_RUN_ID" ]]; then
|
||||
BUILD_RUN_ID=$(gh api --method GET \
|
||||
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f event=push -f status=success -F per_page=100 2>/dev/null |
|
||||
jq -r --arg tag "$TAG" \
|
||||
'[.workflow_runs[] | select(.head_branch == $tag)][0].id // empty' || true)
|
||||
fi
|
||||
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "no successful build run found for tag"
|
||||
echo "Found build run: $BUILD_RUN_ID"
|
||||
else
|
||||
echo "No tag specified, looking for latest main build"
|
||||
BUILD_RUN_ID=$(gh api --method GET \
|
||||
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch=main -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' 2>/dev/null || true)
|
||||
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "no successful main build found"
|
||||
echo "Latest main build: $BUILD_RUN_ID"
|
||||
fi
|
||||
|
||||
# Fetch once and use the same immutable run metadata for identity,
|
||||
# ordering, workflow provenance, and release-channel validation.
|
||||
RUN_JSON=$(gh api "repos/${REPOSITORY}/actions/runs/${BUILD_RUN_ID}") ||
|
||||
fail "cannot read selected build run"
|
||||
RUN_ID=$(jq -r '.id // empty' <<<"$RUN_JSON")
|
||||
RUN_NUMBER=$(jq -r '.run_number // empty' <<<"$RUN_JSON")
|
||||
RUN_STATUS=$(jq -r '.status // empty' <<<"$RUN_JSON")
|
||||
RUN_CONCLUSION=$(jq -r '.conclusion // empty' <<<"$RUN_JSON")
|
||||
RUN_PATH=$(jq -r '.path // empty' <<<"$RUN_JSON")
|
||||
HEAD_SHA=$(jq -r '.head_sha // empty' <<<"$RUN_JSON")
|
||||
RUN_HEAD_BRANCH=$(jq -r '.head_branch // empty' <<<"$RUN_JSON")
|
||||
|
||||
[[ "$RUN_ID" == "$BUILD_RUN_ID" ]] || fail "run metadata ID mismatch"
|
||||
[[ "$RUN_NUMBER" =~ ^[1-9][0-9]*$ ]] || fail "build run number must be a positive decimal integer"
|
||||
[[ "$RUN_STATUS" == completed && "$RUN_CONCLUSION" == success ]] || fail "selected build run is not successful"
|
||||
[[ "$RUN_PATH" == .github/workflows/build.yml ]] || fail "selected run is not Build and Release"
|
||||
[[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || fail "selected build run has an invalid head SHA"
|
||||
[[ "$RUN_HEAD_BRANCH" != *$'\n'* && -n "$RUN_HEAD_BRANCH" ]] || fail "selected build run has an invalid head branch"
|
||||
|
||||
if [[ -n "$TAG" ]]; then
|
||||
[[ "$RUN_HEAD_BRANCH" == "$TAG" ]] || fail "tag and build run head branch do not match"
|
||||
|
||||
TAG_REF_JSON=$(gh api "repos/${REPOSITORY}/git/ref/tags/${TAG}") ||
|
||||
fail "cannot resolve release tag ref"
|
||||
TAG_OBJECT_TYPE=$(jq -r '.object.type // empty' <<<"$TAG_REF_JSON")
|
||||
TAG_OBJECT_SHA=$(jq -r '.object.sha // empty' <<<"$TAG_REF_JSON")
|
||||
depth=0
|
||||
while [[ "$TAG_OBJECT_TYPE" == tag && $depth -lt 5 ]]; do
|
||||
TAG_OBJECT_JSON=$(gh api "repos/${REPOSITORY}/git/tags/${TAG_OBJECT_SHA}") ||
|
||||
fail "cannot peel annotated release tag"
|
||||
TAG_OBJECT_TYPE=$(jq -r '.object.type // empty' <<<"$TAG_OBJECT_JSON")
|
||||
TAG_OBJECT_SHA=$(jq -r '.object.sha // empty' <<<"$TAG_OBJECT_JSON")
|
||||
depth=$((depth + 1))
|
||||
done
|
||||
[[ "$TAG_OBJECT_TYPE" == commit && "$TAG_OBJECT_SHA" =~ ^[0-9a-f]{40}$ ]] ||
|
||||
fail "release tag does not resolve to a commit"
|
||||
[[ "$TAG_OBJECT_SHA" == "$HEAD_SHA" ]] || fail "release tag commit and build run head SHA do not match"
|
||||
VERSION="$TAG"
|
||||
DEV_SEQUENCE=""
|
||||
else
|
||||
VERSION="dev-${HEAD_SHA}"
|
||||
DEV_SEQUENCE="$RUN_NUMBER"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=$VERSION"
|
||||
echo "build_type=$BUILD_TYPE"
|
||||
echo "build_run_id=$BUILD_RUN_ID"
|
||||
echo "build_run_number=$RUN_NUMBER"
|
||||
echo "head_sha=$HEAD_SHA"
|
||||
echo "dev_sequence=$DEV_SEQUENCE"
|
||||
echo "tag=${TAG}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "📊 Resolved:"
|
||||
echo " Version: $VERSION"
|
||||
echo " Build type: $BUILD_TYPE"
|
||||
echo " Build run ID: $BUILD_RUN_ID"
|
||||
echo " Build run number: $RUN_NUMBER"
|
||||
|
||||
# Consume the trusted release certification of the resolved build run and
|
||||
# verify the certified input digests before any package is wrapped. A run
|
||||
# without that certification fails closed here; packages built later must
|
||||
# complete their own verification and cannot inherit an unverified
|
||||
# conclusion.
|
||||
release-certification:
|
||||
name: Release Certification
|
||||
needs: resolve
|
||||
if: needs.resolve.result == 'success'
|
||||
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download and verify release certification
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_VALIDATION_TOKEN || github.token }}
|
||||
BUILD_RUN_ID: ${{ needs.resolve.outputs.build_run_id }}
|
||||
BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p certification
|
||||
if [[ "$BUILD_TYPE" == "development" ]]; then
|
||||
# Development artifacts are explicitly uncertified and never touch
|
||||
# official or latest channels; only the staging/dev paths remain.
|
||||
jq -n '{schema_version: 1, certified: false, reason: "development artifacts are explicitly uncertified"}' \
|
||||
> certification/development-uncertified.json
|
||||
exit 0
|
||||
fi
|
||||
if ! gh run download "$BUILD_RUN_ID" -n release-certification -D certification; then
|
||||
echo "Build run ${BUILD_RUN_ID} published no release-certification artifact; failing closed" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact-sha256 "$(jq -r '.deliverables[0].sha256' certification/candidate.json)" \
|
||||
--artifact-name "$(jq -r '.deliverables[0].name' certification/candidate.json)"
|
||||
|
||||
# Build DEB and RPM packages for each architecture and libc variant
|
||||
package:
|
||||
name: Package (${{ matrix.arch }}-${{ matrix.libc }})
|
||||
needs: [ resolve, release-certification ]
|
||||
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
include:
|
||||
- arch: x86_64
|
||||
libc: gnu
|
||||
deb_arch: amd64
|
||||
rpm_arch: x86_64
|
||||
artifact_name: "rustfs-linux-x86_64-gnu"
|
||||
# gnu binaries dynamically link glibc; musl binaries are static
|
||||
# and run without a libc dependency.
|
||||
deb_depends: "libc6 (>= 2.31)"
|
||||
rpm_depends: "glibc >= 2.31"
|
||||
- arch: aarch64
|
||||
libc: gnu
|
||||
deb_arch: arm64
|
||||
rpm_arch: aarch64
|
||||
artifact_name: "rustfs-linux-aarch64-gnu"
|
||||
deb_depends: "libc6 (>= 2.31)"
|
||||
rpm_depends: "glibc >= 2.31"
|
||||
- arch: x86_64
|
||||
libc: musl
|
||||
deb_arch: amd64
|
||||
rpm_arch: x86_64
|
||||
artifact_name: "rustfs-linux-x86_64-musl"
|
||||
deb_depends: ""
|
||||
rpm_depends: ""
|
||||
- arch: aarch64
|
||||
libc: musl
|
||||
deb_arch: arm64
|
||||
rpm_arch: aarch64
|
||||
artifact_name: "rustfs-linux-aarch64-musl"
|
||||
deb_depends: ""
|
||||
rpm_depends: ""
|
||||
target: [linux-x86_64-musl, linux-aarch64-musl, linux-x86_64-gnu, linux-aarch64-gnu]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ fromJson(inputs.candidate_manifest).source_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Normalize package metadata
|
||||
id: versions
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
|
||||
SOURCE_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
DEV_SEQUENCE: ${{ needs.resolve.outputs.dev_sequence }}
|
||||
ARCH: ${{ matrix.rpm_arch }}
|
||||
LIBC: ${{ matrix.libc }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
normalized=$(./scripts/release/package_versions.sh \
|
||||
"$BUILD_TYPE" "$SOURCE_VERSION" "$DEV_SEQUENCE" "$ARCH" "$LIBC")
|
||||
printf '%s\n' "$normalized" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download binary artifact from build run
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: ${{ matrix.artifact_name }}*
|
||||
path: ./binary-artifact
|
||||
run-id: ${{ needs.resolve.outputs.build_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
merge-multiple: true
|
||||
|
||||
- name: Extract binary
|
||||
id: binary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ZIP_FILE=$(find ./binary-artifact -name "*.zip" -type f | head -1)
|
||||
if [[ -z "$ZIP_FILE" ]]; then
|
||||
echo "❌ No binary artifact found"
|
||||
find ./binary-artifact -mindepth 1 -maxdepth 1 -print 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found artifact: $ZIP_FILE"
|
||||
|
||||
mkdir -p ./bin
|
||||
unzip -o "$ZIP_FILE" -d ./bin
|
||||
|
||||
if [[ ! -f ./bin/rustfs ]]; then
|
||||
echo "❌ rustfs binary not found in archive"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x ./bin/rustfs
|
||||
stat --printf='%n %s bytes\n' ./bin/rustfs
|
||||
echo "✅ Binary extracted"
|
||||
|
||||
- name: Build DEB package
|
||||
id: deb
|
||||
shell: bash
|
||||
env:
|
||||
DEB_VERSION: ${{ steps.versions.outputs.deb_version }}
|
||||
DEB_ARCH: ${{ matrix.deb_arch }}
|
||||
DEB_FILE: ${{ steps.versions.outputs.deb_file }}
|
||||
DEB_DEPENDS: ${{ matrix.deb_depends }}
|
||||
LIBC: ${{ matrix.libc }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
PKG_DIR="${DEB_FILE%.deb}"
|
||||
|
||||
echo "Building DEB: ${DEB_FILE}"
|
||||
|
||||
mkdir -p "${PKG_DIR}/DEBIAN"
|
||||
mkdir -p "${PKG_DIR}/usr/bin"
|
||||
mkdir -p "${PKG_DIR}/etc/default"
|
||||
mkdir -p "${PKG_DIR}/lib/systemd/system"
|
||||
mkdir -p "${PKG_DIR}/usr/share/doc/rustfs"
|
||||
|
||||
cp ./bin/rustfs "${PKG_DIR}/usr/bin/"
|
||||
chmod 755 "${PKG_DIR}/usr/bin/rustfs"
|
||||
|
||||
cp deploy/build/rustfs.service "${PKG_DIR}/lib/systemd/system/"
|
||||
|
||||
cat > "${PKG_DIR}/etc/default/rustfs" << 'ENVEOF'
|
||||
# RustFS Environment Configuration
|
||||
# See https://docs.rustfs.com for more information
|
||||
# RUSTFS_ACCESS_KEY=<changeme>
|
||||
# RUSTFS_SECRET_KEY=<changeme>
|
||||
# RUSTFS_VOLUMES=""
|
||||
# RUSTFS_ADDRESS=":9000"
|
||||
# RUSTFS_CONSOLE_ADDRESS=":9001"
|
||||
# RUSTFS_CONSOLE_ENABLE=true
|
||||
# RUSTFS_OBS_LOGGER_LEVEL=error
|
||||
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
|
||||
ENVEOF
|
||||
|
||||
cat > "${PKG_DIR}/DEBIAN/control" << EOF
|
||||
Package: rustfs
|
||||
Version: ${DEB_VERSION}
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: ${DEB_ARCH}
|
||||
EOF
|
||||
|
||||
# gnu binaries dynamically link glibc and must declare it; musl
|
||||
# binaries are statically linked and need no libc dependency.
|
||||
if [[ -n "$DEB_DEPENDS" ]]; then
|
||||
echo "Depends: ${DEB_DEPENDS}" >> "${PKG_DIR}/DEBIAN/control"
|
||||
fi
|
||||
|
||||
cat >> "${PKG_DIR}/DEBIAN/control" << EOF
|
||||
Maintainer: RustFS Team <support@rustfs.com>
|
||||
Description: High-performance distributed object storage (${LIBC} build)
|
||||
RustFS is a high-performance distributed object storage software
|
||||
built using Rust. It is compatible with MinIO and S3 API.
|
||||
Homepage: https://rustfs.com
|
||||
EOF
|
||||
|
||||
# Declare /etc/default/rustfs as a conffile so dpkg preserves user
|
||||
# modifications on upgrade instead of silently overwriting them.
|
||||
cat > "${PKG_DIR}/DEBIAN/conffiles" << 'CONFFILES'
|
||||
/etc/default/rustfs
|
||||
CONFFILES
|
||||
|
||||
./scripts/release/package_service_scripts.sh after-install > "${PKG_DIR}/DEBIAN/postinst"
|
||||
chmod 755 "${PKG_DIR}/DEBIAN/postinst"
|
||||
|
||||
./scripts/release/package_service_scripts.sh before-remove > "${PKG_DIR}/DEBIAN/prerm"
|
||||
chmod 755 "${PKG_DIR}/DEBIAN/prerm"
|
||||
|
||||
./scripts/release/package_service_scripts.sh after-remove > "${PKG_DIR}/DEBIAN/postrm"
|
||||
chmod 755 "${PKG_DIR}/DEBIAN/postrm"
|
||||
|
||||
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
|
||||
cp README.md "${PKG_DIR}/usr/share/doc/rustfs/"
|
||||
|
||||
fakeroot dpkg-deb --build "${PKG_DIR}" "$DEB_FILE"
|
||||
|
||||
[[ $(dpkg-deb -f "$DEB_FILE" Package) == rustfs ]]
|
||||
[[ $(dpkg-deb -f "$DEB_FILE" Version) == "$DEB_VERSION" ]]
|
||||
[[ $(dpkg-deb -f "$DEB_FILE" Architecture) == "$DEB_ARCH" ]]
|
||||
dpkg-deb --fsys-tarfile "$DEB_FILE" | tar -tf - | grep -Fx './usr/bin/rustfs' >/dev/null
|
||||
stat --printf='%n %s bytes\n' "$DEB_FILE"
|
||||
echo "deb_file=$DEB_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ DEB built: $DEB_FILE"
|
||||
|
||||
- name: Build RPM package
|
||||
id: rpm
|
||||
shell: bash
|
||||
env:
|
||||
RPM_VERSION: ${{ steps.versions.outputs.rpm_version }}
|
||||
RPM_RELEASE: ${{ steps.versions.outputs.rpm_release }}
|
||||
RPM_ARCH: ${{ matrix.rpm_arch }}
|
||||
RPM_FILE: ${{ steps.versions.outputs.rpm_file }}
|
||||
RPM_DEPENDS: ${{ matrix.rpm_depends }}
|
||||
LIBC: ${{ matrix.libc }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Building RPM for ${RPM_ARCH} (${LIBC})"
|
||||
|
||||
sudo apt-get update && sudo apt-get install -y ruby ruby-dev build-essential rpm
|
||||
sudo gem install fpm
|
||||
./scripts/test_package_versions.sh --require-package-managers
|
||||
|
||||
# gnu binaries dynamically link glibc and must declare it; musl
|
||||
# binaries are statically linked and need no libc dependency.
|
||||
depends_args=()
|
||||
if [[ -n "$RPM_DEPENDS" ]]; then
|
||||
depends_args+=(--depends "$RPM_DEPENDS")
|
||||
fi
|
||||
|
||||
# Create config file for fpm (DEB build creates it in its package dir structure,
|
||||
# but fpm needs the file to exist before packaging)
|
||||
mkdir -p ./tmp-pkg/etc/default
|
||||
cat > ./tmp-pkg/etc/default/rustfs << 'ENVEOF'
|
||||
# RustFS Environment Configuration
|
||||
# See https://docs.rustfs.com for more information
|
||||
# RUSTFS_ACCESS_KEY=<changeme>
|
||||
# RUSTFS_SECRET_KEY=<changeme>
|
||||
# RUSTFS_VOLUMES=""
|
||||
# RUSTFS_ADDRESS=":9000"
|
||||
# RUSTFS_CONSOLE_ADDRESS=":9001"
|
||||
# RUSTFS_CONSOLE_ENABLE=true
|
||||
# RUSTFS_OBS_LOGGER_LEVEL=error
|
||||
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
|
||||
ENVEOF
|
||||
|
||||
fpm -s dir -t rpm \
|
||||
--name rustfs \
|
||||
--version "$RPM_VERSION" \
|
||||
--iteration "$RPM_RELEASE" \
|
||||
--architecture "$RPM_ARCH" \
|
||||
--package "$RPM_FILE" \
|
||||
"${depends_args[@]}" \
|
||||
--description "High-performance distributed object storage (${LIBC} build)" \
|
||||
--maintainer "RustFS Team <support@rustfs.com>" \
|
||||
--url "https://rustfs.com" \
|
||||
--license "Apache-2.0" \
|
||||
--before-install <(./scripts/release/package_service_scripts.sh rpm-before-install) \
|
||||
--before-upgrade <(./scripts/release/package_service_scripts.sh rpm-before-upgrade) \
|
||||
--after-install <(./scripts/release/package_service_scripts.sh after-install) \
|
||||
--after-upgrade <(./scripts/release/package_service_scripts.sh after-install) \
|
||||
--before-remove <(./scripts/release/package_service_scripts.sh before-remove) \
|
||||
--after-remove <(./scripts/release/package_service_scripts.sh after-remove) \
|
||||
--rpm-posttrans <(./scripts/release/package_service_scripts.sh rpm-posttrans) \
|
||||
--config-files /etc/default/rustfs \
|
||||
./bin/rustfs=/usr/bin/rustfs \
|
||||
./tmp-pkg/etc/default/rustfs=/etc/default/rustfs \
|
||||
deploy/build/rustfs.service=/lib/systemd/system/rustfs.service \
|
||||
LICENSE=/usr/share/doc/rustfs/LICENSE \
|
||||
README.md=/usr/share/doc/rustfs/README.md
|
||||
|
||||
if [[ ! -f "$RPM_FILE" ]]; then
|
||||
echo "❌ RPM build failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RPM_METADATA=$(rpm -qp --qf '%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n' "$RPM_FILE")
|
||||
EXPECTED_METADATA=$(printf 'rustfs\n%s\n%s\n%s' "$RPM_VERSION" "$RPM_RELEASE" "$RPM_ARCH")
|
||||
[[ "$RPM_METADATA" == "$EXPECTED_METADATA" ]]
|
||||
rpm -qpl "$RPM_FILE" | grep -Fx '/usr/bin/rustfs' >/dev/null
|
||||
stat --printf='%n %s bytes\n' "$RPM_FILE"
|
||||
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ RPM built: $RPM_FILE"
|
||||
|
||||
- name: Download certification evidence
|
||||
if: needs.resolve.outputs.build_type != 'development'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-certification
|
||||
path: ./certification
|
||||
|
||||
# Later-built wrapper verification: the binary inside each package must
|
||||
# match the certified binary digest for this platform before anything is
|
||||
# uploaded, and the package itself stays on staging until promotion.
|
||||
- name: Verify wrapped binary against certification
|
||||
if: needs.resolve.outputs.build_type != 'development'
|
||||
shell: bash
|
||||
env:
|
||||
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
|
||||
ARTIFACT_NAME: ${{ matrix.artifact_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected="$(jq -r --arg needle "$ARTIFACT_NAME" \
|
||||
'[.deliverables[] | select(.name | contains($needle)) | .binary_sha256][0] // empty' \
|
||||
certification/candidate.json)"
|
||||
if [[ -z "$expected" ]]; then
|
||||
echo "No certified binary digest recorded for ${ARTIFACT_NAME}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v rpm2cpio >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y rpm2cpio
|
||||
fi
|
||||
if ! command -v cpio >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y cpio
|
||||
fi
|
||||
for package_file in "$DEB_FILE" "$RPM_FILE"; do
|
||||
case "$package_file" in
|
||||
*.deb)
|
||||
dpkg-deb --fsys-tarfile "$package_file" | tar -xOf - ./usr/bin/rustfs > "$RUNNER_TEMP/wrapped-rustfs"
|
||||
;;
|
||||
*.rpm)
|
||||
rpm2cpio "$package_file" | cpio -i --to-stdout ./usr/bin/rustfs > "$RUNNER_TEMP/wrapped-rustfs"
|
||||
;;
|
||||
esac
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact "$RUNNER_TEMP/wrapped-rustfs" \
|
||||
--artifact-role binary
|
||||
done
|
||||
|
||||
- name: Upload packages to artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packages-${{ matrix.arch }}-${{ matrix.libc }}
|
||||
path: |
|
||||
*.deb
|
||||
*.rpm
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload packages to Cloudflare R2
|
||||
if: needs.resolve.outputs.build_type != 'preview' && env.R2_ACCESS_KEY_ID != ''
|
||||
env:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
|
||||
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "$R2_ACCESS_KEY_ID" || -z "$R2_SECRET_ACCESS_KEY" || -z "$R2_ENDPOINT" || -z "$R2_BUCKET" ]]; then
|
||||
echo "⚠️ R2 credentials missing, skipping upload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y awscli
|
||||
fi
|
||||
|
||||
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||
export AWS_DEFAULT_REGION="auto"
|
||||
|
||||
# Build-phase writes stay on staging for release types: the official
|
||||
# packages/release and packages/latest paths are written only by the
|
||||
# certification-gated promote-packages job, from these same staged
|
||||
# objects. Development packages keep the uncertified dev channel.
|
||||
if [[ "$BUILD_TYPE" == "development" ]]; then
|
||||
R2_PREFIX="artifacts/rustfs/packages/dev"
|
||||
else
|
||||
R2_PREFIX="artifacts/rustfs/packages/staging/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}"
|
||||
fi
|
||||
R2_PATH="s3://${R2_BUCKET}/${R2_PREFIX}/"
|
||||
|
||||
echo "📤 Uploading to $R2_PATH"
|
||||
|
||||
for f in "$DEB_FILE" "$RPM_FILE"; do
|
||||
if [[ -n "$f" && -f "$f" ]]; then
|
||||
echo "Uploading: $f"
|
||||
aws s3 cp "$f" "$R2_PATH" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Upload complete"
|
||||
|
||||
- name: Upload packages to GitHub Release
|
||||
if: needs.resolve.outputs.tag != ''
|
||||
- name: Reuse completed package set
|
||||
id: reuse
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
|
||||
shell: bash
|
||||
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: python3 scripts/release/deliver.py reuse --kind packages --target "$TARGET"
|
||||
- name: Install packaging tools
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Upload the packages, then refresh the release checksums so the new
|
||||
# assets are covered, matching the binary release flow.
|
||||
for f in "$DEB_FILE" "$RPM_FILE"; do
|
||||
if [[ -n "$f" && -f "$f" ]]; then
|
||||
echo "📤 Uploading $(basename "$f") to GitHub release ${TAG}..."
|
||||
gh release upload "$TAG" "$f" --clobber
|
||||
fi
|
||||
done
|
||||
|
||||
CHECKSUM_DIR="$(mktemp -d)"
|
||||
gh release download "$TAG" -p 'SHA256SUMS' -p 'SHA512SUMS' \
|
||||
-D "$CHECKSUM_DIR" --clobber
|
||||
|
||||
for spec in "SHA256SUMS:sha256sum" "SHA512SUMS:sha512sum"; do
|
||||
asset="${spec%%:*}"
|
||||
checksum_cmd="${spec##*:}"
|
||||
checksum_file="${CHECKSUM_DIR}/${asset}"
|
||||
|
||||
for f in "$DEB_FILE" "$RPM_FILE"; do
|
||||
if [[ -n "$f" && -f "$f" ]]; then
|
||||
base="$(basename "$f")"
|
||||
# GitHub stores release asset names with '~' normalized to '.'.
|
||||
# Current package file names embed the raw tag and contain no
|
||||
# '~', but older releases may still ship such assets, so the
|
||||
# checksum entries must reference the name as stored on the
|
||||
# release.
|
||||
github_base="${base//\~/.}"
|
||||
# Remove any stale entry (both naming variants), then append
|
||||
grep -Fv -- "$base" "$checksum_file" > "${checksum_file}.tmp" || true
|
||||
grep -Fv -- "$github_base" "${checksum_file}.tmp" > "${checksum_file}.tmp2" || true
|
||||
mv "${checksum_file}.tmp2" "$checksum_file"
|
||||
digest=$("$checksum_cmd" -- "$f" | awk '{print $1}')
|
||||
printf '%s %s\n' "$digest" "$github_base" >> "$checksum_file"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "📤 Updating ${asset} for release ${TAG}..."
|
||||
gh release upload "$TAG" "$checksum_file" --clobber
|
||||
done
|
||||
|
||||
echo "✅ GitHub release assets updated"
|
||||
|
||||
# The only writer of the official packages/release and packages/latest R2
|
||||
# paths. Promotion re-checks each package's wrapped binary against the
|
||||
# passed certification list and copies the staged objects byte-for-byte:
|
||||
# no rebuild-after-verify, no mutable latest resolution downstream.
|
||||
promote-packages:
|
||||
name: Promote Packages
|
||||
needs: [ resolve, package, release-certification ]
|
||||
# Target-channel lock for the official package paths.
|
||||
concurrency:
|
||||
group: release-channel-r2-packages
|
||||
cancel-in-progress: false
|
||||
if: needs.resolve.outputs.tag != '' && (needs.resolve.outputs.build_type == 'release' || needs.resolve.outputs.build_type == 'prerelease')
|
||||
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
env:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download packages and certification
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: ./packages
|
||||
pattern: packages-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download certification evidence
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-certification
|
||||
path: ./certification
|
||||
|
||||
- name: Verify and promote staged packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "$R2_ACCESS_KEY_ID" || -z "$R2_SECRET_ACCESS_KEY" || -z "$R2_ENDPOINT" || -z "$R2_BUCKET" ]]; then
|
||||
echo "❌ R2 credentials missing; refusing to promote to the official channel" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STAGING_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/staging/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/"
|
||||
RELEASE_PATH="s3://${R2_BUCKET}/artifacts/rustfs/packages/release/"
|
||||
LATEST_PATH="s3://${R2_BUCKET}/artifacts/rustfs/packages/latest/"
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y awscli
|
||||
fi
|
||||
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||
export AWS_DEFAULT_REGION="auto"
|
||||
|
||||
if ! command -v rpm2cpio >/dev/null 2>&1; then
|
||||
sudo apt-get install -y rpm2cpio
|
||||
fi
|
||||
if ! command -v cpio >/dev/null 2>&1; then
|
||||
sudo apt-get install -y cpio
|
||||
fi
|
||||
|
||||
promoted=0
|
||||
for package_file in packages/*.deb packages/*.rpm; do
|
||||
[[ -f "$package_file" ]] || continue
|
||||
name="$(basename "$package_file")"
|
||||
case "$package_file" in
|
||||
*.deb)
|
||||
dpkg-deb --fsys-tarfile "$package_file" | tar -xOf - ./usr/bin/rustfs > "$RUNNER_TEMP/wrapped-rustfs"
|
||||
;;
|
||||
*.rpm)
|
||||
rpm2cpio "$package_file" | cpio -i --to-stdout ./usr/bin/rustfs > "$RUNNER_TEMP/wrapped-rustfs"
|
||||
;;
|
||||
esac
|
||||
python3 scripts/release/release_gate.py verify-promotion \
|
||||
--plan .config/release-validation-plan.json \
|
||||
--candidate certification/candidate.json \
|
||||
--summary certification/release-gate-output \
|
||||
--artifact "$RUNNER_TEMP/wrapped-rustfs" \
|
||||
--artifact-role binary
|
||||
|
||||
staged="$(mktemp)"
|
||||
aws s3 cp "${STAGING_PREFIX}${name}" "$staged" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
staged_sha="$(sha256sum "$staged" | awk '{print $1}')"
|
||||
built_sha="$(sha256sum "$package_file" | awk '{print $1}')"
|
||||
if [[ "$staged_sha" != "$built_sha" ]]; then
|
||||
echo "❌ Staged package ${name} does not match the verified bytes" >&2
|
||||
exit 1
|
||||
fi
|
||||
aws s3 cp "$staged" "${RELEASE_PATH}${name}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
aws s3 cp "$staged" "${LATEST_PATH}${name}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
rm -f "$staged"
|
||||
promoted=$((promoted + 1))
|
||||
echo "✅ Promoted ${name} (${built_sha})"
|
||||
done
|
||||
if [[ "$promoted" -eq 0 ]]; then
|
||||
echo "No staged packages found to promote" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Summary
|
||||
summary:
|
||||
name: Summary
|
||||
needs: [ resolve, package ]
|
||||
# Every completed Build and Release run triggers this workflow; when
|
||||
# resolve declined it (not a release tag), there is nothing to summarise.
|
||||
if: always() && needs.resolve.result != 'skipped'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Print summary
|
||||
shell: bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ruby ruby-dev build-essential rpm fakeroot cpio
|
||||
sudo gem install fpm --version 1.17.0 --no-document
|
||||
- name: Package accepted executable and verify contents
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
env:
|
||||
SUMMARY_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
SUMMARY_BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
|
||||
SUMMARY_BUILD_RUN_ID: ${{ needs.resolve.outputs.build_run_id }}
|
||||
SUMMARY_PACKAGE_STATUS: ${{ needs.package.result }}
|
||||
run: |
|
||||
{
|
||||
echo "## 📦 Package Summary"
|
||||
echo ""
|
||||
echo "| Item | Value |"
|
||||
echo "|------|-------|"
|
||||
echo "| Version | \`${SUMMARY_VERSION}\` |"
|
||||
echo "| Build Type | ${SUMMARY_BUILD_TYPE} |"
|
||||
echo "| Build Run | #${SUMMARY_BUILD_RUN_ID} |"
|
||||
echo "| Package Status | ${SUMMARY_PACKAGE_STATUS} |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: python3 scripts/release/deliver.py package --target "$TARGET" --output delivery
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: steps.reuse.outputs.reused != 'true'
|
||||
with:
|
||||
name: packages-${{ matrix.target }}
|
||||
path: delivery/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 30
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# s3-tests reference parity lane (rustfs/backlog#2773).
|
||||
#
|
||||
# Runs the ceph/s3-tests implemented list (scripts/s3-tests/implemented_tests.txt)
|
||||
# against every target in .config/reference-parity-targets.toml and fails on
|
||||
# any per-node-id difference between a candidate and the reference:
|
||||
#
|
||||
# - reference: the release pinned in .config/reference-binary.toml, downloaded
|
||||
# and sha256-verified by .github/actions/reference-binary;
|
||||
# - candidate targets: the debug binary built from this checkout exactly as
|
||||
# ci.yml's build-rustfs-debug-binary job builds it for S3 Implemented Tests.
|
||||
#
|
||||
# Every target runs the same harness (scripts/s3-tests/run.sh from this
|
||||
# checkout), test list and server configuration on its own hosted runner. A
|
||||
# target's failures get one rerun against a fresh server; a case that passes it
|
||||
# is flaky, not a difference. scripts/s3-tests/reference_parity.py judges the
|
||||
# result and honours .config/reference-parity-tolerated.txt, whose entries must
|
||||
# each match a real difference.
|
||||
#
|
||||
# Report-only: not part of the Test and Lint aggregate. Scheduled failures are
|
||||
# owned by the scheduled-validation watchdog (.github/scheduled-validations.json).
|
||||
|
||||
name: Reference Parity
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/reference-parity.yml"
|
||||
- ".github/actions/reference-binary/**"
|
||||
- ".config/reference-binary.toml"
|
||||
- ".config/reference-parity-targets.toml"
|
||||
- ".config/reference-parity-tolerated.txt"
|
||||
- "scripts/s3-tests/reference_parity.py"
|
||||
- "scripts/junit_diff.py"
|
||||
schedule:
|
||||
- cron: "37 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# Matches ci.yml: these variables are hashed into the rust-cache key, so the
|
||||
# candidate build reads the same ci-dev cache as build-rustfs-debug-binary.
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
name: Plan parity targets
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
targets: ${{ steps.targets.outputs.targets }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate target list
|
||||
id: targets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
targets="$(python3 scripts/s3-tests/reference_parity.py targets --config .config/reference-parity-targets.toml)"
|
||||
echo "targets=${targets}" >> "$GITHUB_OUTPUT"
|
||||
echo "Parity targets: ${targets}"
|
||||
|
||||
build-candidate:
|
||||
name: Build candidate binary
|
||||
# Critical-path CPU and cache ownership: the same debug build as ci.yml's
|
||||
# build-rustfs-debug-binary, reading the ci-dev cache written on sm-standard-4.
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build debug binary
|
||||
run: python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||
|
||||
- name: Upload candidate binary
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: reference-parity-candidate-binary
|
||||
overwrite: true
|
||||
path: |
|
||||
target/debug/rustfs
|
||||
target/debug/rustfs.e2e.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
s3tests:
|
||||
name: s3-tests (${{ matrix.target.name }})
|
||||
needs: [ plan, build-candidate ]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: ${{ fromJSON(needs.plan.outputs.targets) }}
|
||||
env:
|
||||
TARGET_NAME: ${{ matrix.target.name }}
|
||||
TARGET_KIND: ${{ matrix.target.binary }}
|
||||
# Above this many first-run failures the target is systemically broken;
|
||||
# a rerun would only repeat it, so the first run is judged as is.
|
||||
RERUN_CAP: "50"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fetch pinned reference binary
|
||||
id: reference
|
||||
if: matrix.target.binary == 'reference'
|
||||
uses: ./.github/actions/reference-binary
|
||||
|
||||
- name: Download candidate binary
|
||||
if: matrix.target.binary == 'candidate'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: reference-parity-candidate-binary
|
||||
path: target/debug
|
||||
|
||||
- name: Select target binary
|
||||
env:
|
||||
REFERENCE_PATH: ${{ steps.reference.outputs.path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TARGET_KIND" in
|
||||
reference) binary="$REFERENCE_PATH" ;;
|
||||
candidate) chmod +x target/debug/rustfs; binary="$PWD/target/debug/rustfs" ;;
|
||||
*) echo "unknown target binary kind: $TARGET_KIND" >&2; exit 1 ;;
|
||||
esac
|
||||
test -x "$binary"
|
||||
echo "TARGET_BINARY=$binary" >> "$GITHUB_ENV"
|
||||
mkdir -p "parity/$TARGET_NAME"
|
||||
{
|
||||
echo "target=$TARGET_NAME"
|
||||
echo "binary=$TARGET_KIND"
|
||||
echo "sha256=$(sha256sum "$binary" | cut -d' ' -f1)"
|
||||
echo "version=$("$binary" --version | tr '\n' ' ')"
|
||||
} | tee "parity/$TARGET_NAME/target.txt"
|
||||
|
||||
- name: Apply target environment
|
||||
env:
|
||||
TARGET_JSON: ${{ toJSON(matrix.target) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/s3-tests/reference_parity.py export-env --target-json "$TARGET_JSON" \
|
||||
| tee -a "$GITHUB_ENV" "parity/$TARGET_NAME/target-env.txt"
|
||||
|
||||
- name: Run implemented s3-tests
|
||||
run: |
|
||||
set -uo pipefail
|
||||
report="parity/$TARGET_NAME"
|
||||
harness="artifacts/s3tests-single"
|
||||
|
||||
# Same invocation as ci.yml's S3 Implemented Tests, with a fresh data
|
||||
# root and port per run. $1 optionally replaces the test list.
|
||||
run_suite() {
|
||||
local list="$1" label="$2" root port
|
||||
root="${RUNNER_TEMP}/rustfs-s3tests-${label}"
|
||||
rm -rf "$harness" "$root"
|
||||
mkdir -p "$root"
|
||||
port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
|
||||
env DEPLOY_MODE=binary \
|
||||
RUSTFS_BINARY="$TARGET_BINARY" \
|
||||
TEST_MODE=single \
|
||||
MAXFAIL=0 \
|
||||
XDIST=4 \
|
||||
S3_PORT="$port" \
|
||||
DATA_ROOT="$root" \
|
||||
S3TESTS_CONF="$harness/s3tests.conf" \
|
||||
${list:+IMPLEMENTED_TESTS_FILE="$list"} \
|
||||
./scripts/s3-tests/run.sh
|
||||
}
|
||||
|
||||
# A run is usable only if it produced a JUnit report; test failures
|
||||
# are data for the comparison, a missing report is a lane failure.
|
||||
keep() {
|
||||
local prefix="$1"
|
||||
for file in pytest.log compat-report.md; do
|
||||
if [[ -f "$harness/$file" ]]; then cp "$harness/$file" "$report/${prefix}${file}"; fi
|
||||
done
|
||||
if [[ -f "$harness/rustfs-single/rustfs.log" ]]; then cp "$harness/rustfs-single/rustfs.log" "$report/${prefix}rustfs.log"; fi
|
||||
test -s "$harness/junit.xml" || { echo "::error::$TARGET_NAME produced no JUnit report (${prefix:-first} run)"; return 1; }
|
||||
cp "$harness/junit.xml" "$report/${prefix}junit.xml"
|
||||
}
|
||||
|
||||
run_suite "" first
|
||||
echo "first_run_exit=$?" >> "$report/target.txt"
|
||||
keep "" || exit 1
|
||||
cp "$harness/selected-nodeids.txt" "$report/selected-nodeids.txt" || exit 1
|
||||
|
||||
failures="$(python3 scripts/s3-tests/reference_parity.py failed --report "$report")" || exit 1
|
||||
count="$(printf '%s' "$failures" | grep -c . || true)"
|
||||
echo "first_run_failures=$count" >> "$report/target.txt"
|
||||
if (( count == 0 )); then
|
||||
exit 0
|
||||
fi
|
||||
if (( count > RERUN_CAP )); then
|
||||
echo "::warning::$TARGET_NAME has $count failures (cap $RERUN_CAP); judging the first run without a rerun"
|
||||
echo "rerun=skipped-over-cap" >> "$report/target.txt"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$failures" > "$RUNNER_TEMP/rerun-tests.txt"
|
||||
run_suite "$RUNNER_TEMP/rerun-tests.txt" rerun
|
||||
echo "rerun_exit=$?" >> "$report/target.txt"
|
||||
keep rerun- || exit 1
|
||||
|
||||
- name: Upload target report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: reference-parity-report-${{ matrix.target.name }}
|
||||
overwrite: true
|
||||
path: parity/${{ matrix.target.name }}/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
compare:
|
||||
name: Compare targets
|
||||
needs: [ plan, s3tests ]
|
||||
if: always() && needs.plan.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download target reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: reference-parity-report-*
|
||||
path: parity-download
|
||||
|
||||
- name: Compare per node id
|
||||
env:
|
||||
TARGETS: ${{ needs.plan.outputs.targets }}
|
||||
S3TESTS_RESULT: ${{ needs.s3tests.result }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
mkdir -p parity
|
||||
for dir in parity-download/reference-parity-report-*; do
|
||||
if [[ -d "$dir" ]]; then mv "$dir" "parity/${dir#parity-download/reference-parity-report-}"; fi
|
||||
done
|
||||
python3 scripts/s3-tests/reference_parity.py compare \
|
||||
--targets-json "$TARGETS" \
|
||||
--reports parity \
|
||||
--tolerated .config/reference-parity-tolerated.txt \
|
||||
--markdown "$GITHUB_STEP_SUMMARY" \
|
||||
--json parity/diff.json > parity/diff.md
|
||||
status=$?
|
||||
cat parity/diff.md
|
||||
if [[ "$S3TESTS_RESULT" != "success" ]]; then
|
||||
echo "::error::s3-tests target jobs finished with '$S3TESTS_RESULT'"
|
||||
status=1
|
||||
fi
|
||||
exit "$status"
|
||||
|
||||
- name: Upload parity diff
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: reference-parity-diff
|
||||
overwrite: true
|
||||
path: |
|
||||
parity/diff.md
|
||||
parity/diff.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,133 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
name: Release candidate (Linux)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Final product version (no preview suffix)
|
||||
type: string
|
||||
required: true
|
||||
source_sha:
|
||||
description: Full reviewed source commit
|
||||
type: string
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
concurrency:
|
||||
group: release-candidate-${{ inputs.version }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
freeze:
|
||||
if: github.repository == 'rustfs/rustfs' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
manifest: ${{ steps.freeze.outputs.manifest }}
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Freeze or recover original inputs
|
||||
id: freeze
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
SOURCE_SHA: ${{ inputs.source_sha }}
|
||||
run: python3 scripts/release/pipeline.py prepare --version "$VERSION" --source "$SOURCE_SHA" --directory frozen
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: steps.freeze.outputs.reused != 'true'
|
||||
with:
|
||||
name: release-inputs
|
||||
path: frozen/inputs.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
linux:
|
||||
needs: freeze
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
candidate_manifest: ${{ needs.freeze.outputs.manifest }}
|
||||
platforms: linux
|
||||
upgrade:
|
||||
needs: [freeze, linux]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: ${{ fromJson(needs.freeze.outputs.manifest).toolchain }}
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ fromJson(needs.freeze.outputs.manifest).workflow_sha }}
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: ${{ fromJson(needs.freeze.outputs.manifest).toolchain }}
|
||||
cache-shared-key: release-upgrade-harness-hosted
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: "false"
|
||||
install-test-tools: "false"
|
||||
- name: Verify archived server against previous releases
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CANDIDATE_MANIFEST: ${{ needs.freeze.outputs.manifest }}
|
||||
run: |
|
||||
python3 - <<'PYVERIFY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, 'scripts/release')
|
||||
import candidate as c, pipeline, verify
|
||||
inputs = pipeline.manifest_env()
|
||||
run = pipeline.current_run('release-candidate.yml')
|
||||
if any(a['name'] == 'release-upgrade' for a in c.artifact_list(run['id'])):
|
||||
c.download_artifact(run, 'release-upgrade', 'receipts')
|
||||
pipeline.verify_evidence(inputs, Path('receipts'), {run['id']: run})
|
||||
c.emit_output('reused', 'true')
|
||||
else:
|
||||
target = 'linux-x86_64-gnu'
|
||||
c.download_artifact(run, f"candidate-{inputs['candidate_id']}-{target}", 'binary')
|
||||
verify.upgrade(inputs, Path('binary') / f"rustfs-{target}-v{inputs['version']}.zip", Path('receipts'))
|
||||
PYVERIFY
|
||||
id: verify
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: steps.verify.outputs.reused != 'true'
|
||||
with:
|
||||
name: release-upgrade
|
||||
path: receipts/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure()
|
||||
with:
|
||||
name: release-upgrade-failure-${{ github.run_attempt }}
|
||||
path: receipts/*
|
||||
retention-days: 7
|
||||
seal-linux:
|
||||
needs: [freeze, linux, upgrade]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Collect trusted receipts and seal Linux bytes
|
||||
id: seal
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 scripts/release/pipeline.py linux-seal --directory sealed
|
||||
{
|
||||
echo '## Linux acceptance'
|
||||
echo 'Use this run ID and manifest SHA-256 when starting Release candidate (complete).'
|
||||
sha256sum sealed/linux-manifest.json
|
||||
echo 'Record cluster, performance, integrity and upgrade acceptance in backlog before continuing.'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: steps.seal.outputs.reused != 'true'
|
||||
with:
|
||||
name: release-linux
|
||||
path: sealed/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,97 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
name: Release candidate (complete)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
candidate_run_id:
|
||||
description: Successful Linux candidate run ID
|
||||
type: string
|
||||
required: true
|
||||
linux_manifest_sha256:
|
||||
description: Exact accepted Linux manifest SHA-256
|
||||
type: string
|
||||
required: true
|
||||
acceptance_report:
|
||||
description: Backlog report accepting cluster, performance, integrity and upgrade results
|
||||
type: string
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
concurrency:
|
||||
group: release-complete-${{ inputs.candidate_run_id }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
acceptance:
|
||||
if: github.repository == 'rustfs/rustfs' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment: release-linux-acceptance
|
||||
outputs:
|
||||
manifest: ${{ steps.accept.outputs.manifest }}
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Bind acceptance to the exact Linux manifest
|
||||
id: accept
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CANDIDATE_RUN: ${{ inputs.candidate_run_id }}
|
||||
MANIFEST_SHA: ${{ inputs.linux_manifest_sha256 }}
|
||||
REPORT: ${{ inputs.acceptance_report }}
|
||||
run: python3 scripts/release/pipeline.py complete-prepare --run-id "$CANDIDATE_RUN" --digest "$MANIFEST_SHA" --report "$REPORT" --directory linux
|
||||
desktop:
|
||||
needs: acceptance
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
candidate_manifest: ${{ needs.acceptance.outputs.manifest }}
|
||||
platforms: desktop
|
||||
packages:
|
||||
needs: acceptance
|
||||
uses: ./.github/workflows/package.yml
|
||||
with:
|
||||
candidate_manifest: ${{ needs.acceptance.outputs.manifest }}
|
||||
images:
|
||||
needs: acceptance
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
candidate_manifest: ${{ needs.acceptance.outputs.manifest }}
|
||||
chart:
|
||||
needs: acceptance
|
||||
uses: ./.github/workflows/helm-package.yml
|
||||
with:
|
||||
candidate_manifest: ${{ needs.acceptance.outputs.manifest }}
|
||||
seal:
|
||||
needs: [acceptance, desktop, packages, images, chart]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Verify every deliverable and seal the candidate
|
||||
id: seal
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CANDIDATE_RUN: ${{ inputs.candidate_run_id }}
|
||||
MANIFEST_SHA: ${{ inputs.linux_manifest_sha256 }}
|
||||
REPORT: ${{ inputs.acceptance_report }}
|
||||
run: |
|
||||
python3 scripts/release/pipeline.py complete-seal --run-id "$CANDIDATE_RUN" --digest "$MANIFEST_SHA" --report "$REPORT" --directory sealed
|
||||
{
|
||||
echo '## Final publication approval'
|
||||
echo 'Review this manifest, then explicitly dispatch Publish accepted release with its SHA-256.'
|
||||
sha256sum sealed/release-manifest.json
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: steps.seal.outputs.reused != 'true'
|
||||
with:
|
||||
name: release-seal
|
||||
path: sealed/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,193 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
name: Publish accepted release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
complete_run_id:
|
||||
description: Successful completed candidate run ID
|
||||
type: string
|
||||
required: true
|
||||
manifest_sha256:
|
||||
description: Exact approved final manifest SHA-256 (authorizes publication)
|
||||
type: string
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
concurrency:
|
||||
group: release-publish-${{ inputs.complete_run_id }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
verify:
|
||||
if: github.repository == 'rustfs/rustfs' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
version: ${{ steps.verify.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Recheck approved bytes and trusted certification
|
||||
id: verify
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
COMPLETE_RUN: ${{ inputs.complete_run_id }}
|
||||
MANIFEST_SHA: ${{ inputs.manifest_sha256 }}
|
||||
run: python3 scripts/release/publish.py verify --run-id "$COMPLETE_RUN" --digest "$MANIFEST_SHA" --directory sealed
|
||||
version:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
concurrency:
|
||||
group: release-version-${{ needs.verify.outputs.version }}
|
||||
cancel-in-progress: false
|
||||
environment: release-publication
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
packages: write
|
||||
security-events: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
COMPLETE_RUN: ${{ inputs.complete_run_id }}
|
||||
MANIFEST_SHA: ${{ inputs.manifest_sha256 }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
||||
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Install promotion tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y skopeo
|
||||
aws --version
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: rustfs
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ secrets.GHCR_USERNAME }}
|
||||
password: ${{ secrets.GHCR_PASSWORD }}
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_PASSWORD }}
|
||||
- name: Share registry credentials with the OCI copier
|
||||
run: |
|
||||
install -m 600 "$HOME/.docker/config.json" "$RUNNER_TEMP/registry-auth.json"
|
||||
echo "REGISTRY_AUTH_FILE=$RUNNER_TEMP/registry-auth.json" >> "$GITHUB_ENV"
|
||||
- name: Promote verified version
|
||||
run: python3 scripts/release/publish.py version --run-id "$COMPLETE_RUN" --digest "$MANIFEST_SHA" --directory sealed
|
||||
- name: Publish accepted musl amd64 scan
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
|
||||
with:
|
||||
sarif_file: sealed/scan-musl-amd64.sarif
|
||||
category: container-image-musl-amd64
|
||||
- name: Publish accepted musl arm64 scan
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
|
||||
with:
|
||||
sarif_file: sealed/scan-musl-arm64.sarif
|
||||
category: container-image-musl-arm64
|
||||
- name: Publish accepted GNU amd64 scan
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
|
||||
with:
|
||||
sarif_file: sealed/scan-gnu-amd64.sarif
|
||||
category: container-image-gnu-amd64
|
||||
- name: Publish accepted GNU arm64 scan
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
|
||||
with:
|
||||
sarif_file: sealed/scan-gnu-arm64.sarif
|
||||
category: container-image-gnu-arm64
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-version-receipt-${{ github.run_attempt }}
|
||||
path: publication.json
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
channels:
|
||||
needs: [verify, version]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
concurrency:
|
||||
group: rustfs-release-channels
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
packages: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
COMPLETE_RUN: ${{ inputs.complete_run_id }}
|
||||
MANIFEST_SHA: ${{ inputs.manifest_sha256 }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
||||
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Install promotion tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y skopeo
|
||||
aws --version
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: rustfs
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ secrets.GHCR_USERNAME }}
|
||||
password: ${{ secrets.GHCR_PASSWORD }}
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_PASSWORD }}
|
||||
- name: Share registry credentials with the OCI copier
|
||||
run: |
|
||||
install -m 600 "$HOME/.docker/config.json" "$RUNNER_TEMP/registry-auth.json"
|
||||
echo "REGISTRY_AUTH_FILE=$RUNNER_TEMP/registry-auth.json" >> "$GITHUB_ENV"
|
||||
- uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
||||
- name: Install pinned OSS client
|
||||
run: |
|
||||
curl --fail --location --retry 3 --output "$RUNNER_TEMP/ossutil.zip" https://gosspublic.alicdn.com/ossutil/v2/2.1.1/ossutil-2.1.1-linux-amd64.zip
|
||||
printf '%s %s\n' 60f3a808cbbdfd4b5269b8f967c6a66f564c9dacff52d93a5d21a588976ab5a2 "$RUNNER_TEMP/ossutil.zip" | sha256sum --check -
|
||||
unzip -q "$RUNNER_TEMP/ossutil.zip" -d "$RUNNER_TEMP/ossutil-extract"
|
||||
install -m 755 "$RUNNER_TEMP/ossutil-extract/ossutil-2.1.1-linux-amd64/ossutil" "$RUNNER_TEMP/ossutil"
|
||||
echo "OSSUTIL_BIN=$RUNNER_TEMP/ossutil" >> "$GITHUB_ENV"
|
||||
- name: Promote verified channels
|
||||
env:
|
||||
HELM_TOKEN: ${{ secrets.RUSTFS_HELM_PACKAGE }}
|
||||
OSS_ACCESS_KEY_ID: ${{ secrets.ALICLOUDOSS_KEY_ID }}
|
||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.ALICLOUDOSS_KEY_SECRET }}
|
||||
OSS_REGION: cn-beijing
|
||||
OSS_ENDPOINT: https://oss-cn-beijing.aliyuncs.com
|
||||
run: python3 scripts/release/publish.py channels --run-id "$COMPLETE_RUN" --digest "$MANIFEST_SHA" --directory sealed
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-channels-receipt-${{ github.run_attempt }}
|
||||
path: publication.json
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
@@ -26,7 +26,13 @@ permissions:
|
||||
jobs:
|
||||
alert-on-incomplete-run:
|
||||
name: Alert on incomplete scheduled run
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
# The registry still owns responsibility and cadence selection. Successful
|
||||
# and PR runs can never produce an incomplete scheduled-run alert.
|
||||
if: >-
|
||||
github.repository == 'rustfs/rustfs' &&
|
||||
github.event.workflow_run.conclusion != 'success' &&
|
||||
(github.event.workflow_run.event == 'schedule' ||
|
||||
github.event.workflow_run.event == 'workflow_run')
|
||||
# Stay on GitHub-hosted runners: this monitor must keep running when the
|
||||
# self-hosted sm-standard-* pool is down, which is exactly when the
|
||||
# scheduled validations it watches stall.
|
||||
|
||||
Generated
+180
-51
@@ -322,9 +322,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archive-trait"
|
||||
version = "0.0.15"
|
||||
version = "0.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "999bf88cc483664cab77700ca2fdfb0415a73412abe284ef5bc2319d00938e00"
|
||||
checksum = "0b5040ca935c09888e7dfb04383b4d62fa3571e25bc66b6f345b591e97958524"
|
||||
dependencies = [
|
||||
"cap-std",
|
||||
"thiserror 2.0.21",
|
||||
@@ -811,9 +811,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-nats"
|
||||
version = "0.51.0"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4f92759f418774b78c36ef07885849150ce950c67d9bcdeff8a9c253f57d9a1"
|
||||
checksum = "015f0a7a3b0b29701b78c76a4d4a122624c97c71fa30363381664eb729d619c7"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
@@ -3906,9 +3906,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dial9"
|
||||
version = "0.5.7"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f3298a54fd82af243c9f122595762f5b0e48bd3d1ea8fe3f3eb5e95c130e881"
|
||||
checksum = "c83757d61dc75b3e9d3579a3004c7fe0c42aff2f779ca9eeec7f0c8e1ae3fc10"
|
||||
dependencies = [
|
||||
"bon",
|
||||
"dial9-core",
|
||||
@@ -3990,9 +3990,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dial9-tokio-telemetry"
|
||||
version = "0.5.7"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0428cba60fbcf37ab82666f3a7ef3b6d0671e90dfb6a352376813b471319f061"
|
||||
checksum = "6f0eb05627c12c57e40510e63f9a3d5f628ab4b32fec9ccdb0ae98544c13f15e"
|
||||
dependencies = [
|
||||
"bon",
|
||||
"bytes",
|
||||
@@ -4525,9 +4525,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
@@ -4594,7 +4594,7 @@ checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
|
||||
dependencies = [
|
||||
"io-lifetimes 2.0.4",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4698,6 +4698,12 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
@@ -5385,9 +5391,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hotpath"
|
||||
version = "0.30.3"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9632c1fd213e63c1b88d8f3e59543da4999b17f859cc1e1d67cf8f2207e8935"
|
||||
checksum = "4039758c8bca06a34c475d17aaafc17ac1073ce2cecd8dba8c82e69edb300bbe"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-channel",
|
||||
@@ -5420,15 +5426,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hotpath-drain"
|
||||
version = "0.30.3"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "738324f75aec847dea984f6806402f4f1bdfab437b4700fc8c3ebcad2dff6424"
|
||||
checksum = "bbba0a3badfb9ebf95f22dc6cdb4afcd0ff07b91a85fa3196fd1060576ea3065"
|
||||
|
||||
[[package]]
|
||||
name = "hotpath-macros"
|
||||
version = "0.30.3"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d92fdfec803932e5764ea93c201cf7c6c2d8cff1871da44f64f5094c9c3ef8a"
|
||||
checksum = "7d980014875338771b00ac44f1c9e2ea1a10650aa68ce54226233bc767714361"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6412,9 +6418,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490750560fa7851fbbf5d3ab1a8750f09350ed361ed8aed85b1e1e40587812ee"
|
||||
checksum = "ef1d73c59a56771343712b22ea5615092c612c16ba291c0979e3105dfeec4890"
|
||||
dependencies = [
|
||||
"sha2 0.11.1",
|
||||
]
|
||||
@@ -7914,10 +7920,34 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared 0.14.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.12.1"
|
||||
@@ -9999,6 +10029,114 @@ dependencies = [
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-gateway"
|
||||
version = "0.57.0"
|
||||
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-timer",
|
||||
"futures-util",
|
||||
"http 1.5.0",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"httparse",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
"rustfs-gateway-core",
|
||||
"rustfs-gateway-http",
|
||||
"rustfs-gateway-macros",
|
||||
"rustfs-gateway-sig",
|
||||
"rustfs-gateway-stream",
|
||||
"rustfs-gateway-types",
|
||||
"rustfs-gateway-xml",
|
||||
"sha2 0.11.1",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-gateway-adapter"
|
||||
version = "1.0.5"
|
||||
dependencies = [
|
||||
"http 1.5.0",
|
||||
"rustfs-credentials",
|
||||
"rustfs-gateway",
|
||||
"rustfs-gateway-http",
|
||||
"rustfs-iam",
|
||||
"rustfs-policy",
|
||||
"serde_json",
|
||||
"time",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-gateway-core"
|
||||
version = "0.54.0"
|
||||
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http 1.5.0",
|
||||
"md-5 0.11.0",
|
||||
"percent-encoding",
|
||||
"rustfs-gateway-http",
|
||||
"rustfs-gateway-sig",
|
||||
"rustfs-gateway-stream",
|
||||
"rustfs-gateway-types",
|
||||
"rustfs-gateway-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-gateway-http"
|
||||
version = "0.1.4"
|
||||
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
|
||||
dependencies = [
|
||||
"hmac 0.13.0",
|
||||
"http 1.5.0",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"phf 0.14.0",
|
||||
"rustfs-gateway-stream",
|
||||
"rustfs-gateway-types",
|
||||
"sha2 0.11.1",
|
||||
"smallvec",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-gateway-macros"
|
||||
version = "0.1.4"
|
||||
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-gateway-sig"
|
||||
version = "0.13.0"
|
||||
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
|
||||
dependencies = [
|
||||
"hmac 0.13.0",
|
||||
"http 1.5.0",
|
||||
"rustfs-gateway-http",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"smallvec",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-gateway-stream"
|
||||
version = "0.2.0"
|
||||
@@ -10388,18 +10526,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-mimalloc"
|
||||
version = "0.7.3"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "216ecfc9341b5d6e2506d47ce57c9232ae1b8020bb10264ea05286fb234286e7"
|
||||
checksum = "fc7914232a854336c62058c24a8362c553ac8e7adba2e2395ce9097db24d1510"
|
||||
dependencies = [
|
||||
"rustfs-mimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-mimalloc-sys"
|
||||
version = "0.7.3"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb16ad68f96883407a75cdb88252b43dc2ea592428f0fc54ff7885756de4b814"
|
||||
checksum = "8073624242781448512682f1931624ee67108b6b7873b22fb29c1a1df689ac0c"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
@@ -11773,9 +11911,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -12105,9 +12243,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.4"
|
||||
version = "1.16.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
|
||||
checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -12302,9 +12440,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "starshard"
|
||||
version = "2.4.0"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc45de5060fab0de00d7a42230877543ae00c37caf6f49975e69851089b023ac"
|
||||
checksum = "b0883700b50fd0b62120ebc4c07230e02f0cf1ead474c3b3b44eaa13e0a999ea"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hashbrown 0.17.1",
|
||||
@@ -12553,9 +12691,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tar-codec"
|
||||
version = "0.0.15"
|
||||
version = "0.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9dad615e50d181afea4bafea23dacb8e33ed52dde06503813570634b932d467"
|
||||
checksum = "6875ba507bbec6022500f46477159a03f07ebdffdb4ea043254974531df1dac2"
|
||||
dependencies = [
|
||||
"archive-trait",
|
||||
"tar-framing",
|
||||
@@ -12565,9 +12703,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tar-framing"
|
||||
version = "0.0.15"
|
||||
version = "0.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "563e8181b47c9cdcce569eae3ad431eec71543d6269dd60c7bd2350f70bcd233"
|
||||
checksum = "49727b1a300514960d425d0e741a7da3e693170f3db39de3180fdbbc07e542c5"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
@@ -12603,10 +12741,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12941,9 +13079,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.2.0+spec-1.1.0"
|
||||
version = "1.2.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5ffcdf8f50436dbbfd675c1fe1b8e0854f42b7defea8584bdebe1aee900e561"
|
||||
checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7"
|
||||
dependencies = [
|
||||
"indexmap 2.14.2",
|
||||
"serde_core",
|
||||
@@ -12956,9 +13094,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||
checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -13851,15 +13989,6 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
@@ -14032,7 +14161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14282,9 +14411,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.3.0"
|
||||
version = "9.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
|
||||
checksum = "0700e6c427aff64acdbf02e85fbfd946c2ee12319d1af013092f3a2178914ff8"
|
||||
dependencies = [
|
||||
"aes 0.9.4",
|
||||
"bzip2",
|
||||
|
||||
+12
-4
@@ -26,6 +26,7 @@ members = [
|
||||
"crates/ecstore", # Erasure coding storage implementation
|
||||
"crates/e2e_test", # End-to-end test suite
|
||||
"crates/filemeta", # File metadata management
|
||||
"crates/gateway-adapter", # RustFS implementations of the gateway extension traits
|
||||
"crates/heal", # Erasure set and object healing
|
||||
"crates/heal-contracts", # Heal request/response channel contracts
|
||||
"crates/iam", # Identity and Access Management
|
||||
@@ -93,6 +94,7 @@ redundant_clone = "warn"
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.5" }
|
||||
rustfs-gateway-adapter = { path = "crates/gateway-adapter", version = "1.0.5" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.5" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.5" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.5" }
|
||||
@@ -149,6 +151,12 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.5" }
|
||||
# the s3s-facing edge of the rustfs binary.
|
||||
rustfs-gateway-types = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.32.0", default-features = false }
|
||||
rustfs-gateway-stream = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.2.0", default-features = false }
|
||||
# The gateway framework itself, consumed for its extension traits by the ring-2
|
||||
# adapter. `default-features = false` keeps the `server` feature, and with it the
|
||||
# server crate, out of the build.
|
||||
rustfs-gateway = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.57.0", default-features = false }
|
||||
# Test-only: builds the `EffectiveHost` a host-resolution test queries with.
|
||||
rustfs-gateway-http = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", default-features = false }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -156,7 +164,7 @@ async_zip = { default-features = false, version = "0.0.19" }
|
||||
mysql_async = { default-features = false, version = "0.37.1" }
|
||||
async-compression = { version = "0.4.52" }
|
||||
async-trait = "0.1.92"
|
||||
async-nats = { version = "0.51.0", default-features = false }
|
||||
async-nats = { version = "0.52.0", default-features = false }
|
||||
axum = "0.8.9"
|
||||
futures = "0.3.34"
|
||||
futures-core = "0.3.34"
|
||||
@@ -256,8 +264,8 @@ anyhow = "1.0.104"
|
||||
arc-swap = "1.9.3"
|
||||
rustfs-tokio-tar = { version = "0.7.1" }
|
||||
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
|
||||
tar-codec = "0.0.15"
|
||||
tar-framing = "0.0.15"
|
||||
tar-codec = "0.0.16"
|
||||
tar-framing = "0.0.16"
|
||||
atoi = "3.1.1"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.13.0" }
|
||||
@@ -401,7 +409,7 @@ dav-server = "0.11.0"
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.7.3" }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "0.30.3", default-features = false }
|
||||
hotpath = { version = "0.31.1", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.49" }
|
||||
|
||||
|
||||
+11
-2
@@ -12,7 +12,9 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
FROM alpine:3.24.2 AS build
|
||||
ARG BINARY_SOURCE=download
|
||||
|
||||
FROM alpine:3.24.2 AS download
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG RELEASE=latest
|
||||
@@ -74,6 +76,14 @@ COPY scripts/download_rc.sh /usr/local/bin/download_rc.sh
|
||||
RUN sh /usr/local/bin/download_rc.sh "$TARGETARCH" musl "$RC_RELEASE" /build/rc
|
||||
|
||||
|
||||
# Candidate packaging supplies the accepted executables from verified archives.
|
||||
# The default download stage remains available for local image builds.
|
||||
FROM scratch AS supplied
|
||||
ARG TARGETARCH
|
||||
COPY .release-inputs/${TARGETARCH}/ /build/
|
||||
|
||||
FROM ${BINARY_SOURCE} AS build
|
||||
|
||||
FROM alpine:3.24.2
|
||||
|
||||
ARG RELEASE=latest
|
||||
@@ -103,7 +113,6 @@ RUN apk upgrade --no-cache && \
|
||||
tzdata \
|
||||
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530"
|
||||
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=build /build/rustfs /usr/bin/rustfs
|
||||
COPY --from=build /build/rc /usr/bin/rc
|
||||
COPY entrypoint.sh /etc/rustfs/entrypoint.sh
|
||||
|
||||
+11
-1
@@ -12,7 +12,9 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
FROM ubuntu:26.04 AS build
|
||||
ARG BINARY_SOURCE=download
|
||||
|
||||
FROM ubuntu:26.04 AS download
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG RELEASE=latest
|
||||
@@ -80,6 +82,14 @@ RUN set -eux; \
|
||||
COPY scripts/download_rc.sh /usr/local/bin/download_rc.sh
|
||||
RUN sh /usr/local/bin/download_rc.sh "$TARGETARCH" gnu "$RC_RELEASE" /build/rc
|
||||
|
||||
# Candidate packaging supplies the accepted executables from verified archives.
|
||||
# The default download stage remains available for local image builds.
|
||||
FROM scratch AS supplied
|
||||
ARG TARGETARCH
|
||||
COPY .release-inputs/${TARGETARCH}/ /build/
|
||||
|
||||
FROM ${BINARY_SOURCE} AS build
|
||||
|
||||
FROM ubuntu:26.04
|
||||
|
||||
ARG RELEASE=latest
|
||||
|
||||
+22
-4
@@ -24,9 +24,10 @@
|
||||
#
|
||||
# Supports cross-compilation for amd64 and arm64 via TARGETPLATFORM.
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETARCH
|
||||
# Do not declare TARGETPLATFORM, BUILDPLATFORM or TARGETARCH here: a global
|
||||
# declaration without a value shadows BuildKit's automatic values, so the
|
||||
# stage-level re-declarations below see empty strings. They are only
|
||||
# declared inside the stage that uses them.
|
||||
ARG RUSTFS_BUILD_FEATURES=""
|
||||
ARG RC_RELEASE=latest
|
||||
|
||||
@@ -169,7 +170,24 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
;; \
|
||||
esac
|
||||
|
||||
RUN sh /usr/src/rustfs/scripts/download_rc.sh "$TARGETARCH" musl "$RC_RELEASE" /usr/local/bin/rc
|
||||
# TARGETARCH is only provided by BuildKit. Fall back to TARGETPLATFORM (for
|
||||
# example set by docker-compose) and then to the build host, like the cargo
|
||||
# step above, so every path that builds this file passes a real architecture.
|
||||
RUN set -eu; \
|
||||
rc_arch="${TARGETARCH:-}"; \
|
||||
if [ -z "${rc_arch}" ]; then \
|
||||
case "${TARGETPLATFORM:-}" in \
|
||||
linux/amd64) rc_arch=amd64 ;; \
|
||||
linux/arm64) rc_arch=arm64 ;; \
|
||||
*) \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) rc_arch=amd64 ;; \
|
||||
aarch64|arm64) rc_arch=arm64 ;; \
|
||||
*) echo "TARGETARCH is empty and TARGETPLATFORM='${TARGETPLATFORM:-}' is not linux/amd64 or linux/arm64; cannot choose an rc architecture on $(uname -m)" >&2; exit 1 ;; \
|
||||
esac ;; \
|
||||
esac; \
|
||||
fi; \
|
||||
sh /usr/src/rustfs/scripts/download_rc.sh "${rc_arch}" musl "${RC_RELEASE}" /usr/local/bin/rc
|
||||
|
||||
# -----------------------------
|
||||
# Development stage (keeps toolchain)
|
||||
|
||||
@@ -145,7 +145,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# Using specific version
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.5
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
@@ -224,7 +224,7 @@ For developers who want to build RustFS Docker images from source with multi-arc
|
||||
./docker-buildx.sh --push
|
||||
|
||||
# Build specific version
|
||||
./docker-buildx.sh --release 1.0.4 --push
|
||||
./docker-buildx.sh --release 1.0.5 --push
|
||||
|
||||
# Build for custom registry
|
||||
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
||||
@@ -242,7 +242,7 @@ You can also use Make targets for convenience:
|
||||
```bash
|
||||
make docker-buildx # Build locally
|
||||
make docker-buildx-push # Build and push
|
||||
make docker-buildx-version VERSION=1.0.4 # Build specific version
|
||||
make docker-buildx-version VERSION=1.0.5 # Build specific version
|
||||
make help-docker # Show all Docker-related commands
|
||||
```
|
||||
|
||||
|
||||
+3
-3
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# 使用指定版本运行
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.5
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
|
||||
./docker-buildx.sh --push
|
||||
|
||||
# 构建指定版本
|
||||
./docker-buildx.sh --release 1.0.4 --push
|
||||
./docker-buildx.sh --release 1.0.5 --push
|
||||
|
||||
# 构建并推送到自定义仓库
|
||||
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
||||
@@ -193,7 +193,7 @@ docker compose -f docker-compose-simple.yml up -d
|
||||
```bash
|
||||
make docker-buildx # 本地构建
|
||||
make docker-buildx-push # 构建并推送
|
||||
make docker-buildx-version VERSION=1.0.4 # 构建指定版本
|
||||
make docker-buildx-version VERSION=1.0.5 # 构建指定版本
|
||||
make help-docker # 显示所有 Docker 相关命令
|
||||
```
|
||||
|
||||
|
||||
@@ -902,4 +902,104 @@ mod tests {
|
||||
assert_eq!(raw.len(), 8);
|
||||
assert_eq!(&raw[..], reference.digest().to_be_bytes().as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksums_streaming_chunks_and_header_encoding_match_oneshot_across_unaligned_boundaries() {
|
||||
use crate::http::HttpChecksum;
|
||||
|
||||
let all = [
|
||||
ChecksumAlgorithm::Crc32,
|
||||
ChecksumAlgorithm::Crc32c,
|
||||
ChecksumAlgorithm::Crc64Nvme,
|
||||
ChecksumAlgorithm::Sha1,
|
||||
ChecksumAlgorithm::Sha256,
|
||||
ChecksumAlgorithm::Sha512,
|
||||
ChecksumAlgorithm::Xxhash3,
|
||||
ChecksumAlgorithm::Xxhash64,
|
||||
ChecksumAlgorithm::Xxhash128,
|
||||
];
|
||||
|
||||
for &payload_len in &[0usize, 1, 31, 33, 1024, 4096 + 37, 64 * 1024 + 19] {
|
||||
let payload: Vec<u8> = (0..payload_len).map(|i| ((i * 19 + 5) % 253) as u8).collect();
|
||||
for algorithm in all {
|
||||
let mut one_shot = algorithm.into_impl();
|
||||
one_shot.update(&payload);
|
||||
let expected_raw = one_shot.finalize();
|
||||
assert_eq!(expected_raw.len(), algorithm.raw_len());
|
||||
|
||||
let mut one_shot_hdr = algorithm.into_impl();
|
||||
one_shot_hdr.update(&payload);
|
||||
let expected_headers = one_shot_hdr.headers();
|
||||
|
||||
for &chunk_size in &[1usize, 31, 1021, 4096] {
|
||||
if payload_len > 4096 && chunk_size == 1 {
|
||||
continue;
|
||||
}
|
||||
let mut streaming: Box<dyn HttpChecksum> = algorithm.into_impl();
|
||||
for chunk in payload.chunks(chunk_size) {
|
||||
streaming.update(chunk);
|
||||
}
|
||||
let actual_headers = streaming.headers();
|
||||
assert_eq!(
|
||||
actual_headers.get(algorithm.http_header_name()),
|
||||
expected_headers.get(algorithm.http_header_name()),
|
||||
"{algorithm:?} streaming mismatch at payload_len={payload_len}, chunk_size={chunk_size}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "utility throughput microbenchmark; run via cargo test -p rustfs-checksums --lib --release -- --ignored --nocapture"]
|
||||
fn microbench_checksums_streaming_chunks_and_header_encoding() {
|
||||
use crate::http::HttpChecksum;
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
let all = [
|
||||
ChecksumAlgorithm::Crc32,
|
||||
ChecksumAlgorithm::Crc32c,
|
||||
ChecksumAlgorithm::Crc64Nvme,
|
||||
ChecksumAlgorithm::Sha1,
|
||||
ChecksumAlgorithm::Sha256,
|
||||
ChecksumAlgorithm::Sha512,
|
||||
ChecksumAlgorithm::Xxhash3,
|
||||
ChecksumAlgorithm::Xxhash64,
|
||||
ChecksumAlgorithm::Xxhash128,
|
||||
];
|
||||
|
||||
let payload: Vec<u8> = (0..(256 * 1024)).map(|i| ((i * 19 + 5) % 253) as u8).collect();
|
||||
for &chunk_size in &[4 * 1024usize, 64 * 1024, 256 * 1024] {
|
||||
for algorithm in all {
|
||||
let mut one_shot = algorithm.into_impl();
|
||||
one_shot.update(&payload);
|
||||
let expected_headers = one_shot.headers();
|
||||
|
||||
let iters = 8usize;
|
||||
let t0 = Instant::now();
|
||||
let mut last_headers = http::HeaderMap::new();
|
||||
for _ in 0..iters {
|
||||
let mut streaming: Box<dyn HttpChecksum> = algorithm.into_impl();
|
||||
for chunk in payload.chunks(chunk_size) {
|
||||
streaming.update(black_box(chunk));
|
||||
}
|
||||
last_headers = streaming.headers();
|
||||
black_box(&last_headers);
|
||||
}
|
||||
let elapsed = t0.elapsed();
|
||||
assert_eq!(
|
||||
last_headers.get(algorithm.http_header_name()),
|
||||
expected_headers.get(algorithm.http_header_name()),
|
||||
"{algorithm:?} streaming chunk_size={chunk_size} header mismatch"
|
||||
);
|
||||
let total_mib = (payload.len() * iters) as f64 / (1024.0 * 1024.0);
|
||||
let mibs = total_mib / elapsed.as_secs_f64().max(1e-9);
|
||||
println!(
|
||||
"[microbench][checksums] algo={algorithm:?} chunk_size={chunk_size}B throughput={mibs:.1} MiB/s (size={}B)",
|
||||
algorithm.raw_len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1285,7 +1285,9 @@ async fn list_parts_disk_loss_after_metadata_read(
|
||||
let error = result.expect_err("one missing directory and three unavailable participants cannot prove an empty upload");
|
||||
let response = error.raw_response().expect("ListParts must return an S3 service response");
|
||||
assert_eq!(response.status().as_u16(), 503);
|
||||
assert_eq!(error.as_service_error().and_then(|err| err.meta().code()), Some("SlowDownRead"));
|
||||
// Since #9149, SlowDownRead is reserved for GET/HEAD object reads; a
|
||||
// ListParts read-quorum failure is reported as ServiceUnavailable.
|
||||
assert_eq!(error.as_service_error().and_then(|err| err.meta().code()), Some("ServiceUnavailable"));
|
||||
assert!(response.headers().get("x-rustfs-error-cause").is_none());
|
||||
assert!(response.headers().get("retry-after").is_none());
|
||||
assert!(
|
||||
|
||||
@@ -493,7 +493,12 @@ async fn compare_degraded_legacy_list_parts(dist: &mut DistCluster, seed: &Upgra
|
||||
assert!(legacy.parts().is_empty());
|
||||
let current = current.expect_err("current coordinator must not infer empty from missing directories and offline disks");
|
||||
assert_eq!(current.raw_response().expect("current S3 response").status().as_u16(), 503);
|
||||
assert_eq!(current.as_service_error().and_then(|error| error.meta().code()), Some("SlowDownRead"));
|
||||
// Since #9149, SlowDownRead is reserved for GET/HEAD object reads; a
|
||||
// ListParts read-quorum failure is reported as ServiceUnavailable.
|
||||
assert_eq!(
|
||||
current.as_service_error().and_then(|error| error.meta().code()),
|
||||
Some("ServiceUnavailable")
|
||||
);
|
||||
dist.cluster.start_node_from_binary(3, previous).await?;
|
||||
wait_for_upgrade_write_readiness(&clients, &seed.history_bucket, "mixed-list-restored", CREDENTIAL_TIMEOUT).await?;
|
||||
for (path, before) in original_files {
|
||||
|
||||
@@ -21,12 +21,15 @@
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use crate::common::{RustFSTestEnvironment, admin_create_user, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
|
||||
use std::error::Error;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
async fn create_bucket(client: &Client, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
Ok(())
|
||||
@@ -90,7 +93,7 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Duplicate and repeated slashes after a leading slash collapse MinIO-style.
|
||||
/// Duplicate and repeated slashes after a leading slash collapse MinIO-style across single and bulk operations.
|
||||
#[tokio::test]
|
||||
async fn test_put_object_with_duplicate_slashes_normalized() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -144,8 +147,258 @@ mod tests {
|
||||
"GET after DELETE with raw key must return HTTP 404, got {error:?}"
|
||||
);
|
||||
|
||||
assert_bulk_delete_normalizes_keys(&client).await?;
|
||||
assert_bulk_delete_authorizes_normalized_keys(&env, &client).await?;
|
||||
assert_bulk_delete_preserves_version_identity(&client).await?;
|
||||
|
||||
env.stop_server();
|
||||
info!("Test completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_bulk_delete_normalizes_keys(client: &Client) -> TestResult {
|
||||
let bucket = "test-bulk-leading-slash";
|
||||
create_bucket(client, bucket).await?;
|
||||
let cases = [
|
||||
("/foo/bar", "foo/bar"),
|
||||
("//keyname", "keyname"),
|
||||
("/dir///sub//file", "dir/sub/file"),
|
||||
("/folder///", "folder/"),
|
||||
("/literal%2F+名", "literal%2F+名"),
|
||||
("plain", "plain"),
|
||||
];
|
||||
let invalid = ["///", "/dir/../keep", "dir//keep"];
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("keep")
|
||||
.body(ByteStream::from_static(b"keep"))
|
||||
.send()
|
||||
.await?;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("dir/keep")
|
||||
.body(ByteStream::from_static(b"keep"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
for quiet in [false, true] {
|
||||
for (raw, _) in cases {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(raw)
|
||||
.body(ByteStream::from_static(b""))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let listing = client.list_objects_v2().bucket(bucket).send().await?;
|
||||
let stored: Vec<_> = listing.contents().iter().filter_map(|object| object.key()).collect();
|
||||
let mut expected: Vec<_> = cases
|
||||
.iter()
|
||||
.map(|(_, normalized)| *normalized)
|
||||
.chain(["dir/keep", "keep"])
|
||||
.collect();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(stored, expected, "PUT must store the exact normalized targets before bulk deletion");
|
||||
let objects = cases
|
||||
.iter()
|
||||
.map(|(raw, _)| *raw)
|
||||
.chain(invalid)
|
||||
.map(|key| ObjectIdentifier::builder().key(key).build())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let delete = Delete::builder().set_objects(Some(objects)).quiet(quiet).build()?;
|
||||
// A second identical batch also pins idempotent deletion of absent normalized keys.
|
||||
for _ in 0..2 {
|
||||
let output = client.delete_objects().bucket(bucket).delete(delete.clone()).send().await?;
|
||||
let errors: Vec<_> = output.errors().iter().map(|error| (error.key(), error.code())).collect();
|
||||
assert_eq!(
|
||||
errors,
|
||||
invalid.map(|key| (Some(key), Some("InvalidArgument"))),
|
||||
"unexpected per-key errors: {output:?}"
|
||||
);
|
||||
let deleted: Vec<_> = output.deleted().iter().filter_map(|object| object.key()).collect();
|
||||
let expected = if quiet {
|
||||
vec![]
|
||||
} else {
|
||||
cases.iter().map(|(raw, _)| *raw).collect()
|
||||
};
|
||||
assert_eq!(deleted, expected, "responses must retain request keys, including directory slashes");
|
||||
}
|
||||
let listing = client.list_objects_v2().bucket(bucket).send().await?;
|
||||
let remaining: Vec<_> = listing.contents().iter().filter_map(|object| object.key()).collect();
|
||||
assert_eq!(
|
||||
remaining,
|
||||
["dir/keep", "keep"],
|
||||
"bulk delete must remove normalized objects without resolving dot segments"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_bulk_delete_authorizes_normalized_keys(env: &RustFSTestEnvironment, admin: &Client) -> TestResult {
|
||||
let bucket = "test-bulk-slash-policy";
|
||||
let user = "bulk-slash-user";
|
||||
admin_create_user(env, user, "bulkSlashTestSecret123!").await?;
|
||||
create_bucket(admin, bucket).await?;
|
||||
for key in ["allowed/item", "protected/item"] {
|
||||
admin
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"keep"))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{"Effect": "Allow", "Principal": {"AWS": [user]}, "Action": ["s3:DeleteObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{bucket}/*")]},
|
||||
{"Effect": "Deny", "Principal": {"AWS": [user]}, "Action": ["s3:DeleteObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{bucket}/protected/*")]}
|
||||
]
|
||||
});
|
||||
admin
|
||||
.put_bucket_policy()
|
||||
.bucket(bucket)
|
||||
.policy(policy.to_string())
|
||||
.send()
|
||||
.await?;
|
||||
let client = env.create_s3_client_with_credentials(user, "bulkSlashTestSecret123!");
|
||||
let output = client
|
||||
.delete_objects()
|
||||
.bucket(bucket)
|
||||
.delete(
|
||||
Delete::builder()
|
||||
.objects(ObjectIdentifier::builder().key("/allowed///item").build()?)
|
||||
.objects(ObjectIdentifier::builder().key("/protected///item").build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(output.deleted().len(), 1, "allowed normalized key must be deleted: {output:?}");
|
||||
assert_eq!(output.deleted()[0].key(), Some("/allowed///item"));
|
||||
assert_eq!(output.errors().len(), 1, "normalized resource denial must apply: {output:?}");
|
||||
assert_eq!(output.errors()[0].key(), Some("/protected///item"));
|
||||
assert_eq!(output.errors()[0].code(), Some("AccessDenied"));
|
||||
let listing = admin.list_objects_v2().bucket(bucket).send().await?;
|
||||
let remaining: Vec<_> = listing.contents().iter().filter_map(|object| object.key()).collect();
|
||||
assert_eq!(remaining, ["protected/item"], "only the authorized object may be removed");
|
||||
let protected = admin.get_object().bucket(bucket).key("protected/item").send().await?;
|
||||
assert_eq!(protected.body.collect().await?.into_bytes().as_ref(), b"keep");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_bulk_delete_preserves_version_identity(client: &Client) -> TestResult {
|
||||
let bucket = "test-bulk-slash-versions";
|
||||
let raw = "/history///file";
|
||||
let normalized = "history/file";
|
||||
create_bucket(client, bucket).await?;
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let first = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(raw)
|
||||
.body(ByteStream::from_static(b"old"))
|
||||
.send()
|
||||
.await?;
|
||||
let first_id = first.version_id().ok_or("first PUT omitted version ID")?;
|
||||
let latest = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(raw)
|
||||
.body(ByteStream::from_static(b"latest"))
|
||||
.send()
|
||||
.await?;
|
||||
let latest_id = latest.version_id().ok_or("latest PUT omitted version ID")?;
|
||||
let invalid = client
|
||||
.delete_objects()
|
||||
.bucket(bucket)
|
||||
.delete(
|
||||
Delete::builder()
|
||||
.objects(ObjectIdentifier::builder().key("///").build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(invalid.deleted().is_empty(), "slash-only keys must not delete a versioned object");
|
||||
assert_eq!(
|
||||
invalid.errors().len(),
|
||||
1,
|
||||
"slash-only keys must remain invalid in versioned buckets: {invalid:?}"
|
||||
);
|
||||
assert_eq!(invalid.errors()[0].key(), Some("///"));
|
||||
assert_eq!(invalid.errors()[0].code(), Some("InvalidArgument"));
|
||||
let deleted = client
|
||||
.delete_objects()
|
||||
.bucket(bucket)
|
||||
.delete(
|
||||
Delete::builder()
|
||||
.objects(ObjectIdentifier::builder().key(raw).version_id(first_id).build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(deleted.errors().is_empty(), "historical delete failed: {deleted:?}");
|
||||
assert_eq!(deleted.deleted().len(), 1);
|
||||
assert_eq!(deleted.deleted()[0].key(), Some(raw));
|
||||
assert_eq!(deleted.deleted()[0].version_id(), Some(first_id));
|
||||
let listing = client.list_object_versions().bucket(bucket).send().await?;
|
||||
assert_eq!(listing.versions().len(), 1, "only the latest data version should remain: {listing:?}");
|
||||
assert_eq!(listing.versions()[0].version_id(), Some(latest_id));
|
||||
|
||||
let deleted = client
|
||||
.delete_objects()
|
||||
.bucket(bucket)
|
||||
.delete(
|
||||
Delete::builder()
|
||||
.objects(ObjectIdentifier::builder().key(raw).build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(deleted.errors().is_empty(), "delete marker creation failed: {deleted:?}");
|
||||
assert_eq!(deleted.deleted().len(), 1);
|
||||
assert_eq!(deleted.deleted()[0].key(), Some(raw));
|
||||
assert_eq!(deleted.deleted()[0].delete_marker(), Some(true));
|
||||
let marker = deleted.deleted()[0]
|
||||
.delete_marker_version_id()
|
||||
.ok_or("missing delete marker ID")?;
|
||||
let listing = client.list_object_versions().bucket(bucket).send().await?;
|
||||
assert_eq!(listing.delete_markers().len(), 1);
|
||||
assert_eq!(listing.delete_markers()[0].key(), Some(normalized));
|
||||
assert_eq!(listing.delete_markers()[0].version_id(), Some(marker));
|
||||
|
||||
let deleted = client
|
||||
.delete_objects()
|
||||
.bucket(bucket)
|
||||
.delete(
|
||||
Delete::builder()
|
||||
.objects(ObjectIdentifier::builder().key(raw).version_id(marker).build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(deleted.errors().is_empty(), "explicit marker removal failed: {deleted:?}");
|
||||
assert_eq!(deleted.deleted().len(), 1);
|
||||
assert_eq!(deleted.deleted()[0].key(), Some(raw));
|
||||
assert_eq!(deleted.deleted()[0].version_id(), Some(marker));
|
||||
let listing = client.list_object_versions().bucket(bucket).send().await?;
|
||||
assert!(listing.delete_markers().is_empty());
|
||||
let restored = client.get_object().bucket(bucket).key(normalized).send().await?;
|
||||
assert_eq!(restored.version_id(), Some(latest_id));
|
||||
assert_eq!(restored.body.collect().await?.into_bytes().as_ref(), b"latest");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4325,20 +4325,28 @@ fn take_presynced_tmp_file(file: &Path) -> bool {
|
||||
PRESYNCED_TMP_FILES.remove(file).is_some()
|
||||
}
|
||||
|
||||
fn open_lazy_local_file_std(file_path: &Path, volume_dir: &Path, _reclaim_on_shutdown: bool) -> std::io::Result<std::fs::File> {
|
||||
if let Some(parent) = file_path.parent()
|
||||
&& (volume_dir.as_os_str().is_empty() || !volume_dir.starts_with(parent))
|
||||
&& let Err(err) = super::fs::create_dir_all_std(parent)
|
||||
&& err.kind() != std::io::ErrorKind::AlreadyExists
|
||||
{
|
||||
return Err(to_file_error(err));
|
||||
}
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(file_path)
|
||||
.map_err(to_file_error)?;
|
||||
fn open_lazy_local_file_std(
|
||||
file_path: &Path,
|
||||
volume_dir: &Path,
|
||||
_reclaim_on_shutdown: bool,
|
||||
parent_exists: bool,
|
||||
) -> std::io::Result<std::fs::File> {
|
||||
let mut open_opts = std::fs::OpenOptions::new();
|
||||
open_opts.write(true).create(true).truncate(true);
|
||||
let file = match parent_exists.then(|| open_opts.open(file_path)) {
|
||||
Some(Ok(file)) => file,
|
||||
Some(Err(err)) if err.kind() != std::io::ErrorKind::NotFound => return Err(to_file_error(err)),
|
||||
_ => {
|
||||
if let Some(parent) = file_path.parent()
|
||||
&& (volume_dir.as_os_str().is_empty() || !volume_dir.starts_with(parent))
|
||||
&& let Err(err) = super::fs::create_dir_all_std(parent)
|
||||
&& err.kind() != std::io::ErrorKind::AlreadyExists
|
||||
{
|
||||
return Err(to_file_error(err));
|
||||
}
|
||||
open_opts.open(file_path).map_err(to_file_error)?
|
||||
}
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
if _reclaim_on_shutdown {
|
||||
let _ = set_std_fd_nocache(&file);
|
||||
@@ -4353,6 +4361,7 @@ struct LazyLocalFileWriter {
|
||||
reclaim_on_shutdown: bool,
|
||||
sync_on_close: bool,
|
||||
mark_dir_presynced: bool,
|
||||
parent_exists: bool,
|
||||
bytes_written: usize,
|
||||
state: LazyLocalFileWriterState,
|
||||
}
|
||||
@@ -4373,6 +4382,7 @@ impl LazyLocalFileWriter {
|
||||
reclaim_on_shutdown: bool,
|
||||
sync_on_close: bool,
|
||||
mark_dir_presynced: bool,
|
||||
parent_exists: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
file_path,
|
||||
@@ -4381,6 +4391,7 @@ impl LazyLocalFileWriter {
|
||||
reclaim_on_shutdown,
|
||||
sync_on_close,
|
||||
mark_dir_presynced,
|
||||
parent_exists,
|
||||
bytes_written: 0,
|
||||
state: LazyLocalFileWriterState::Unopened,
|
||||
}
|
||||
@@ -4393,12 +4404,13 @@ impl LazyLocalFileWriter {
|
||||
let reclaim_on_shutdown = self.reclaim_on_shutdown;
|
||||
let sync_on_close = self.sync_on_close;
|
||||
let mark_dir_presynced = self.mark_dir_presynced;
|
||||
let parent_exists = self.parent_exists;
|
||||
match std::mem::replace(&mut self.state, LazyLocalFileWriterState::Closed) {
|
||||
LazyLocalFileWriterState::Unopened => {
|
||||
let file_path = self.file_path.clone();
|
||||
let volume_dir = self.volume_dir.clone();
|
||||
self.state = LazyLocalFileWriterState::Writing(tokio::task::spawn_blocking(move || {
|
||||
let mut file = open_lazy_local_file_std(&file_path, &volume_dir, reclaim_on_shutdown)?;
|
||||
let mut file = open_lazy_local_file_std(&file_path, &volume_dir, reclaim_on_shutdown, parent_exists)?;
|
||||
std::io::Write::write_all(&mut file, &payload).map_err(to_file_error)?;
|
||||
if close_after {
|
||||
if sync_on_close {
|
||||
@@ -4540,8 +4552,9 @@ impl AsyncWrite for LazyLocalFileWriter {
|
||||
let file_path = self.file_path.clone();
|
||||
let volume_dir = self.volume_dir.clone();
|
||||
let reclaim_on_shutdown = self.reclaim_on_shutdown;
|
||||
let parent_exists = self.parent_exists;
|
||||
self.state = LazyLocalFileWriterState::Closing(tokio::task::spawn_blocking(move || {
|
||||
open_lazy_local_file_std(&file_path, &volume_dir, reclaim_on_shutdown).map(drop)
|
||||
open_lazy_local_file_std(&file_path, &volume_dir, reclaim_on_shutdown, parent_exists).map(drop)
|
||||
}));
|
||||
}
|
||||
LazyLocalFileWriterState::Open(file) => {
|
||||
@@ -5593,11 +5606,14 @@ impl LocalIoBackend for StdBackend {
|
||||
let file_path = local_disk_object_path(self.io_root(), volume, path)?;
|
||||
check_path_length(file_path.to_string_lossy().as_ref())?;
|
||||
let mut mark_dir_presynced = false;
|
||||
let mut parent_exists = false;
|
||||
if let Some(parent) = file_path.parent() {
|
||||
check_path_length(parent.to_string_lossy().as_ref())?;
|
||||
parent_exists = parent == volume_dir;
|
||||
if volume == RUSTFS_META_TMP_BUCKET {
|
||||
unmark_presynced_tmp_file(&file_path);
|
||||
let parent_was_presynced = unmark_presynced_tmp_data_dir(parent);
|
||||
parent_exists |= parent_was_presynced;
|
||||
let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
mark_dir_presynced = sync_on_close
|
||||
&& (file_name == "part.1"
|
||||
@@ -5651,6 +5667,7 @@ impl LocalIoBackend for StdBackend {
|
||||
reclaim_on_shutdown,
|
||||
sync_on_close,
|
||||
mark_dir_presynced,
|
||||
parent_exists,
|
||||
)))
|
||||
}
|
||||
WriteMode::Append => {
|
||||
@@ -11524,11 +11541,14 @@ impl LocalDisk {
|
||||
) -> Result<()> {
|
||||
multipart_staging::reject_reserved_anchor(path)?;
|
||||
let fuse_with_admission = admission.is_some() && !crash_inject::is_armed(CrashPoint::MetaWriteAfterTmpBeforeRename, path);
|
||||
// Held until the staging root is published, by the filesystem work
|
||||
// itself, so a concurrent listing cannot prune the marker in between.
|
||||
let mut index_pin = None;
|
||||
let prepared_index = if volume == RUSTFS_META_MULTIPART_BUCKET {
|
||||
if fuse_with_admission {
|
||||
self.multipart_index.prepare_record_head(path, &buf).await
|
||||
} else {
|
||||
self.multipart_index.record_head(path, &buf).await?;
|
||||
index_pin = self.multipart_index.record_head(path, &buf).await?;
|
||||
None
|
||||
}
|
||||
} else {
|
||||
@@ -11554,8 +11574,10 @@ impl LocalDisk {
|
||||
let root = self.publication_root.clone();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&file_path, namespace_owner).await;
|
||||
os::run_blocking_namespace_operation_with_sync(lease, do_sync, move || {
|
||||
if let Some(record) = prepared_index {
|
||||
record.run_std()?;
|
||||
let _index_pin = prepared_index.map(|record| record.run_std()).transpose()?;
|
||||
#[cfg(test)]
|
||||
if let Some(pin) = &_index_pin {
|
||||
pin.pause_for_test();
|
||||
}
|
||||
super::fs::create_dir_all_std(&tmp_volume_dir)?;
|
||||
#[cfg(test)]
|
||||
@@ -11599,12 +11621,16 @@ impl LocalDisk {
|
||||
return Err(DiskError::Unexpected);
|
||||
}
|
||||
|
||||
let publication_owner: Option<Arc<dyn Send + Sync>> = match index_pin {
|
||||
Some(pin) => Some(Arc::new((namespace_owner.clone(), pin))),
|
||||
None => namespace_owner.clone(),
|
||||
};
|
||||
os::rename_all_with_precondition_and_parent_sync(
|
||||
&tmp_file_path,
|
||||
&file_path,
|
||||
&volume_dir,
|
||||
&self.publication_root,
|
||||
namespace_owner.clone(),
|
||||
publication_owner,
|
||||
admission.as_ref().map(|admission| admission.precondition()),
|
||||
admission.is_some() && sync && durability.syncs_commit_metadata(),
|
||||
)
|
||||
|
||||
@@ -286,12 +286,15 @@ pub(super) fn fsync_inline_commit_std(
|
||||
|
||||
if destination_was_absent {
|
||||
let mut ancestor = dst.parent().and_then(Path::parent);
|
||||
let mut ancestor_step = 0usize;
|
||||
while let Some(ancestor_dir) = ancestor {
|
||||
if !ancestor_dir.starts_with(dst_volume_dir) {
|
||||
break;
|
||||
}
|
||||
let fsync_started = rustfs_io_metrics::put_stage_timer();
|
||||
if let Err(err) = os::fsync_dir_limited_std(ancestor_dir) {
|
||||
if let Err(err) =
|
||||
os::fsync_commit_ancestor_directory_limited_std(ancestor_dir, dst_volume_dir, ancestor_step, commit_guard)
|
||||
{
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_ANCESTOR_DIR_FSYNC,
|
||||
fsync_started,
|
||||
@@ -310,6 +313,7 @@ pub(super) fn fsync_inline_commit_std(
|
||||
break;
|
||||
}
|
||||
ancestor = ancestor_dir.parent();
|
||||
ancestor_step += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -517,8 +521,12 @@ impl LocalDisk {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
})?;
|
||||
crate::hp_guard!("LocalDisk::rename_data");
|
||||
if dst_volume == super::super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
self.multipart_index.record_info(dst_path, &fi).await?;
|
||||
if dst_volume == super::super::RUSTFS_META_MULTIPART_BUCKET
|
||||
&& let Some(pin) = self.multipart_index.record_info(dst_path, &fi).await?
|
||||
{
|
||||
// The owner chain reaches every lease this rename publishes under,
|
||||
// so the index marker stays pinned until that work has returned.
|
||||
state.namespace_owner = Some(Arc::new((state.namespace_owner.take(), pin)));
|
||||
}
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if fi.is_healing() {
|
||||
|
||||
@@ -41,19 +41,53 @@ struct DiscoverySession {
|
||||
touched: Instant,
|
||||
}
|
||||
|
||||
/// Plain (non-transaction) children of an object-scoped discovery root cost one
|
||||
/// budget entry per this many names: enough to bound a hostile prefix without
|
||||
/// making a real folder prefix undeletable.
|
||||
/// Names an object-scoped scan skips without a stat (plain children of the
|
||||
/// key's directory, and entries of a UUID child that cannot be delete evidence)
|
||||
/// cost one budget entry per this many names: enough to bound a hostile prefix
|
||||
/// without making a real folder prefix undeletable.
|
||||
const PLAIN_CHILDREN_PER_BUDGET_ENTRY: u32 = 64;
|
||||
|
||||
/// Entries one step of the resumable (background) discovery may visit. The
|
||||
/// foreground bounded protocol has its own, larger per-call allowance.
|
||||
const DISCOVERY_STEP_ENTRIES: u32 = 4096;
|
||||
|
||||
/// Wall-clock allowance of one bounded discovery call: a third of the drive
|
||||
/// timeout that wraps the call (including its wait for a lane worker), capped at
|
||||
/// ten seconds, so an incomplete walk returns before the call can time out.
|
||||
fn bounded_discovery_time_limit() -> std::time::Duration {
|
||||
(crate::disk::disk_store::get_max_timeout_duration() / 3).min(std::time::Duration::from_secs(10))
|
||||
}
|
||||
|
||||
/// Whether an entry inside a UUID child of an object-scoped root can name one
|
||||
/// of the key's delete transactions: a journal file of a rollback directory or
|
||||
/// a data-directory marker. Every other name is inert to discovery.
|
||||
fn may_be_delete_evidence(name: &str) -> bool {
|
||||
[PREPARED, STORAGE_FORMAT_FILE_BACKUP, DELETE_QUORUM_COMMITTED_FILE].contains(&name)
|
||||
|| [
|
||||
DELETE_DATA_DIR_MARKER_PREFIX,
|
||||
RESERVED_DELETE_DATA_DIR_MARKER_PREFIX,
|
||||
RECOVERY_DELETE_DATA_DIR_MARKER_PREFIX,
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| delete_transaction_marker_id(name, prefix).is_some())
|
||||
}
|
||||
|
||||
impl DiscoverySession {
|
||||
/// Consume one bounded step, returning ownership only when traversal remains.
|
||||
fn advance(self, limit: u32) -> std::io::Result<(Vec<DeleteTransactionId>, Option<Self>)> {
|
||||
self.advance_bounded(limit, super::super::DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES)
|
||||
self.advance_bounded(limit, DISCOVERY_STEP_ENTRIES, &|| false)
|
||||
.map(|(transactions, next, _)| (transactions, next))
|
||||
}
|
||||
|
||||
fn advance_bounded(mut self, limit: u32, max_entries: u32) -> std::io::Result<(Vec<DeleteTransactionId>, Option<Self>, u32)> {
|
||||
/// Walk until `limit` transactions, `max_entries` charged entries, the end
|
||||
/// of the scope, or `should_stop` (checked before every entry); any early
|
||||
/// stop returns the remaining traversal as continuation state.
|
||||
fn advance_bounded(
|
||||
mut self,
|
||||
limit: u32,
|
||||
max_entries: u32,
|
||||
should_stop: &dyn Fn() -> bool,
|
||||
) -> std::io::Result<(Vec<DeleteTransactionId>, Option<Self>, u32)> {
|
||||
if self.frames.is_empty() {
|
||||
match std::fs::read_dir(&self.root) {
|
||||
Ok(entries) => self.frames.push((String::new(), entries)),
|
||||
@@ -66,10 +100,11 @@ impl DiscoverySession {
|
||||
let mut transactions = BTreeSet::new();
|
||||
let mut seen_candidates = HashSet::new();
|
||||
let mut visited = 0u32;
|
||||
let mut plain_children = 0u32;
|
||||
let mut skipped_by_name = 0u32;
|
||||
while !self.frames.is_empty()
|
||||
&& transactions.len() < usize::try_from(limit).map_err(|_| std::io::Error::from(ErrorKind::InvalidInput))?
|
||||
&& visited < max_entries
|
||||
&& !should_stop()
|
||||
{
|
||||
let (parent, entries) = self
|
||||
.frames
|
||||
@@ -90,13 +125,22 @@ impl DiscoverySession {
|
||||
return Err(std::io::Error::from(ErrorKind::InvalidData));
|
||||
}
|
||||
// An object-scoped scan only ever descends into transaction (UUID)
|
||||
// directories at the root. Plain child keys of a prefix directory are
|
||||
// skipped by name without a stat; they are charged at one entry per
|
||||
// readdir batch so the work stays bounded while deleting a missing key
|
||||
// that names a prefix with thousands of children can still complete.
|
||||
if self.object.is_some() && parent.is_empty() && !canonical_uuid(&name).is_some_and(|id| !id.is_nil()) {
|
||||
plain_children += 1;
|
||||
if plain_children.is_multiple_of(PLAIN_CHILDREN_PER_BUDGET_ENTRY) {
|
||||
// directories at the root, and inside them only journal files and
|
||||
// data-directory markers can be evidence. Every other name (plain
|
||||
// child keys of a prefix directory, and the `xl.meta`, data
|
||||
// directories or children of a UUID-named child key) is skipped by
|
||||
// name without a stat and charged at one entry per readdir batch, so
|
||||
// the work stays bounded while deleting a missing key that names a
|
||||
// folder prefix with many children, UUID-named or not, can complete.
|
||||
if self.object.is_some()
|
||||
&& if parent.is_empty() {
|
||||
!canonical_uuid(&name).is_some_and(|id| !id.is_nil())
|
||||
} else {
|
||||
!may_be_delete_evidence(&name)
|
||||
}
|
||||
{
|
||||
skipped_by_name += 1;
|
||||
if skipped_by_name.is_multiple_of(PLAIN_CHILDREN_PER_BUDGET_ENTRY) {
|
||||
visited += 1;
|
||||
}
|
||||
continue;
|
||||
@@ -1128,6 +1172,8 @@ struct PreparedDeleteReceiptEntry {
|
||||
journal_raw: Vec<u8>,
|
||||
journal: DeleteRecoveryJournal,
|
||||
sole_transaction: bool,
|
||||
had_old_data_dir: bool,
|
||||
rollback_dir_fd: Option<Arc<std::fs::File>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -1137,6 +1183,8 @@ struct PublishedDeleteReceiptEntry {
|
||||
file_path: PathBuf,
|
||||
staging_root: PathBuf,
|
||||
journal_raw: Vec<u8>,
|
||||
had_old_data_dir: bool,
|
||||
rollback_dir_fd: Option<Arc<std::fs::File>>,
|
||||
}
|
||||
|
||||
const PREPARED_DELETE_RECEIPTS_PER_SHARD: usize = 64;
|
||||
@@ -1387,8 +1435,16 @@ impl LocalDisk {
|
||||
self.validate_delete_discovery_owner(&session)?;
|
||||
// The blocking owner drops every directory handle even if the RPC waiter disappears.
|
||||
// A partial inventory is never evidence of absence and never enters DISCOVERY_SESSIONS.
|
||||
os::run_blocking_namespace_operation(lease, move || {
|
||||
let (transactions, next, visited) = session.advance_bounded(256, max_entries)?;
|
||||
// A large folder prefix may take a while to walk, so the probe rides the
|
||||
// durable-mutation lane like bounded inspection, never the one-worker
|
||||
// foreground lane that every other mutation on the drive queues behind.
|
||||
// It also stops, incomplete, when its waiter goes away or well before the
|
||||
// drive timeout, so a slow walk answers SlowDown instead of marking the
|
||||
// drive failed or holding a lane worker after the request is gone.
|
||||
let deadline = Instant::now() + bounded_discovery_time_limit();
|
||||
os::run_cancellable_blocking_namespace_operation_with_sync(lease, true, move |is_cancelled| {
|
||||
let should_stop = || is_cancelled() || Instant::now() >= deadline;
|
||||
let (transactions, next, visited) = session.advance_bounded(256, max_entries, &should_stop)?;
|
||||
Ok(super::super::BoundedDeleteRecoveryDiscovery {
|
||||
transactions: if next.is_none() { transactions } else { Vec::new() },
|
||||
visited,
|
||||
@@ -2495,8 +2551,9 @@ impl LocalDisk {
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Ok(None)),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
type PreparedDeleteTuple = (Option<Uuid>, Option<Vec<u8>>, Vec<u8>, DeleteRecoveryJournal, bool, bool, bool);
|
||||
let (old_dir, target, journal_raw, journal, sole_transaction, destination_missing, old_data_dir_present) =
|
||||
type PreparedDeleteTuple =
|
||||
(Option<Uuid>, Option<Vec<u8>>, Vec<u8>, DeleteRecoveryJournal, bool, bool, Option<bool>);
|
||||
let (old_dir, target, journal_raw, journal, sole_transaction, destination_missing, old_data_dir_state) =
|
||||
match (|| -> Result<Option<PreparedDeleteTuple>> {
|
||||
let mut meta = FileMeta::load(&buf)?;
|
||||
let mut deleted_headers: Vec<_> = meta
|
||||
@@ -2513,13 +2570,12 @@ impl LocalDisk {
|
||||
if old_dir.is_some_and(|dir| dir == transaction || dir.is_nil()) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let mut old_path_to_verify = None;
|
||||
if let Some(uuid) = old_dir {
|
||||
meta.data.remove(vec![fi_clone.version_id.unwrap_or_default(), uuid])?;
|
||||
let old_path = path_join(&[file_path.as_path(), Path::new(uuid.to_string().as_str())]);
|
||||
check_path_length(old_path.to_string_lossy().as_ref())?;
|
||||
if old_path.join(STORAGE_FORMAT_FILE).exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
old_path_to_verify = Some(old_path);
|
||||
}
|
||||
let surviving: HashSet<_> = meta
|
||||
.versions
|
||||
@@ -2601,6 +2657,12 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(old_path) = old_path_to_verify
|
||||
&& (!scanned_file_path || old_data_dir_exists)
|
||||
&& old_path.join(STORAGE_FORMAT_FILE).exists()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
sole_transaction &= predecessors.is_empty();
|
||||
let mut sparse = fi_clone;
|
||||
sparse.data = None;
|
||||
@@ -2626,15 +2688,21 @@ impl LocalDisk {
|
||||
journal,
|
||||
sole_transaction,
|
||||
scanned_file_path && !destination_exists,
|
||||
scanned_file_path && old_data_dir_exists,
|
||||
scanned_file_path.then_some(old_data_dir_exists),
|
||||
)))
|
||||
})() {
|
||||
Ok(Some(tuple)) => tuple,
|
||||
Ok(None) => return Ok(Ok(None)),
|
||||
Err(err) => return Ok(Err(err)),
|
||||
};
|
||||
std::fs::create_dir_all(&staging_root)?;
|
||||
let staged = tempfile::Builder::new().prefix("delete-prep-").tempdir_in(&staging_root)?;
|
||||
let staged = match tempfile::Builder::new().prefix("delete-prep-").tempdir_in(&staging_root) {
|
||||
Ok(dir) => dir,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||
std::fs::create_dir_all(&staging_root)?;
|
||||
tempfile::Builder::new().prefix("delete-prep-").tempdir_in(&staging_root)?
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let staged_dir = staged.path().to_path_buf();
|
||||
let backup_path = staged_dir.join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
if std::fs::hard_link(&xl_path, &backup_path).is_err() {
|
||||
@@ -2658,8 +2726,12 @@ impl LocalDisk {
|
||||
for file in written_files {
|
||||
file.sync_data()?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
let rollback_dir_fd = sync.then(|| std::fs::File::open(&staged_dir).ok().map(Arc::new)).flatten();
|
||||
#[cfg(not(unix))]
|
||||
let rollback_dir_fd: Option<Arc<std::fs::File>> = None;
|
||||
if sync {
|
||||
os::fsync_dir_std(&staged_dir)?;
|
||||
os::fsync_directory_handle_std(&staged_dir, rollback_dir_fd.as_deref())?;
|
||||
}
|
||||
if !destination_missing {
|
||||
match std::fs::symlink_metadata(&destination) {
|
||||
@@ -2682,8 +2754,12 @@ impl LocalDisk {
|
||||
}
|
||||
let old_data_path = old_dir
|
||||
.map(|uuid| file_path.join(uuid.to_string()))
|
||||
.filter(|p| old_data_dir_present || p.is_dir());
|
||||
if sync && let Err(err) = os::fsync_dir_std(&file_path) {
|
||||
.filter(|p| old_data_dir_state.unwrap_or_else(|| p.is_dir()));
|
||||
#[cfg(unix)]
|
||||
let file_path_dir = sync.then(|| std::fs::File::open(&file_path).ok()).flatten();
|
||||
#[cfg(not(unix))]
|
||||
let file_path_dir: Option<std::fs::File> = None;
|
||||
if sync && let Err(err) = os::fsync_directory_handle_std(&file_path, file_path_dir.as_ref()) {
|
||||
let _ = std::fs::remove_dir_all(&destination);
|
||||
return Err(err);
|
||||
}
|
||||
@@ -2692,11 +2768,15 @@ impl LocalDisk {
|
||||
let mut reserved_data_path = None;
|
||||
if let Some(old_path) = old_data_path {
|
||||
let reserved_marker = old_path.join(format!("{RESERVED_DELETE_DATA_DIR_MARKER_PREFIX}{transaction}"));
|
||||
#[cfg(unix)]
|
||||
let old_dir_file = sync.then(|| std::fs::File::open(&old_path).ok()).flatten();
|
||||
#[cfg(not(unix))]
|
||||
let old_dir_file: Option<std::fs::File> = None;
|
||||
let reserve_res = (|| -> std::io::Result<()> {
|
||||
let marker = std::fs::File::create(&reserved_marker)?;
|
||||
if sync {
|
||||
marker.sync_all()?;
|
||||
os::fsync_dir_std(&old_path)?;
|
||||
os::fsync_directory_handle_std(&old_path, old_dir_file.as_ref())?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
@@ -2705,7 +2785,7 @@ impl LocalDisk {
|
||||
let _ = std::fs::remove_dir_all(&destination);
|
||||
return Err(err);
|
||||
}
|
||||
reserved_data_path = Some((old_path, reserved_marker));
|
||||
reserved_data_path = Some((old_path, reserved_marker, old_dir_file));
|
||||
}
|
||||
if let Some(ref target_bytes) = target {
|
||||
// A provisional head must stay outside the published recovery transaction.
|
||||
@@ -2737,7 +2817,7 @@ impl LocalDisk {
|
||||
})()
|
||||
};
|
||||
if let Err(err) = write_res {
|
||||
if let Some((_, reserved_marker)) = &reserved_data_path {
|
||||
if let Some((_, reserved_marker, _)) = &reserved_data_path {
|
||||
let _ = std::fs::remove_file(reserved_marker);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&destination);
|
||||
@@ -2746,19 +2826,20 @@ impl LocalDisk {
|
||||
} else if let Err(err) = std::fs::remove_file(&xl_path)
|
||||
&& err.kind() != ErrorKind::NotFound
|
||||
{
|
||||
if let Some((_, reserved_marker)) = &reserved_data_path {
|
||||
if let Some((_, reserved_marker, _)) = &reserved_data_path {
|
||||
let _ = std::fs::remove_file(reserved_marker);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&destination);
|
||||
return Err(err);
|
||||
}
|
||||
if sync {
|
||||
os::fsync_dir_std(&file_path)?;
|
||||
os::fsync_directory_handle_std(&file_path, file_path_dir.as_ref())?;
|
||||
}
|
||||
if let Some((old_path, reserved_marker)) = reserved_data_path {
|
||||
let had_old_data_dir = reserved_data_path.is_some();
|
||||
if let Some((old_path, reserved_marker, old_dir_file)) = reserved_data_path {
|
||||
let recovery_marker = old_path.join(format!("{RECOVERY_DELETE_DATA_DIR_MARKER_PREFIX}{transaction}"));
|
||||
match std::fs::rename(&reserved_marker, &recovery_marker) {
|
||||
Ok(()) if sync => os::fsync_dir_std(&old_path)?,
|
||||
Ok(()) if sync => os::fsync_directory_handle_std(&old_path, old_dir_file.as_ref())?,
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err),
|
||||
@@ -2774,9 +2855,11 @@ impl LocalDisk {
|
||||
journal_raw,
|
||||
journal,
|
||||
sole_transaction,
|
||||
had_old_data_dir,
|
||||
rollback_dir_fd,
|
||||
},
|
||||
);
|
||||
Ok(Ok(Some(old_dir)))
|
||||
Ok(Ok(Some(old_dir.filter(|_| had_old_data_dir))))
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -2829,7 +2912,13 @@ impl LocalDisk {
|
||||
Some(entry.bucket_path),
|
||||
entry.file_path,
|
||||
entry.staging_root,
|
||||
Some((entry.journal_raw, entry.journal, entry.sole_transaction)),
|
||||
Some((
|
||||
entry.journal_raw,
|
||||
entry.journal,
|
||||
entry.sole_transaction,
|
||||
entry.had_old_data_dir,
|
||||
entry.rollback_dir_fd,
|
||||
)),
|
||||
),
|
||||
None => {
|
||||
self.reject_multipart_delete_recovery_mutation(volume, object)?;
|
||||
@@ -2860,7 +2949,7 @@ impl LocalDisk {
|
||||
lease,
|
||||
sync,
|
||||
move || -> std::io::Result<Result<Option<(DeleteRecoveryJournal, bool, bool)>>> {
|
||||
if let Some((journal_raw, journal, sole_transaction)) = cached_receipt
|
||||
if let Some((journal_raw, journal, sole_transaction, had_old_data_dir, rollback_dir_fd)) = cached_receipt
|
||||
&& rollback.join(PREPARED).is_file()
|
||||
{
|
||||
let tmp_receipt = staging_root.join(format!("receipt-{transaction}.tmp"));
|
||||
@@ -2874,7 +2963,7 @@ impl LocalDisk {
|
||||
os::record_native_publication(&receipt_path);
|
||||
std::fs::rename(&tmp_receipt, &receipt_path)?;
|
||||
if sync {
|
||||
os::fsync_dir_std(&rollback)?;
|
||||
os::fsync_directory_handle_std(&rollback, rollback_dir_fd.as_deref())?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
@@ -2891,6 +2980,8 @@ impl LocalDisk {
|
||||
file_path: root,
|
||||
staging_root,
|
||||
journal_raw,
|
||||
had_old_data_dir,
|
||||
rollback_dir_fd,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -3027,29 +3118,34 @@ impl LocalDisk {
|
||||
None
|
||||
};
|
||||
let cached_published = rollback_open_path.as_deref().and_then(take_published_delete_receipt);
|
||||
let (object_path, bucket_path, staging_root, root, cached_journal_raw) = match cached_published {
|
||||
Some(entry) => (
|
||||
entry.object_path,
|
||||
entry.bucket_path,
|
||||
entry.staging_root,
|
||||
entry.file_path,
|
||||
Some(entry.journal_raw),
|
||||
),
|
||||
None => {
|
||||
self.reject_multipart_delete_recovery_mutation(volume, object)?;
|
||||
if !safe_path(object, false) {
|
||||
return Err(DiskError::InvalidPath);
|
||||
let (object_path, bucket_path, staging_root, root, cached_journal_raw, cached_had_old_data_dir, mut rollback_dir_fd) =
|
||||
match cached_published {
|
||||
Some(entry) => (
|
||||
entry.object_path,
|
||||
entry.bucket_path,
|
||||
entry.staging_root,
|
||||
entry.file_path,
|
||||
Some(entry.journal_raw),
|
||||
Some(entry.had_old_data_dir),
|
||||
entry.rollback_dir_fd,
|
||||
),
|
||||
None => {
|
||||
self.reject_multipart_delete_recovery_mutation(volume, object)?;
|
||||
if !safe_path(object, false) {
|
||||
return Err(DiskError::InvalidPath);
|
||||
}
|
||||
let obj_path = self.get_object_path(volume, object)?;
|
||||
(
|
||||
obj_path,
|
||||
self.io_get_bucket_path(volume)?,
|
||||
self.io_get_bucket_path(RUSTFS_META_TMP_BUCKET)?,
|
||||
self.io_get_object_path(volume, object)?,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
let obj_path = self.get_object_path(volume, object)?;
|
||||
(
|
||||
obj_path,
|
||||
self.io_get_bucket_path(volume)?,
|
||||
self.io_get_bucket_path(RUSTFS_META_TMP_BUCKET)?,
|
||||
self.io_get_object_path(volume, object)?,
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
};
|
||||
let metadata_owner = os::acquire_metadata_mutation_lease(&object_path, namespace_owner).await;
|
||||
let volume_guard = os::disk_volume_mutation_lock(&self.root, volume).read_owned().await;
|
||||
let owner: Option<Arc<dyn Send + Sync>> = Some(Arc::new((metadata_owner, volume_guard)));
|
||||
@@ -3123,56 +3219,61 @@ impl LocalDisk {
|
||||
let recovery_committed = format!("{RECOVERY_DELETE_DATA_DIR_MARKER_PREFIX}{transaction}");
|
||||
let reserved = format!("{RESERVED_DELETE_DATA_DIR_MARKER_PREFIX}{transaction}");
|
||||
let mut had_physical_data_dirs = false;
|
||||
for data_dir in &data_dirs {
|
||||
for base in [&root, &rollback] {
|
||||
let dir_path = base.join(data_dir.to_string());
|
||||
match std::fs::read_dir(&dir_path) {
|
||||
Ok(entries) => {
|
||||
had_physical_data_dirs = true;
|
||||
let mut removable = Vec::new();
|
||||
let mut preserve_dir = false;
|
||||
for entry in recovery_entries(entries, work_budget.as_deref()) {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else {
|
||||
preserve_dir = true;
|
||||
continue;
|
||||
};
|
||||
if entry.file_type()?.is_file()
|
||||
&& (delete_recovery_part_file(name)
|
||||
|| name == committed
|
||||
|| name == recovery_committed
|
||||
|| name == reserved
|
||||
|| is_delete_transaction_marker(name, RESERVED_DELETE_DATA_DIR_MARKER_PREFIX))
|
||||
{
|
||||
removable.push(entry.path());
|
||||
} else {
|
||||
preserve_dir = true;
|
||||
}
|
||||
}
|
||||
for path in removable {
|
||||
ignore_not_found(std::fs::remove_file(path))?;
|
||||
}
|
||||
if sync {
|
||||
os::fsync_dir_std(&dir_path)?;
|
||||
}
|
||||
if !preserve_dir {
|
||||
match std::fs::remove_dir(&dir_path) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound || is_dir_not_empty_error(&err) => {}
|
||||
Err(err) => return Err(err),
|
||||
let fast_cleanup = cached_journal_raw.is_some();
|
||||
if cached_had_old_data_dir != Some(false) {
|
||||
let bases: &[&Path] = if fast_cleanup { &[&root] } else { &[&root, &rollback] };
|
||||
for data_dir in &data_dirs {
|
||||
for base in bases {
|
||||
let dir_path = base.join(data_dir.to_string());
|
||||
match std::fs::read_dir(&dir_path) {
|
||||
Ok(entries) => {
|
||||
had_physical_data_dirs = true;
|
||||
let mut removable = Vec::new();
|
||||
let mut preserve_dir = false;
|
||||
for entry in recovery_entries(entries, work_budget.as_deref()) {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else {
|
||||
preserve_dir = true;
|
||||
continue;
|
||||
};
|
||||
if entry.file_type()?.is_file()
|
||||
&& (delete_recovery_part_file(name)
|
||||
|| name == committed
|
||||
|| name == recovery_committed
|
||||
|| name == reserved
|
||||
|| is_delete_transaction_marker(name, RESERVED_DELETE_DATA_DIR_MARKER_PREFIX))
|
||||
{
|
||||
removable.push(entry.path());
|
||||
} else {
|
||||
preserve_dir = true;
|
||||
}
|
||||
}
|
||||
for path in removable {
|
||||
ignore_not_found(std::fs::remove_file(path))?;
|
||||
}
|
||||
let mut dir_removed = false;
|
||||
if !preserve_dir {
|
||||
match std::fs::remove_dir(&dir_path) {
|
||||
Ok(()) => dir_removed = true,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => dir_removed = true,
|
||||
Err(err) if is_dir_not_empty_error(&err) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
if !dir_removed && sync {
|
||||
os::fsync_dir_std(&dir_path)?;
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
if had_physical_data_dirs && sync && root.is_dir() {
|
||||
os::fsync_dir_std(&root)?;
|
||||
}
|
||||
let fast_cleanup = cached_journal_raw.is_some();
|
||||
if fast_cleanup {
|
||||
let backup_or_marker = if has_original {
|
||||
Some(STORAGE_FORMAT_FILE_BACKUP)
|
||||
@@ -3221,12 +3322,15 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
}
|
||||
if sync && let Err(err) = os::fsync_dir_std(&rollback) {
|
||||
let rollback_sync = rollback_dir_fd.take();
|
||||
if sync && let Err(err) = os::fsync_directory_handle_std(&rollback, rollback_sync.as_deref()) {
|
||||
drop(rollback_sync);
|
||||
if has_target || had_physical_data_dirs {
|
||||
let _ = persist_receipt(true);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
drop(rollback_sync);
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, &receipt_path);
|
||||
if let Err(err) = ignore_not_found(std::fs::remove_file(&receipt_path)) {
|
||||
@@ -6509,6 +6613,93 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_bounded_discovery_charges_uuid_named_children_once_and_keeps_evidence() {
|
||||
let (_temp, disk, _, _) = fixture().await;
|
||||
let path = disk.delete_discovery_path("recovery", Some("prefix")).expect("prefix path");
|
||||
std::fs::create_dir_all(&path).expect("physical prefix");
|
||||
// UUID-named child keys of the folder: each holds its own `xl.meta` and a
|
||||
// data directory, neither of which can be evidence for the folder's key.
|
||||
const CHILDREN: u32 = 2000;
|
||||
for _ in 0..CHILDREN {
|
||||
let child = path.join(Uuid::new_v4().to_string());
|
||||
std::fs::create_dir_all(child.join(Uuid::new_v4().to_string())).expect("child key data dir");
|
||||
std::fs::write(child.join(STORAGE_FORMAT_FILE), b"meta").expect("child key metadata");
|
||||
}
|
||||
// A UUID-named sub-prefix with children of its own.
|
||||
const SUB_PREFIX_CHILDREN: u32 = 640;
|
||||
let sub_prefix = path.join(Uuid::new_v4().to_string());
|
||||
for index in 0..SUB_PREFIX_CHILDREN {
|
||||
std::fs::create_dir_all(sub_prefix.join(format!("child-{index}"))).expect("sub-prefix child");
|
||||
}
|
||||
// Evidence of two interrupted transactions of the key itself: a journaled
|
||||
// rollback directory, and a data-directory marker whose rollback is gone.
|
||||
let journaled = Uuid::new_v4();
|
||||
std::fs::create_dir_all(path.join(journaled.to_string())).expect("rollback directory");
|
||||
std::fs::write(path.join(journaled.to_string()).join(PREPARED), b"{}").expect("journal");
|
||||
let marker_only = Uuid::new_v4();
|
||||
let data_dir = path.join(Uuid::new_v4().to_string());
|
||||
std::fs::create_dir_all(&data_dir).expect("residual data directory");
|
||||
std::fs::write(data_dir.join(format!("{RESERVED_DELETE_DATA_DIR_MARKER_PREFIX}{marker_only}")), [])
|
||||
.expect("reservation marker");
|
||||
|
||||
let page = disk
|
||||
.discover_delete_transactions_bounded(
|
||||
"recovery",
|
||||
"prefix",
|
||||
super::super::super::DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES,
|
||||
)
|
||||
.await
|
||||
.expect("bounded probe");
|
||||
assert!(page.complete, "a folder of UUID-named keys is a complete inventory");
|
||||
// One entry per descent (children, sub-prefix, rollback and data
|
||||
// directory) and per evidence file; inert names at 1/64.
|
||||
let inert = 2 * CHILDREN + SUB_PREFIX_CHILDREN;
|
||||
assert_eq!(page.visited, CHILDREN + 3 + 2 + inert / PLAIN_CHILDREN_PER_BUDGET_ENTRY);
|
||||
let found = page.transactions.iter().map(|id| id.transaction).collect::<BTreeSet<_>>();
|
||||
assert!(found.contains(&journaled), "a journaled rollback directory is evidence");
|
||||
assert!(found.contains(&marker_only), "a marker-only residue is evidence");
|
||||
assert_eq!(found.len(), 2, "no child key is mistaken for a transaction");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_bounded_discovery_stops_on_request_and_skips_only_inert_irregular_entries() {
|
||||
let (_temp, disk, _, _) = fixture().await;
|
||||
let path = disk.delete_discovery_path("recovery", Some("prefix")).expect("prefix path");
|
||||
let child = path.join(Uuid::new_v4().to_string());
|
||||
std::fs::create_dir_all(&child).expect("UUID-named child key");
|
||||
// A stop request (cancellation or the wall-clock limit) ends the walk
|
||||
// before any entry, leaving continuation state: never evidence of absence.
|
||||
let session = disk
|
||||
.new_delete_discovery_session("recovery", Some("prefix"), &path)
|
||||
.expect("private session");
|
||||
let (transactions, next, visited) = session
|
||||
.advance_bounded(256, super::super::super::DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES, &|| true)
|
||||
.expect("stopped walk");
|
||||
assert!(transactions.is_empty());
|
||||
assert_eq!(visited, 0);
|
||||
assert!(next.is_some(), "a stopped walk is incomplete");
|
||||
drop(next);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// An irregular entry whose name cannot be evidence is skipped unread.
|
||||
std::os::unix::fs::symlink(&path, child.join("inert-link")).expect("inert symlink");
|
||||
let page = disk
|
||||
.discover_delete_transactions_bounded("recovery", "prefix", 4096)
|
||||
.await
|
||||
.expect("inert irregular entries are not evidence");
|
||||
assert!(page.complete && page.transactions.is_empty());
|
||||
// One with an evidence name still fails closed.
|
||||
std::os::unix::fs::symlink(&path, child.join(PREPARED)).expect("evidence-named symlink");
|
||||
assert!(
|
||||
disk.discover_delete_transactions_bounded("recovery", "prefix", 4096)
|
||||
.await
|
||||
.is_err(),
|
||||
"an evidence-named symlink is corruption, not a skipped name"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_bounded_discovery_refuses_partial_inventory_without_public_cursor() {
|
||||
let (_temp, disk, _, _) = fixture().await;
|
||||
@@ -6611,8 +6802,11 @@ mod tests {
|
||||
let filler_id = Uuid::new_v4().to_string();
|
||||
let filler = path.join(&filler_id);
|
||||
fs::create_dir_all(&filler).await.expect("non-evidence directory");
|
||||
for index in 0..5000 {
|
||||
fs::write(filler.join(format!("part-{index:04}")), [])
|
||||
// Inert names are skipped at 1/64, so fill the step with entries that
|
||||
// pass the evidence-name filter but are directories: each costs a full
|
||||
// budget entry and names no transaction.
|
||||
for _ in 0..5000 {
|
||||
fs::create_dir(filler.join(format!("{DELETE_DATA_DIR_MARKER_PREFIX}{}", Uuid::new_v4())))
|
||||
.await
|
||||
.expect("non-evidence entry");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
//! Bucket-to-staging-root candidates: one marker per staging root, recorded
|
||||
//! before the root is published and pruned once the root is gone. Source
|
||||
//! metadata stays authoritative.
|
||||
//!
|
||||
//! A marker without a staging root is either a leftover or an upload whose
|
||||
//! root is still being published. The publisher proves the second case by
|
||||
//! holding a [`PublicationPin`] from before it records the marker until its
|
||||
//! filesystem work has returned; no elapsed time is taken as that proof.
|
||||
|
||||
use super::{LocalDisk, read_metadata_only_std};
|
||||
use crate::disk::{
|
||||
@@ -29,9 +34,6 @@ use uuid::Uuid;
|
||||
|
||||
const INDEX: &str = ".rustfs.sys/multipart-index-v1";
|
||||
const REBUILD_DIRENTS_PER_JOB: usize = 64;
|
||||
/// A marker is recorded before its staging root is published. A marker younger
|
||||
/// than this without a root is an upload in flight, never a leftover to prune.
|
||||
const MULTIPART_INDEX_PRUNE_GRACE: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
type DirectoryEntries = Box<dyn Iterator<Item = io::Result<String>> + Send>;
|
||||
type PhysicalRoot = (u64, [u8; 16]);
|
||||
static OWNERS: LazyLock<parking_lot::Mutex<HashMap<PhysicalRoot, Weak<IndexOwner>>>> =
|
||||
@@ -56,9 +58,15 @@ pub(super) struct IndexOwner {
|
||||
// drop so a cancelled scan can be resumed by the next caller.
|
||||
driving: AtomicBool,
|
||||
disabled: AtomicBool,
|
||||
// Markers whose staging root some publisher is creating right now, with
|
||||
// the number of publishers. Boot-local like the index itself: every writer
|
||||
// of this physical drive in this process shares this owner.
|
||||
publishing: parking_lot::Mutex<HashMap<PathBuf, usize>>,
|
||||
#[cfg(test)]
|
||||
rebuild_head_pause: parking_lot::Mutex<Option<RebuildHeadPause>>,
|
||||
#[cfg(test)]
|
||||
publication_pause: parking_lot::Mutex<Option<RebuildHeadPause>>,
|
||||
#[cfg(test)]
|
||||
observed_rebuild_queue: parking_lot::Mutex<Option<Arc<os::DiskBlockingQueue>>>,
|
||||
#[cfg(test)]
|
||||
rebuild_single_job: AtomicBool,
|
||||
@@ -120,23 +128,6 @@ impl IndexDirectory {
|
||||
std::fs::File::open(self.path.join(name))
|
||||
}
|
||||
}
|
||||
fn modified(&self, name: &str) -> io::Result<std::time::SystemTime> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
self.open_file(name)?.metadata()?.modified()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::metadata(self.path.join(name))?.modified()
|
||||
}
|
||||
}
|
||||
/// Whether the marker is still inside the publication grace period. A
|
||||
/// marker dated in the future (clock stepped back) is never a leftover.
|
||||
fn marker_is_young(&self, name: &str) -> bool {
|
||||
self.modified(name)
|
||||
.ok()
|
||||
.is_some_and(|modified| modified.elapsed().map_or(true, |age| age < MULTIPART_INDEX_PRUNE_GRACE))
|
||||
}
|
||||
fn remove_file(&self, name: &str) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -280,9 +271,12 @@ impl IndexOwner {
|
||||
initialized: OnceLock::new(),
|
||||
driving: AtomicBool::new(false),
|
||||
disabled: AtomicBool::new(false),
|
||||
publishing: parking_lot::Mutex::new(HashMap::new()),
|
||||
#[cfg(test)]
|
||||
rebuild_head_pause: parking_lot::Mutex::new(None),
|
||||
#[cfg(test)]
|
||||
publication_pause: parking_lot::Mutex::new(None),
|
||||
#[cfg(test)]
|
||||
observed_rebuild_queue: parking_lot::Mutex::new(None),
|
||||
#[cfg(test)]
|
||||
rebuild_single_job: AtomicBool::new(false),
|
||||
@@ -394,8 +388,8 @@ impl IndexOwner {
|
||||
// The index is boot-local: every start rescans the staging area
|
||||
// before serving it, so a marker lost to a crash is recreated before
|
||||
// it can be consulted. Creation is enough; no fsync of the marker or
|
||||
// its ancestors. An existing marker is touched so pruning sees the
|
||||
// new upload's age, not the previous one's.
|
||||
// its ancestors. An existing marker needs nothing: the caller's
|
||||
// publication pin, not the marker's age, keeps it from being pruned.
|
||||
match openat(
|
||||
&parent,
|
||||
source_root,
|
||||
@@ -407,16 +401,7 @@ impl IndexOwner {
|
||||
return Err(io::Error::other("index marker is not a file"));
|
||||
}
|
||||
}
|
||||
Err(rustix::io::Errno::EXIST) => {
|
||||
// Best effort: a filesystem that refuses to set times keeps
|
||||
// the previous upload's age, which only shortens the prune
|
||||
// grace for this key; index completeness does not depend on it.
|
||||
if let Ok(marker) =
|
||||
openat(&parent, source_root, OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())
|
||||
{
|
||||
let _ = std::fs::File::from(marker).set_modified(std::time::SystemTime::now());
|
||||
}
|
||||
}
|
||||
Err(rustix::io::Errno::EXIST) => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
@@ -432,6 +417,22 @@ impl IndexOwner {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Declare that the caller is about to publish the staging root of
|
||||
/// `marker`. Must precede the marker lease the caller records under, so a
|
||||
/// prune that found no pin has released that lease before the marker is
|
||||
/// (re)created.
|
||||
fn pin_publication(self: &Arc<Self>, marker: PathBuf) -> PublicationPin {
|
||||
*self.publishing.lock().entry(marker.clone()).or_default() += 1;
|
||||
PublicationPin {
|
||||
owner: Arc::clone(self),
|
||||
marker,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_publishing(&self, marker: &Path) -> bool {
|
||||
self.publishing.lock().contains_key(marker)
|
||||
}
|
||||
|
||||
fn ensure_current_root(&self) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
let current = {
|
||||
@@ -513,9 +514,12 @@ impl IndexOwner {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn record_info(self: &Arc<Self>, path: &str, info: &FileInfo) -> Result<()> {
|
||||
/// Record the marker of `path`'s staging root. The returned pin must stay
|
||||
/// alive until the filesystem work that publishes the root has returned,
|
||||
/// including work that outlives a cancelled caller.
|
||||
pub(super) async fn record_info(self: &Arc<Self>, path: &str, info: &FileInfo) -> Result<Option<PublicationPin>> {
|
||||
if canonical_upload(path).is_none() {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
let owner = self.clone();
|
||||
let path = path.to_owned();
|
||||
@@ -531,6 +535,7 @@ impl IndexOwner {
|
||||
.join(path.split('/').next().unwrap_or_default())
|
||||
})
|
||||
.unwrap_or_else(|| owner.base.join(INDEX));
|
||||
let pin = self.pin_publication(marker.clone());
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&marker, None).await;
|
||||
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
|
||||
match owner.record(&path, &info) {
|
||||
@@ -544,7 +549,7 @@ impl IndexOwner {
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(Some(pin))
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_record_head(self: &Arc<Self>, path: &str, bytes: &[u8]) -> Option<PreparedHeadRecord> {
|
||||
@@ -578,20 +583,23 @@ impl IndexOwner {
|
||||
.join(upload.split('/').next().unwrap_or_default())
|
||||
})
|
||||
.unwrap_or_else(|| self.base.join(INDEX));
|
||||
let pin = self.pin_publication(marker.clone());
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&marker, None).await;
|
||||
Some(PreparedHeadRecord {
|
||||
owner: Arc::clone(self),
|
||||
upload: upload.to_owned(),
|
||||
info,
|
||||
pin,
|
||||
_lease: lease,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn record_head(self: &Arc<Self>, path: &str, bytes: &[u8]) -> Result<()> {
|
||||
/// Record the marker of the staging root that `path` belongs to. See
|
||||
/// [`Self::record_info`] for how long the returned pin must live.
|
||||
pub(super) async fn record_head(self: &Arc<Self>, path: &str, bytes: &[u8]) -> Result<Option<PublicationPin>> {
|
||||
let Some(prepared) = self.prepare_record_head(path, bytes).await else {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
};
|
||||
prepared.run().await
|
||||
prepared.run().await.map(Some)
|
||||
}
|
||||
|
||||
async fn page(self: &Arc<Self>, bucket: &str, marker: Option<&str>, disk_id: Uuid) -> Result<MultipartIndexPage> {
|
||||
@@ -642,17 +650,20 @@ impl IndexOwner {
|
||||
// to every key ever multipart-initiated on this drive.
|
||||
let mut live = Vec::with_capacity(roots.len());
|
||||
let mut stale = Vec::new();
|
||||
let markers = owner.base.join(INDEX).join(sha(&bucket));
|
||||
for root in roots {
|
||||
// The marker precedes its staging root: a pinned one is an
|
||||
// upload in flight and stays a candidate. The pin is read
|
||||
// before the root, so a publisher that finished in between has
|
||||
// already made its root visible.
|
||||
if owner.is_publishing(&markers.join(&root)) {
|
||||
live.push(root);
|
||||
continue;
|
||||
}
|
||||
match owner.directory(&Path::new(RUSTFS_META_MULTIPART_BUCKET).join(&root)) {
|
||||
Ok(_) => live.push(root),
|
||||
Err(error) if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) => {
|
||||
// The marker precedes its staging root; a young one is an
|
||||
// upload in flight and stays a candidate.
|
||||
if directory.as_ref().is_some_and(|index| index.marker_is_young(&root)) {
|
||||
live.push(root);
|
||||
} else {
|
||||
stale.push(root);
|
||||
}
|
||||
stale.push(root);
|
||||
}
|
||||
Err(error) => return Err(DiskError::from(error)),
|
||||
}
|
||||
@@ -677,9 +688,11 @@ impl IndexOwner {
|
||||
}
|
||||
|
||||
/// Remove one marker whose staging root was gone when a page examined it.
|
||||
/// The marker's own namespace lease serializes this with `record_info`; the
|
||||
/// staging root and the marker's age are re-checked under that lease, so an
|
||||
/// upload recorded in between keeps its marker.
|
||||
/// The marker's own namespace lease serializes this with recording. Under
|
||||
/// that lease the publication pin is read first and the staging root
|
||||
/// second: a publisher pinned before the read keeps the marker, one that
|
||||
/// already finished has made its root visible, and one that pins later
|
||||
/// records its marker only after this lease is released.
|
||||
async fn prune_marker(self: &Arc<Self>, bucket: &str, root: &str) -> Result<()> {
|
||||
let owner = self.clone();
|
||||
let bucket_directory = Path::new(INDEX).join(sha(bucket));
|
||||
@@ -690,6 +703,9 @@ impl IndexOwner {
|
||||
// not queue behind every other mutation on the one-worker foreground lane.
|
||||
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
|
||||
owner.ensure_current_root()?;
|
||||
if owner.is_publishing(&marker) {
|
||||
return Ok(());
|
||||
}
|
||||
match owner.directory(&Path::new(RUSTFS_META_MULTIPART_BUCKET).join(&root)) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) => {}
|
||||
@@ -700,9 +716,6 @@ impl IndexOwner {
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if directory.marker_is_young(&root) {
|
||||
return Ok(());
|
||||
}
|
||||
match directory.remove_file(&root) {
|
||||
Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
|
||||
_ => Ok(()),
|
||||
@@ -713,34 +726,69 @@ impl IndexOwner {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PreparedHeadRecord {
|
||||
/// Proof that the staging root of one marker is being published. While any pin
|
||||
/// for a marker is alive, pruning leaves that marker alone.
|
||||
#[must_use = "dropping the pin lets a concurrent listing prune the marker before its staging root exists"]
|
||||
pub(super) struct PublicationPin {
|
||||
owner: Arc<IndexOwner>,
|
||||
marker: PathBuf,
|
||||
}
|
||||
|
||||
impl PublicationPin {
|
||||
/// Test seam between recording the marker and publishing the staging root.
|
||||
#[cfg(test)]
|
||||
pub(super) fn pause_for_test(&self) {
|
||||
let pause = self.owner.publication_pause.lock().take();
|
||||
if let Some(pause) = pause {
|
||||
let _pause = os::enter_blocking_test_hook();
|
||||
let _ = pause.started.send(());
|
||||
let _ = pause.release.recv_timeout(std::time::Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PublicationPin {
|
||||
fn drop(&mut self) {
|
||||
let mut publishing = self.owner.publishing.lock();
|
||||
if let Some(count) = publishing.get_mut(&self.marker) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
publishing.remove(&self.marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PreparedHeadRecord {
|
||||
upload: String,
|
||||
info: FileInfo,
|
||||
pin: PublicationPin,
|
||||
_lease: Arc<os::NamespaceMutationLease>,
|
||||
}
|
||||
|
||||
impl PreparedHeadRecord {
|
||||
pub(super) fn run_std(self) -> io::Result<()> {
|
||||
/// Record the marker and release its lease. The caller publishes the
|
||||
/// staging root while it holds the returned pin.
|
||||
pub(super) fn run_std(self) -> io::Result<PublicationPin> {
|
||||
let Self {
|
||||
owner,
|
||||
upload,
|
||||
info,
|
||||
pin,
|
||||
_lease,
|
||||
} = self;
|
||||
match owner.record(&upload, &info) {
|
||||
match pin.owner.record(&upload, &info) {
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidData => {
|
||||
owner.disabled.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
pin.owner.disabled.store(true, Ordering::Release);
|
||||
Ok(pin)
|
||||
}
|
||||
result => result,
|
||||
Err(error) => Err(error),
|
||||
Ok(()) => Ok(pin),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(self) -> Result<()> {
|
||||
async fn run(self) -> Result<PublicationPin> {
|
||||
let lease = Arc::clone(&self._lease);
|
||||
os::run_blocking_namespace_operation_with_sync(lease, true, move || self.run_std()).await?;
|
||||
Ok(())
|
||||
Ok(os::run_blocking_namespace_operation_with_sync(lease, true, move || self.run_std()).await?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,47 +1006,274 @@ mod tests {
|
||||
let live = format!("{:064x}", 1);
|
||||
let finished = format!("{:064x}", 2);
|
||||
let in_flight = format!("{:064x}", 3);
|
||||
let clock_stepped = format!("{:064x}", 4);
|
||||
for name in [&live, &finished, &in_flight, &clock_stepped] {
|
||||
for name in [&live, &finished, &in_flight] {
|
||||
std::fs::write(directory.join(name), b"1").expect("marker");
|
||||
}
|
||||
// Completion and abort remove the staging root and leave the marker.
|
||||
std::fs::create_dir_all(root.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&live)).expect("live staging root");
|
||||
set_marker_modified(&directory.join(&finished), std::time::SystemTime::now() - MULTIPART_INDEX_PRUNE_GRACE * 2);
|
||||
// A marker dated after a backwards clock step is never a leftover.
|
||||
set_marker_modified(
|
||||
&directory.join(&clock_stepped),
|
||||
std::time::SystemTime::now() + MULTIPART_INDEX_PRUNE_GRACE * 2,
|
||||
);
|
||||
let page = disk.multipart_index.page("bucket", None, Uuid::new_v4()).await.expect("page");
|
||||
// The in-flight marker has no staging root yet but is young: a candidate, not a leftover.
|
||||
assert_eq!(page.roots, vec![live.clone(), in_flight.clone(), clock_stepped.clone()]);
|
||||
let owner = &disk.multipart_index;
|
||||
let publishing = owner.pin_publication(owner.base.join(INDEX).join(sha("bucket")).join(&in_flight));
|
||||
let second_publisher = owner.pin_publication(owner.base.join(INDEX).join(sha("bucket")).join(&in_flight));
|
||||
let page = owner.page("bucket", None, Uuid::new_v4()).await.expect("page");
|
||||
// The in-flight marker has no staging root yet but is pinned: a candidate, not a leftover.
|
||||
assert_eq!(page.roots, vec![live.clone(), in_flight.clone()]);
|
||||
assert!(page.next_marker.is_none());
|
||||
assert!(directory.join(&live).exists(), "a live root keeps its marker");
|
||||
assert!(directory.join(&in_flight).exists(), "a marker inside the grace period is kept");
|
||||
assert!(directory.join(&clock_stepped).exists(), "a future-dated marker is kept");
|
||||
assert!(!directory.join(&finished).exists(), "a finished root's marker is pruned");
|
||||
// The next page no longer pays for the pruned marker.
|
||||
let page = disk
|
||||
.multipart_index
|
||||
assert!(directory.join(&in_flight).exists(), "a pinned marker is kept");
|
||||
assert!(!directory.join(&finished).exists(), "a finished root's marker is pruned at once");
|
||||
// One of two publishers of the same root finishing does not unpin it.
|
||||
drop(publishing);
|
||||
let page = owner
|
||||
.page("bucket", None, Uuid::new_v4())
|
||||
.await
|
||||
.expect("page after pruning");
|
||||
assert_eq!(page.roots, vec![live, in_flight, clock_stepped]);
|
||||
.expect("page with one publisher left");
|
||||
assert_eq!(page.roots, vec![live.clone(), in_flight.clone()]);
|
||||
assert!(directory.join(&in_flight).exists(), "the remaining publisher keeps the marker");
|
||||
// A publication that ended without a staging root leaves a leftover.
|
||||
drop(second_publisher);
|
||||
let page = owner
|
||||
.page("bucket", None, Uuid::new_v4())
|
||||
.await
|
||||
.expect("page after the failed publication");
|
||||
assert_eq!(page.roots, vec![live]);
|
||||
assert!(!directory.join(&in_flight).exists(), "an unpinned marker without a root is pruned");
|
||||
assert!(owner.publishing.lock().is_empty(), "finished publishers leave no pin behind");
|
||||
}
|
||||
|
||||
/// Move a marker's modification time outside the publication grace period.
|
||||
fn age_marker(marker: &Path) {
|
||||
set_marker_modified(marker, std::time::SystemTime::now() - MULTIPART_INDEX_PRUNE_GRACE * 2);
|
||||
fn pause_next_publication(owner: &IndexOwner) -> (tokio::sync::oneshot::Receiver<()>, std::sync::mpsc::Sender<()>) {
|
||||
let (started, waiting) = tokio::sync::oneshot::channel();
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
*owner.publication_pause.lock() = Some(RebuildHeadPause {
|
||||
started,
|
||||
release: released,
|
||||
});
|
||||
(waiting, release)
|
||||
}
|
||||
|
||||
fn set_marker_modified(marker: &Path, modified: std::time::SystemTime) {
|
||||
/// The window the pin closes: the marker is recorded and its lease is
|
||||
/// released, the staging root is not published yet, and a listing examines
|
||||
/// the bucket. The marker is dated a day back so that no grace period could
|
||||
/// be what keeps it.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn multipart_index_listing_during_source_publication_keeps_the_new_upload_listed() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let disk = Arc::new(open(root.path()).await);
|
||||
let (path, info) = upload("bucket", "published-while-listed");
|
||||
let source_root = path.split('/').next().expect("source sha").to_owned();
|
||||
let marker = root.path().join(INDEX).join(sha("bucket")).join(&source_root);
|
||||
let source_head = root
|
||||
.path()
|
||||
.join(RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(&path)
|
||||
.join(STORAGE_FORMAT_FILE);
|
||||
let (paused, release) = pause_next_publication(&disk.multipart_index);
|
||||
let writer = tokio::spawn({
|
||||
let disk = Arc::clone(&disk);
|
||||
let path = path.clone();
|
||||
async move { disk.write_metadata("bucket", RUSTFS_META_MULTIPART_BUCKET, &path, info).await }
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), paused)
|
||||
.await
|
||||
.expect("the publication reaches the pause")
|
||||
.expect("pause sender");
|
||||
assert!(marker.exists(), "the marker precedes the staging root");
|
||||
assert!(!source_head.exists(), "the staging root is not published yet");
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(marker)
|
||||
.open(&marker)
|
||||
.expect("marker")
|
||||
.set_modified(modified)
|
||||
.expect("set marker time");
|
||||
.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(86_400))
|
||||
.expect("age the marker");
|
||||
|
||||
let identity = Uuid::new_v4();
|
||||
let page = disk
|
||||
.multipart_index
|
||||
.page("bucket", None, identity)
|
||||
.await
|
||||
.expect("page during publication");
|
||||
assert_eq!(page.roots, vec![source_root.clone()], "an upload in flight stays a candidate");
|
||||
assert!(marker.exists(), "a listing must not prune the marker of an upload in flight");
|
||||
|
||||
release.send(()).expect("resume the publication");
|
||||
writer.await.expect("writer task").expect("the upload metadata is published");
|
||||
assert!(source_head.exists());
|
||||
let page = disk
|
||||
.multipart_index
|
||||
.page("bucket", None, identity)
|
||||
.await
|
||||
.expect("page after publication");
|
||||
assert_eq!(page.roots, vec![source_root], "the published upload is listed");
|
||||
assert!(marker.exists());
|
||||
assert!(disk.multipart_index.publishing.lock().is_empty());
|
||||
}
|
||||
|
||||
/// A timed-out caller drops its future while the filesystem work goes on.
|
||||
/// The pin belongs to that work, so the marker survives until the staging
|
||||
/// root the detached work publishes is visible.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn multipart_index_cancelled_publisher_keeps_its_marker_until_the_detached_work_returns() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let disk = Arc::new(open(root.path()).await);
|
||||
let (path, info) = upload("bucket", "caller-timed-out");
|
||||
let source_root = path.split('/').next().expect("source sha").to_owned();
|
||||
let marker = root.path().join(INDEX).join(sha("bucket")).join(&source_root);
|
||||
let source_head = root
|
||||
.path()
|
||||
.join(RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(&path)
|
||||
.join(STORAGE_FORMAT_FILE);
|
||||
let (paused, release) = pause_next_publication(&disk.multipart_index);
|
||||
let writer = tokio::spawn({
|
||||
let disk = Arc::clone(&disk);
|
||||
let path = path.clone();
|
||||
async move { disk.write_metadata("bucket", RUSTFS_META_MULTIPART_BUCKET, &path, info).await }
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), paused)
|
||||
.await
|
||||
.expect("the publication reaches the pause")
|
||||
.expect("pause sender");
|
||||
writer.abort();
|
||||
assert!(writer.await.expect_err("the caller is gone").is_cancelled());
|
||||
|
||||
let identity = Uuid::new_v4();
|
||||
let page = disk
|
||||
.multipart_index
|
||||
.page("bucket", None, identity)
|
||||
.await
|
||||
.expect("page after the caller left");
|
||||
assert_eq!(page.roots, vec![source_root.clone()]);
|
||||
assert!(marker.exists(), "the detached publication still owns the marker");
|
||||
|
||||
release.send(()).expect("resume the detached publication");
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
while !source_head.exists() || !disk.multipart_index.publishing.lock().is_empty() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the detached publication completes");
|
||||
let page = disk
|
||||
.multipart_index
|
||||
.page("bucket", None, identity)
|
||||
.await
|
||||
.expect("page after the detached publication");
|
||||
assert_eq!(page.roots, vec![source_root], "the upload the detached work published is listed");
|
||||
assert!(marker.exists());
|
||||
}
|
||||
|
||||
/// A page classifies a marker as stale before the publisher pins it; the
|
||||
/// prune that follows must read the pin again under the marker lease.
|
||||
#[tokio::test]
|
||||
async fn multipart_index_prune_rechecks_the_pin_under_the_marker_lease() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let disk = open(root.path()).await;
|
||||
let owner = &disk.multipart_index;
|
||||
let directory = root.path().join(INDEX).join(sha("bucket"));
|
||||
std::fs::create_dir_all(&directory).expect("marker directory");
|
||||
let name = format!("{:064x}", 9);
|
||||
std::fs::write(directory.join(&name), b"1").expect("marker");
|
||||
|
||||
let pin = owner.pin_publication(owner.base.join(INDEX).join(sha("bucket")).join(&name));
|
||||
owner.prune_marker("bucket", &name).await.expect("prune of a pinned marker");
|
||||
assert!(directory.join(&name).exists(), "a publisher that pinned after the page keeps its marker");
|
||||
|
||||
drop(pin);
|
||||
owner.prune_marker("bucket", &name).await.expect("prune of a leftover");
|
||||
assert!(!directory.join(&name).exists(), "the same marker is a leftover once nobody publishes it");
|
||||
}
|
||||
|
||||
/// The publisher pins before it queues on the marker lease. A prune that
|
||||
/// holds the lease and found no pin has therefore unlinked the marker
|
||||
/// before the publisher records it, and the record recreates it.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn multipart_index_publisher_pins_before_it_waits_for_the_marker_lease() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let disk = Arc::new(open(root.path()).await);
|
||||
let owner = Arc::clone(&disk.multipart_index);
|
||||
let (path, info) = upload("bucket", "queued-behind-a-prune");
|
||||
let source_root = path.split('/').next().expect("source sha").to_owned();
|
||||
let marker = owner.base.join(INDEX).join(sha("bucket")).join(&source_root);
|
||||
let visible_marker = root.path().join(INDEX).join(sha("bucket")).join(&source_root);
|
||||
|
||||
// Stand in for a prune that owns the marker lease and already read "no pin".
|
||||
let prune_lease = os::acquire_namespace_mutation_lease_with_owner(&marker, None).await;
|
||||
assert!(!owner.is_publishing(&marker));
|
||||
let writer = tokio::spawn({
|
||||
let disk = Arc::clone(&disk);
|
||||
let path = path.clone();
|
||||
async move { disk.write_metadata("bucket", RUSTFS_META_MULTIPART_BUCKET, &path, info).await }
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while !owner.is_publishing(&marker) {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the publisher pins while the marker lease is still held by the prune");
|
||||
assert!(!visible_marker.exists(), "the marker is recorded only under the lease");
|
||||
|
||||
drop(prune_lease);
|
||||
writer.await.expect("writer task").expect("the upload metadata is published");
|
||||
assert!(visible_marker.exists(), "the record after the prune recreates the marker");
|
||||
let page = owner.page("bucket", None, Uuid::new_v4()).await.expect("page");
|
||||
assert_eq!(page.roots, vec![source_root]);
|
||||
}
|
||||
|
||||
/// Stale markers at the start of a page, at its cursor, and filling a whole
|
||||
/// page: every page still satisfies the receiver's contract and advances.
|
||||
#[tokio::test]
|
||||
async fn multipart_index_pages_of_stale_markers_keep_valid_cursors() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let disk = open(root.path()).await;
|
||||
let directory = root.path().join(INDEX).join(sha("bucket"));
|
||||
std::fs::create_dir_all(&directory).expect("marker directory");
|
||||
// Page one is entirely stale. Page two is live except its first name
|
||||
// and the name its cursor lands on. Two live names follow.
|
||||
let total = MULTIPART_INDEX_PAGE_SIZE * 2 + 2;
|
||||
let stale = |i: usize| i <= MULTIPART_INDEX_PAGE_SIZE || i == MULTIPART_INDEX_PAGE_SIZE * 2 - 1;
|
||||
for i in 0..total {
|
||||
let name = format!("{i:064x}");
|
||||
std::fs::write(directory.join(&name), b"1").expect("marker");
|
||||
if !stale(i) {
|
||||
std::fs::create_dir_all(root.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&name)).expect("staging root");
|
||||
}
|
||||
}
|
||||
let disk_id = Uuid::new_v4();
|
||||
let first = disk.multipart_index.page("bucket", None, disk_id).await.expect("first page");
|
||||
first
|
||||
.validate("bucket", None, disk_id)
|
||||
.expect("an empty page with a cursor validates");
|
||||
assert!(first.roots.is_empty(), "a fully stale page has no roots");
|
||||
let cursor = first.next_marker.clone().expect("more markers follow a fully stale page");
|
||||
assert_eq!(cursor, format!("{:064x}", MULTIPART_INDEX_PAGE_SIZE - 1));
|
||||
|
||||
let second = disk
|
||||
.multipart_index
|
||||
.page("bucket", Some(&cursor), disk_id)
|
||||
.await
|
||||
.expect("second page");
|
||||
second
|
||||
.validate("bucket", Some(&cursor), disk_id)
|
||||
.expect("stale first and cursor names validate");
|
||||
assert_eq!(second.roots.len(), MULTIPART_INDEX_PAGE_SIZE - 2);
|
||||
assert_eq!(second.roots.first(), Some(&format!("{:064x}", MULTIPART_INDEX_PAGE_SIZE + 1)));
|
||||
let cursor = second
|
||||
.next_marker
|
||||
.clone()
|
||||
.expect("the cursor is the stale last examined name");
|
||||
assert_eq!(cursor, format!("{:064x}", MULTIPART_INDEX_PAGE_SIZE * 2 - 1));
|
||||
assert!(second.roots.last() < Some(&cursor));
|
||||
|
||||
let third = disk
|
||||
.multipart_index
|
||||
.page("bucket", Some(&cursor), disk_id)
|
||||
.await
|
||||
.expect("third page");
|
||||
third.validate("bucket", Some(&cursor), disk_id).expect("last page validates");
|
||||
assert_eq!(third.roots.len(), 2);
|
||||
assert!(third.next_marker.is_none());
|
||||
let remaining = std::fs::read_dir(&directory).expect("marker directory").count();
|
||||
assert_eq!(remaining, total - MULTIPART_INDEX_PAGE_SIZE - 2, "every stale marker is pruned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1015,7 +1290,6 @@ mod tests {
|
||||
std::fs::create_dir_all(root.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&name)).expect("staging root");
|
||||
}
|
||||
}
|
||||
age_marker(&directory.join(&stale));
|
||||
let disk_id = Uuid::new_v4();
|
||||
let first = disk.multipart_index.page("bucket", None, disk_id).await.expect("first page");
|
||||
first
|
||||
|
||||
@@ -813,7 +813,8 @@ impl SourceStore {
|
||||
Ok(())
|
||||
}
|
||||
pub async fn replace_head(&self, bytes: &[u8]) -> Result<()> {
|
||||
self.multipart_index
|
||||
let index_pin = self
|
||||
.multipart_index
|
||||
.record_head(&format!("{}/xl.meta", self.path), bytes)
|
||||
.await?;
|
||||
invalidate_active_multipart_source_head(&self.source);
|
||||
@@ -822,6 +823,7 @@ impl SourceStore {
|
||||
let root = self.publication_root.clone();
|
||||
let bytes = bytes.to_vec();
|
||||
os::run_blocking_namespace_operation(self.lease.clone(), move || {
|
||||
let _index_pin = index_pin;
|
||||
os::replace_multipart_source_metadata(&path, &bytes, &base, &root)
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -2585,7 +2585,13 @@ pub struct BoundedDeleteRecoveryDiscovery {
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
pub const DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES: u32 = 4096;
|
||||
/// Entries one bounded discovery call may charge on one drive. Object-scoped
|
||||
/// discovery charges each transaction-shaped (UUID) child of the key's
|
||||
/// directory once and every name that cannot be delete evidence at
|
||||
/// 1/`PLAIN_CHILDREN_PER_BUDGET_ENTRY`, so this admits a missing-key delete of a
|
||||
/// folder prefix with this many UUID-named children per drive. The request
|
||||
/// budget (`DeleteRecoveryWorkBudget`) still bounds the total across keys.
|
||||
pub const DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES: u32 = 262_144;
|
||||
pub const DELETE_RECOVERY_MAX_INSPECTION_ENTRIES: u32 = 65536;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -619,10 +619,6 @@ pub(crate) mod prepare_rename_test_hooks {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_after_component_opened() -> bool {
|
||||
!AFTER_COMPONENT_OPENED.lock().is_empty()
|
||||
}
|
||||
|
||||
/// Hooks keyed by a directory component name, run before a Windows
|
||||
/// directory pin retries a transient sharing conflict on it.
|
||||
#[cfg(windows)]
|
||||
@@ -1292,6 +1288,28 @@ pub(crate) fn fsync_dir_limited_std(dir: &Path) -> io::Result<()> {
|
||||
fsync_dir_std(dir)
|
||||
}
|
||||
|
||||
pub(crate) fn fsync_commit_ancestor_directory_limited_std(
|
||||
dir: &Path,
|
||||
base_dir: &Path,
|
||||
step_from_destination: usize,
|
||||
guard: &RenameCommitGuard,
|
||||
) -> io::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if base_dir == guard.directories.destination_base
|
||||
&& guard.directories.destination_path.ancestors().nth(1 + step_from_destination) == Some(dir)
|
||||
&& let Some(ancestors) = guard.directories.destination_ancestors.get()
|
||||
&& let Some(idx) = ancestors.len().checked_sub(1 + step_from_destination)
|
||||
&& let Some(file) = ancestors.get(idx)
|
||||
{
|
||||
#[cfg(test)]
|
||||
fsync_dir_recorder::record_limited(dir);
|
||||
return fsync_directory_handle_std(dir, Some(file));
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = (base_dir, step_from_destination, guard);
|
||||
fsync_dir_limited_std(dir)
|
||||
}
|
||||
|
||||
/// Reuse the published directory without bypassing group commit or sync admission.
|
||||
pub(crate) async fn fsync_commit_directory(
|
||||
dir: &Path,
|
||||
@@ -5996,6 +6014,7 @@ struct LinuxCommitDirectories {
|
||||
destination_path: PathBuf,
|
||||
source: LinuxCommitDirectory,
|
||||
destination: std::sync::OnceLock<LinuxCommitDirectory>,
|
||||
destination_ancestors: std::sync::OnceLock<Vec<std::fs::File>>,
|
||||
destination_base: PathBuf,
|
||||
}
|
||||
|
||||
@@ -6012,6 +6031,8 @@ impl LinuxCommitDirectories {
|
||||
.ok_or_else(|| io::Error::other("missing destination directory guard"))?,
|
||||
);
|
||||
let directory = LinuxCommitDirectory::new(directory)?;
|
||||
let ancestors = parents.into_iter().map(std::fs::File::from).collect();
|
||||
let _ = self.destination_ancestors.set(ancestors);
|
||||
Ok(self.destination.get_or_init(|| directory))
|
||||
}
|
||||
|
||||
@@ -6021,7 +6042,7 @@ impl LinuxCommitDirectories {
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "file parent does not match its commit guard"))
|
||||
}
|
||||
|
||||
fn validate(&self) -> io::Result<()> {
|
||||
fn validate(&self, check_destination: bool) -> io::Result<()> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// A pinned directory may have been unlinked or replaced. Do not report a
|
||||
// successful publication into a detached object tree.
|
||||
@@ -6030,8 +6051,9 @@ impl LinuxCommitDirectories {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "commit directory identity changed"));
|
||||
}
|
||||
let destination = self.prepare_destination()?;
|
||||
if (self.source.device, self.source.inode) != (destination.device, destination.inode)
|
||||
|| self.source_path.as_os_str() != self.destination_path.as_os_str()
|
||||
if check_destination
|
||||
&& ((self.source.device, self.source.inode) != (destination.device, destination.inode)
|
||||
|| self.source_path.as_os_str() != self.destination_path.as_os_str())
|
||||
{
|
||||
let current = self.root.open_directory(&self.destination_path)?.metadata()?;
|
||||
if (current.dev(), current.ino()) != (destination.device, destination.inode) {
|
||||
@@ -6045,8 +6067,9 @@ impl LinuxCommitDirectories {
|
||||
record_native_publication(destination);
|
||||
let source_name = self.file_name(source, true)?;
|
||||
let destination_name = self.file_name(destination, false)?;
|
||||
let destination_was_prepared = self.destination.get().is_some();
|
||||
let destination_parent = self.prepare_destination()?;
|
||||
self.validate()?;
|
||||
self.validate(destination_was_prepared)?;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
if let Some(prepared) = prepared {
|
||||
@@ -6268,7 +6291,7 @@ pub(crate) fn multipart_payload_commit_guard_std(
|
||||
source_parent: &Path,
|
||||
) -> io::Result<RenameCommitGuard> {
|
||||
let original = &metadata_guard.directories;
|
||||
original.validate()?;
|
||||
original.validate(true)?;
|
||||
let source = LinuxCommitDirectory::new(original.root.open_directory(source_parent)?)?;
|
||||
let destination = original.prepare_destination()?;
|
||||
// Clone the pinned file identity, never recapture the target pathname
|
||||
@@ -6285,6 +6308,7 @@ pub(crate) fn multipart_payload_commit_guard_std(
|
||||
destination_path: original.destination_path.clone(),
|
||||
source,
|
||||
destination: std::sync::OnceLock::from(destination),
|
||||
destination_ancestors: std::sync::OnceLock::new(),
|
||||
destination_base: original.destination_base.clone(),
|
||||
}),
|
||||
})
|
||||
@@ -6366,6 +6390,7 @@ pub(crate) fn prepare_rename_commit_guard(
|
||||
destination_path: destination_parent.to_path_buf(),
|
||||
source,
|
||||
destination,
|
||||
destination_ancestors: std::sync::OnceLock::new(),
|
||||
destination_base: destination_base.to_path_buf(),
|
||||
}),
|
||||
})
|
||||
@@ -7475,6 +7500,7 @@ fn same_parent_commit_guard(
|
||||
device: dir.device,
|
||||
inode: dir.inode,
|
||||
}),
|
||||
destination_ancestors: std::sync::OnceLock::new(),
|
||||
destination_base: base.to_path_buf(),
|
||||
}),
|
||||
});
|
||||
@@ -8514,37 +8540,7 @@ pub(crate) fn mkdir_all_below_existing_base_std(
|
||||
let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
|
||||
let mode = Mode::RWXU | Mode::RWXG | Mode::RWXO;
|
||||
#[cfg(target_os = "linux")]
|
||||
let base = {
|
||||
use rustix::fs::{ResolveFlags, openat2};
|
||||
use std::os::fd::AsFd;
|
||||
|
||||
let owned_base = (base_dir != publication_root.io_path.as_path() && base_dir != publication_root.path.as_path())
|
||||
.then(|| publication_root.open_directory(base_dir).map(std::os::fd::OwnedFd::from))
|
||||
.transpose()?;
|
||||
#[cfg(test)]
|
||||
let has_hook = prepare_rename_test_hooks::has_after_component_opened();
|
||||
#[cfg(not(test))]
|
||||
let has_hook = false;
|
||||
if !has_hook {
|
||||
let base_fd = owned_base
|
||||
.as_ref()
|
||||
.map_or_else(|| publication_root.directory.as_fd(), AsFd::as_fd);
|
||||
let target = if relative.as_os_str().is_empty() {
|
||||
Path::new(".")
|
||||
} else {
|
||||
relative
|
||||
};
|
||||
match openat2(base_fd, target, flags, Mode::empty(), ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS) {
|
||||
Ok(leaf) => return Ok(vec![leaf]),
|
||||
Err(Errno::NOENT | Errno::NOSYS) => {}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
match owned_base {
|
||||
Some(base) => base,
|
||||
None => publication_root.open_directory(base_dir)?.into(),
|
||||
}
|
||||
};
|
||||
let base = publication_root.open_directory(base_dir)?.into();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let base = {
|
||||
// The configured endpoint is trusted; every component beneath it
|
||||
|
||||
@@ -975,9 +975,11 @@ pub struct DeleteRecoveryWorkBudget {
|
||||
remaining: std::sync::atomic::AtomicU32,
|
||||
}
|
||||
|
||||
/// Entries one disk may visit per recovery step (discovery plus inspection).
|
||||
/// Every disk of every set a request fans out to is admitted up to this much
|
||||
/// and charged on completion, so a budget must cover that many disks.
|
||||
/// Per-drive unit a request budget is sized by (a typical discovery plus one
|
||||
/// inspection). Admission charges what a call visited, not this unit, so it only
|
||||
/// scales the request total with the drives a request can reach; it is kept
|
||||
/// independent of the per-call caps, which may let one key's probe use more of
|
||||
/// the same total on a large folder.
|
||||
const DELETE_RECOVERY_RESERVATION_PER_DISK: u32 = 4096 + 65_536;
|
||||
|
||||
impl Default for DeleteRecoveryWorkBudget {
|
||||
|
||||
@@ -11040,7 +11040,12 @@ mod tests {
|
||||
let live = Uuid::new_v4();
|
||||
let orphan = Uuid::new_v4();
|
||||
let object_dir = dir.path().join("bucket/obj");
|
||||
write_object_meta_with_data_dirs(&object_dir, "bucket", "obj", &[live]).await;
|
||||
// Part files are inert to discovery and cost 1/64 of an entry, so the
|
||||
// first step is exhausted by what still costs a full entry each: the
|
||||
// data directories of many versions, all referenced by the metadata.
|
||||
let versions = (0..4200).map(|_| Uuid::new_v4()).collect::<Vec<_>>();
|
||||
let referenced = std::iter::once(live).chain(versions.iter().copied()).collect::<Vec<_>>();
|
||||
write_object_meta_with_data_dirs(&object_dir, "bucket", "obj", &referenced).await;
|
||||
let live_dir = object_dir.join(live.to_string());
|
||||
let orphan_dir = object_dir.join(orphan.to_string());
|
||||
fs::create_dir_all(&live_dir).await.expect("create live multipart directory");
|
||||
@@ -11049,10 +11054,14 @@ mod tests {
|
||||
.await
|
||||
.expect("write orphan payload");
|
||||
let parts_dir = live_dir.clone();
|
||||
let versions_root = object_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
for part in 1..=4200 {
|
||||
std::fs::write(parts_dir.join(format!("part.{part}")), b"live").expect("write live multipart entry");
|
||||
}
|
||||
for version in versions {
|
||||
std::fs::create_dir(versions_root.join(version.to_string())).expect("create referenced version directory");
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("create multipart fixture");
|
||||
@@ -11062,7 +11071,10 @@ mod tests {
|
||||
.await
|
||||
.expect("read first discovery page");
|
||||
assert!(first.transactions.is_empty());
|
||||
assert!(first.next.is_some(), "ordinary multipart entries must exhaust the first scan budget");
|
||||
assert!(
|
||||
first.next.is_some(),
|
||||
"the data directories of many versions must exhaust the first scan step"
|
||||
);
|
||||
let last = disk
|
||||
.discover_delete_transactions("bucket", Some("obj"), first.next, 1)
|
||||
.await
|
||||
|
||||
@@ -1709,6 +1709,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_missing_key_naming_a_folder_of_uuid_named_keys_completes() {
|
||||
let bucket = "recover-uuid-named-folder";
|
||||
let (dirs, store, _) = fixture(bucket).await;
|
||||
// More UUID-named child keys per drive than the former 4096-entry probe
|
||||
// could cover once each child's `xl.meta` and data directory were charged
|
||||
// (three entries per child).
|
||||
const CHILDREN: usize = 1500;
|
||||
for dir in &dirs {
|
||||
let prefix = dir.path().join(bucket).join("uploads");
|
||||
for _ in 0..CHILDREN {
|
||||
let child = prefix.join(Uuid::new_v4().to_string());
|
||||
std::fs::create_dir_all(child.join(Uuid::new_v4().to_string())).expect("child key data dir");
|
||||
std::fs::write(child.join(crate::disk::STORAGE_FORMAT_FILE), b"meta").expect("child key metadata");
|
||||
}
|
||||
}
|
||||
let error = store
|
||||
.delete_object(bucket, "uploads", ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("the folder's own key does not exist");
|
||||
assert!(
|
||||
crate::error::is_err_object_not_found(&error),
|
||||
"a complete inventory keeps the missing-key no-op semantics: {error:?}"
|
||||
);
|
||||
assert!(dirs.iter().all(|dir| {
|
||||
std::fs::read_dir(dir.path().join(bucket).join("uploads"))
|
||||
.map(|entries| entries.count() == CHILDREN)
|
||||
.unwrap_or(false)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_batch_budget_survives_pool_and_set_fanout() {
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
[package]
|
||||
name = "rustfs-gateway-adapter"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "RustFS implementations of the rustfs-gateway extension traits (credentials, policy facts, virtual hosts, bucket owner and request id)."
|
||||
keywords = ["rustfs", "gateway", "s3", "iam"]
|
||||
categories = ["web-programming", "authentication"]
|
||||
authors.workspace = true
|
||||
publish = false
|
||||
|
||||
[package.metadata.gateway]
|
||||
# Documentary only: this crate is a ring-2 consumer of the gateway (it depends on
|
||||
# RustFS business crates). The RustFS repository does not run the gateway's ring guard.
|
||||
ring = 2
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
http = { workspace = true }
|
||||
rustfs-gateway = { workspace = true }
|
||||
rustfs-credentials = { workspace = true }
|
||||
rustfs-iam = { workspace = true }
|
||||
rustfs-policy = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rustfs-gateway-http = { workspace = true }
|
||||
time = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! The gateway's [`BucketOwnerSource`]: the one owner RustFS reports, for a bucket that exists.
|
||||
//!
|
||||
//! Responsible for: confirming through a [`BucketLookup`] that a bucket exists, answering the
|
||||
//! single owner id the deployment reports ([`RustfsBucketOwners::new`]) for it, and turning every
|
||||
//! outcome that is not a usable owner into the gateway's one refusal, [`BucketOwnerError`].
|
||||
//! NOT responsible for: choosing the owner id (the binary passes the constant it already reports
|
||||
//! as the owner in listings), comparing it with `x-amz-expected-bucket-owner` (the gateway does,
|
||||
//! byte for byte), or authorizing anything.
|
||||
//! Upstream: the gateway's expected-owner check, which consults this source only when a request
|
||||
//! carries the header, after authorization allowed it. Downstream: the lookup the binary supplies
|
||||
//! over the ecstore bucket metadata system.
|
||||
//!
|
||||
//! # What an owner is in RustFS
|
||||
//!
|
||||
//! RustFS stores no per-bucket owner: bucket ACL writes are no-ops and nothing writes the ACL
|
||||
//! document, so the only owner RustFS reports anywhere (ListBuckets, ACL and multipart listings)
|
||||
//! is one constant. The source answers exactly that constant. A caller therefore passes the
|
||||
//! gateway's check only by presenting the owner id RustFS lists, which separates no tenants; it is
|
||||
//! a compatibility answer, and the constant's own documentation says it must not decide
|
||||
//! authorization.
|
||||
//!
|
||||
//! # Difference from legacy (ledger entry R-071)
|
||||
//!
|
||||
//! Legacy RustFS ignores `x-amz-expected-bucket-owner` entirely. On the gateway stack a request
|
||||
//! carrying it is served only when the value equals the reported owner id; otherwise it is `403
|
||||
//! AccessDenied`. A missing bucket and a backend failure with the header present are also `403`
|
||||
//! at the pinned gateway, where legacy answers `404` and `5xx`: [`BucketOwnerError`] has one value,
|
||||
//! so it cannot express either. [`RustfsBucketOwners::resolve`] keeps the two apart at this
|
||||
//! adapter's boundary so the gateway can surface them once it grows the classes.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustfs_gateway::{BoxFuture, BucketName, BucketOwnerError, BucketOwnerSource};
|
||||
|
||||
/// Why a bucket yielded no owner.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OwnerLookupError {
|
||||
/// The bucket does not exist.
|
||||
NoSuchBucket,
|
||||
/// The metadata backend failed, could not be reached, or the owner is not usable (an empty
|
||||
/// owner id is a misconfiguration), so the bucket's state is unknown.
|
||||
Backend,
|
||||
}
|
||||
|
||||
/// The narrow slice of RustFS bucket metadata an owner answer needs: whether a bucket exists.
|
||||
///
|
||||
/// Exists so the source can be tested against a fake. The binary implements it over the ecstore
|
||||
/// bucket metadata system, mapping `BucketNotFound` to [`OwnerLookupError::NoSuchBucket`] and every
|
||||
/// other error to [`OwnerLookupError::Backend`].
|
||||
pub trait BucketLookup: Send + Sync + 'static {
|
||||
/// `Ok(())` when `bucket` exists.
|
||||
fn check<'a>(&'a self, bucket: &'a str) -> BoxFuture<'a, Result<(), OwnerLookupError>>;
|
||||
}
|
||||
|
||||
/// A [`BucketOwnerSource`] answering one owner id for every bucket that exists.
|
||||
pub struct RustfsBucketOwners<L> {
|
||||
lookup: L,
|
||||
owner: Arc<str>,
|
||||
}
|
||||
|
||||
impl<L: BucketLookup> RustfsBucketOwners<L> {
|
||||
/// A source answering `owner` for every bucket `lookup` finds.
|
||||
///
|
||||
/// An empty `owner` is no owner: every answer is then [`OwnerLookupError::Backend`].
|
||||
#[must_use]
|
||||
pub fn new(lookup: L, owner: impl Into<Arc<str>>) -> Self {
|
||||
Self {
|
||||
lookup,
|
||||
owner: owner.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner of `bucket`, or why there is none. Missing and failing stay distinct here.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`OwnerLookupError::NoSuchBucket`] when the bucket does not exist;
|
||||
/// [`OwnerLookupError::Backend`] on any lookup failure or when the configured owner is empty.
|
||||
pub async fn resolve(&self, bucket: &str) -> Result<Arc<str>, OwnerLookupError> {
|
||||
self.lookup.check(bucket).await?;
|
||||
if self.owner.is_empty() {
|
||||
return Err(OwnerLookupError::Backend);
|
||||
}
|
||||
Ok(Arc::clone(&self.owner))
|
||||
}
|
||||
}
|
||||
|
||||
impl<L: BucketLookup> BucketOwnerSource for RustfsBucketOwners<L> {
|
||||
fn owner<'a>(&'a self, bucket: &'a BucketName) -> BoxFuture<'a, Result<Arc<str>, BucketOwnerError>> {
|
||||
Box::pin(async move {
|
||||
// The pinned gateway collapses both classes (R-071), so both are the one refusal.
|
||||
self.resolve(bucket.as_str())
|
||||
.await
|
||||
.map_err(|_: OwnerLookupError| BucketOwnerError::unavailable())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Stands for the owner id RustFS reports; the binary passes its own constant.
|
||||
const OWNER: &str = "reported-owner-id";
|
||||
|
||||
struct Fake {
|
||||
answer: Result<(), OwnerLookupError>,
|
||||
asked: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn answering(answer: Result<(), OwnerLookupError>) -> Self {
|
||||
Self {
|
||||
answer,
|
||||
asked: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketLookup for Fake {
|
||||
fn check<'a>(&'a self, bucket: &'a str) -> BoxFuture<'a, Result<(), OwnerLookupError>> {
|
||||
self.asked.lock().unwrap().push(bucket.to_owned());
|
||||
let answer = self.answer;
|
||||
Box::pin(async move { answer })
|
||||
}
|
||||
}
|
||||
|
||||
fn source(answer: Result<(), OwnerLookupError>) -> RustfsBucketOwners<Fake> {
|
||||
RustfsBucketOwners::new(Fake::answering(answer), OWNER)
|
||||
}
|
||||
|
||||
async fn gateway_answer(answer: Result<(), OwnerLookupError>) -> Result<Arc<str>, BucketOwnerError> {
|
||||
source(answer).owner(&BucketName::new("photos").expect("a bucket name")).await
|
||||
}
|
||||
|
||||
/// Positive: an existing bucket's owner is the configured id, byte for byte.
|
||||
#[tokio::test]
|
||||
async fn an_existing_bucket_has_the_configured_owner() {
|
||||
assert_eq!(gateway_answer(Ok(())).await.as_deref(), Ok(OWNER));
|
||||
}
|
||||
|
||||
/// Positive: every bucket gets the same owner, since RustFS stores none per bucket.
|
||||
#[tokio::test]
|
||||
async fn every_bucket_has_the_same_owner() {
|
||||
let source = source(Ok(()));
|
||||
assert_eq!(source.resolve("photos").await.as_deref(), Ok(OWNER));
|
||||
assert_eq!(source.resolve("backups").await.as_deref(), Ok(OWNER));
|
||||
}
|
||||
|
||||
/// Positive: the lookup is asked about exactly the bucket the gateway named.
|
||||
#[tokio::test]
|
||||
async fn the_lookup_is_asked_about_the_named_bucket() {
|
||||
let source = source(Ok(()));
|
||||
source
|
||||
.owner(&BucketName::new("photos").expect("a bucket name"))
|
||||
.await
|
||||
.expect("an owner");
|
||||
assert_eq!(*source.lookup.asked.lock().unwrap(), vec!["photos".to_owned()]);
|
||||
}
|
||||
|
||||
/// Negative: a missing bucket is reported as missing at the adapter boundary.
|
||||
#[tokio::test]
|
||||
async fn a_missing_bucket_is_reported_as_missing() {
|
||||
assert_eq!(
|
||||
source(Err(OwnerLookupError::NoSuchBucket)).resolve("photos").await,
|
||||
Err(OwnerLookupError::NoSuchBucket)
|
||||
);
|
||||
}
|
||||
|
||||
/// Negative: a failing backend is reported as a failure, never as an owner and never as missing.
|
||||
#[tokio::test]
|
||||
async fn a_backend_failure_is_reported_as_a_failure() {
|
||||
assert_eq!(
|
||||
source(Err(OwnerLookupError::Backend)).resolve("photos").await,
|
||||
Err(OwnerLookupError::Backend)
|
||||
);
|
||||
}
|
||||
|
||||
/// Negative: the pinned gateway sees one refusal for both, and never an owner.
|
||||
#[tokio::test]
|
||||
async fn the_gateway_is_refused_in_both_cases() {
|
||||
assert_eq!(
|
||||
gateway_answer(Err(OwnerLookupError::NoSuchBucket)).await,
|
||||
Err(BucketOwnerError::unavailable())
|
||||
);
|
||||
assert_eq!(gateway_answer(Err(OwnerLookupError::Backend)).await, Err(BucketOwnerError::unavailable()));
|
||||
}
|
||||
|
||||
/// Negative: an empty configured owner is no owner; it must not equal an empty assertion.
|
||||
#[tokio::test]
|
||||
async fn an_empty_owner_is_refused() {
|
||||
let source = RustfsBucketOwners::new(Fake::answering(Ok(())), "");
|
||||
assert_eq!(source.resolve("photos").await, Err(OwnerLookupError::Backend));
|
||||
assert_eq!(
|
||||
source.owner(&BucketName::new("photos").expect("a bucket name")).await,
|
||||
Err(BucketOwnerError::unavailable())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! The gateway's [`CredentialProvider`], answered by RustFS IAM.
|
||||
//!
|
||||
//! Responsible for: resolving one access key id to the secret the gateway verifies a signature
|
||||
//! with, and classifying every other outcome as either a business result
|
||||
//! ([`CredentialLookup::NotFound`], eligible for the gateway's negative cache) or an operational
|
||||
//! fault ([`ProviderError`], counted and never cached).
|
||||
//! NOT responsible for: authorization of any kind, session-token claim validation, or deciding
|
||||
//! what the client is answered with. The gateway answers every unusable credential identically.
|
||||
//!
|
||||
//! # Secret handling
|
||||
//!
|
||||
//! Secrets travel only inside the gateway's [`Credentials`], whose `Debug` redacts them. No type
|
||||
//! in this module derives or implements `Debug`, and no error produced here carries text: the
|
||||
//! IAM error is classified and dropped. A secret that fails the crate's effective-length rule
|
||||
//! (`crate::MIN_SECRET_KEY_LEN` bytes once trailing NULs are removed) is never handed to the
|
||||
//! gateway, whatever the lookup says about its validity.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustfs_gateway::{BoxFuture, CredentialLookup, CredentialProvider, Credentials, ProviderError, SessionBinding};
|
||||
use rustfs_iam::error::Error as IamError;
|
||||
use rustfs_iam::store::Store;
|
||||
use rustfs_iam::sys::IamSys;
|
||||
use rustfs_policy::auth::{ACCOUNT_OFF, UserIdentity};
|
||||
|
||||
use crate::root::{RootSource, is_usable_secret};
|
||||
|
||||
/// The opaque issuer recorded in the [`rustfs_gateway::SessionBinding`] of every RustFS STS
|
||||
/// credential.
|
||||
pub const STS_SESSION_ISSUER: &str = "rustfs-sts";
|
||||
|
||||
/// Access keys with this prefix belong to Keystone token authentication, which never verifies an
|
||||
/// AWS signature.
|
||||
const KEYSTONE_ACCESS_KEY_PREFIX: &str = "keystone:";
|
||||
|
||||
/// The narrow slice of IAM that a credential lookup needs.
|
||||
///
|
||||
/// Exists so the adapter can be tested against a fake: IAM exports no in-memory backend.
|
||||
/// Implemented for [`IamSys`], whose `check_key` loads a user from storage on a cache miss.
|
||||
pub trait IamKeyLookup: Send + Sync + 'static {
|
||||
/// Whether the IAM store has finished loading.
|
||||
fn is_ready(&self) -> bool;
|
||||
|
||||
/// Resolves an access key, with [`IamSys::check_key`]'s contract: `Ok((None, _))` for an
|
||||
/// unknown key, `Ok((Some(identity), valid))` otherwise, where `valid` is false for a
|
||||
/// disabled, expired, or malformed credential.
|
||||
fn check_key<'a>(&'a self, access_key: &'a str) -> BoxFuture<'a, Result<(Option<UserIdentity>, bool), IamError>>;
|
||||
}
|
||||
|
||||
impl<T: Store> IamKeyLookup for IamSys<T> {
|
||||
fn is_ready(&self) -> bool {
|
||||
IamSys::is_ready(self)
|
||||
}
|
||||
|
||||
fn check_key<'a>(&'a self, access_key: &'a str) -> BoxFuture<'a, Result<(Option<UserIdentity>, bool), IamError>> {
|
||||
Box::pin(IamSys::check_key(self, access_key))
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`CredentialProvider`] over RustFS IAM and the live root credentials.
|
||||
///
|
||||
/// Built only by [`crate::IamAdapters::new`], so it always reads the same [`RootSource`] as the
|
||||
/// policy source it was assembled with.
|
||||
pub struct IamCredentials {
|
||||
iam: Arc<dyn IamKeyLookup>,
|
||||
root: Arc<dyn RootSource>,
|
||||
}
|
||||
|
||||
impl IamCredentials {
|
||||
pub(crate) fn new(iam: Arc<dyn IamKeyLookup>, root: Arc<dyn RootSource>) -> Self {
|
||||
Self { iam, root }
|
||||
}
|
||||
|
||||
async fn resolve(&self, access_key: &str) -> Result<CredentialLookup, ProviderError> {
|
||||
// Deliberate change from the legacy s3s path (`rustfs/src/auth.rs` `get_secret_key`),
|
||||
// which answered a `keystone:` key with an empty secret. Keystone authenticates by token,
|
||||
// so there is no AWS secret to verify a signature against: the key is unknown here.
|
||||
if access_key.starts_with(KEYSTONE_ACCESS_KEY_PREFIX) {
|
||||
return Ok(CredentialLookup::NotFound);
|
||||
}
|
||||
|
||||
// Legacy answers every key with an internal error while the root credentials are not
|
||||
// initialized; without them no key can be classified as root or not.
|
||||
let Some(root) = self.root.current() else {
|
||||
return Err(ProviderError::Unavailable);
|
||||
};
|
||||
// Byte-exact, as the trait requires: the access key id is signed material.
|
||||
if root.access_key() == access_key {
|
||||
return Ok(CredentialLookup::Found(root.credentials().clone_credentials()));
|
||||
}
|
||||
|
||||
if !self.iam.is_ready() {
|
||||
return Err(ProviderError::Unavailable);
|
||||
}
|
||||
|
||||
match self.iam.check_key(access_key).await {
|
||||
// IAM refuses such a secret through `valid`, but this provider does not rely on every
|
||||
// lookup doing so: a short secret is never usable, so it is not a credential at all.
|
||||
Ok((Some(identity), _)) if !is_usable_secret(identity.credentials.secret_key.as_bytes()) => {
|
||||
Ok(CredentialLookup::NotFound)
|
||||
}
|
||||
Ok((Some(identity), valid)) => to_gateway_credentials(access_key, &identity, valid).map(CredentialLookup::Found),
|
||||
Ok((None, _)) => Ok(CredentialLookup::NotFound),
|
||||
Err(error) => classify_iam_error(&error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies an IAM failure without keeping any of its text.
|
||||
fn classify_iam_error(error: &IamError) -> Result<CredentialLookup, ProviderError> {
|
||||
match error {
|
||||
IamError::NoSuchUser(_)
|
||||
| IamError::NoSuchServiceAccount(_)
|
||||
| IamError::NoSuchTempAccount(_)
|
||||
| IamError::NoSuchAccount(_) => Ok(CredentialLookup::NotFound),
|
||||
IamError::IamSysNotInitialized => Err(ProviderError::Unavailable),
|
||||
_ => Err(ProviderError::Backend),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the gateway credential for one IAM identity. No authorization happens here: a
|
||||
/// disabled or invalid principal still resolves, marked disabled, and the gateway refuses it
|
||||
/// exactly as it refuses an unknown key.
|
||||
fn to_gateway_credentials(access_key: &str, identity: &UserIdentity, valid: bool) -> Result<Credentials, ProviderError> {
|
||||
let cred = &identity.credentials;
|
||||
let mut credentials = Credentials::new(access_key, cred.secret_key.as_bytes()).map_err(|_| ProviderError::Backend)?;
|
||||
|
||||
// A service account carries a claims token internally, but its clients never present it, so
|
||||
// it authenticates as a long-term credential (as `check_session_token` treats it in legacy).
|
||||
if !cred.session_token.is_empty() && !cred.is_service_account() {
|
||||
// A temporary credential without an expiry cannot become a gateway session, which always
|
||||
// ends. Refusing it as a fault is the fail-closed answer; a never-ending session is not.
|
||||
let expiration = cred.expiration.ok_or(ProviderError::Backend)?;
|
||||
let binding = SessionBinding::new(STS_SESSION_ISSUER, expiration.unix_timestamp()).map_err(|_| ProviderError::Backend)?;
|
||||
credentials = credentials
|
||||
.with_session(&cred.session_token, binding)
|
||||
.map_err(|_| ProviderError::Backend)?;
|
||||
}
|
||||
|
||||
if !valid || cred.status == ACCOUNT_OFF {
|
||||
credentials = credentials.disable();
|
||||
}
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
impl CredentialProvider for IamCredentials {
|
||||
fn lookup<'a>(&'a self, access_key_id: &'a str) -> BoxFuture<'a, Result<CredentialLookup, ProviderError>> {
|
||||
Box::pin(self.resolve(access_key_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::root::{RootCell, RootCredentials};
|
||||
use rustfs_credentials::Credentials as IamCredential;
|
||||
use rustfs_gateway::RequestNow;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
const ACCESS_KEY: &str = "AKIDUSER0001";
|
||||
const SECRET: &str = "user-secret-key-0001";
|
||||
|
||||
type Answer = Box<dyn Fn() -> Result<(Option<UserIdentity>, bool), IamError> + Send + Sync>;
|
||||
|
||||
struct FakeIam {
|
||||
ready: bool,
|
||||
answer: Answer,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl IamKeyLookup for FakeIam {
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn check_key<'a>(&'a self, _access_key: &'a str) -> BoxFuture<'a, Result<(Option<UserIdentity>, bool), IamError>> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
let answer = (self.answer)();
|
||||
Box::pin(async move { answer })
|
||||
}
|
||||
}
|
||||
|
||||
fn fake(
|
||||
ready: bool,
|
||||
answer: impl Fn() -> Result<(Option<UserIdentity>, bool), IamError> + Send + Sync + 'static,
|
||||
) -> Arc<FakeIam> {
|
||||
Arc::new(FakeIam {
|
||||
ready,
|
||||
answer: Box::new(answer),
|
||||
calls: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn user(status: &str) -> IamCredential {
|
||||
IamCredential {
|
||||
access_key: ACCESS_KEY.to_owned(),
|
||||
secret_key: SECRET.to_owned(),
|
||||
status: status.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn found(cred: IamCredential, valid: bool) -> Result<(Option<UserIdentity>, bool), IamError> {
|
||||
Ok((Some(UserIdentity::new(cred)), valid))
|
||||
}
|
||||
|
||||
fn root_cell() -> Arc<RootCell> {
|
||||
let root = RootCredentials::new("rootadmin", "root-secret-key", "sts-signing-key").expect("valid root credentials");
|
||||
Arc::new(RootCell::new(Some(root)))
|
||||
}
|
||||
|
||||
async fn lookup(iam: Arc<FakeIam>, access_key: &str) -> Result<CredentialLookup, ProviderError> {
|
||||
IamCredentials::new(iam, root_cell()).lookup(access_key).await
|
||||
}
|
||||
|
||||
fn expect_found(result: Result<CredentialLookup, ProviderError>) -> Credentials {
|
||||
match result {
|
||||
Ok(CredentialLookup::Found(credentials)) => credentials,
|
||||
Ok(CredentialLookup::NotFound) => panic!("expected Found, got NotFound"),
|
||||
Err(error) => panic!("expected Found, got {error:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_not_found(result: &Result<CredentialLookup, ProviderError>) -> bool {
|
||||
matches!(result, Ok(CredentialLookup::NotFound))
|
||||
}
|
||||
|
||||
/// Positive — an enabled user resolves to exactly its secret, as a long-term credential.
|
||||
#[tokio::test]
|
||||
async fn credentials_found_user_resolves_to_its_secret() {
|
||||
let iam = fake(true, || found(user("on"), true));
|
||||
let credentials = expect_found(lookup(iam, ACCESS_KEY).await);
|
||||
assert_eq!(credentials.secret().expose(), SECRET.as_bytes());
|
||||
assert_eq!(credentials.identity().access_key_id(), ACCESS_KEY);
|
||||
assert!(!credentials.is_disabled());
|
||||
assert!(credentials.session_binding().is_none());
|
||||
}
|
||||
|
||||
/// Negative — a key IAM does not know is a business result, not a fault.
|
||||
#[tokio::test]
|
||||
async fn credentials_unknown_key_is_not_found() {
|
||||
let iam = fake(true, || Ok((None, false)));
|
||||
assert!(is_not_found(&lookup(iam, "AKIDUNKNOWN").await));
|
||||
}
|
||||
|
||||
/// Negative — every "no such principal" error IAM can surface is `NotFound`. Answering these
|
||||
/// as `Backend` would keep them out of the negative cache and count every guessed key as a
|
||||
/// backend fault.
|
||||
#[tokio::test]
|
||||
async fn credentials_no_such_principal_errors_are_not_found() {
|
||||
let errors: [fn() -> IamError; 4] = [
|
||||
|| IamError::NoSuchUser(ACCESS_KEY.to_owned()),
|
||||
|| IamError::NoSuchServiceAccount(ACCESS_KEY.to_owned()),
|
||||
|| IamError::NoSuchTempAccount(ACCESS_KEY.to_owned()),
|
||||
|| IamError::NoSuchAccount(ACCESS_KEY.to_owned()),
|
||||
];
|
||||
for error in errors {
|
||||
let iam = fake(true, move || Err(error()));
|
||||
let result = lookup(iam, ACCESS_KEY).await;
|
||||
assert!(is_not_found(&result), "{:?} must be NotFound", error());
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative — any other IAM failure is a backend fault, never `NotFound` and never `Found`.
|
||||
#[tokio::test]
|
||||
async fn credentials_backend_error_is_backend() {
|
||||
let iam = fake(true, || Err(IamError::StringError("storage read failed".to_owned())));
|
||||
assert!(matches!(lookup(iam, ACCESS_KEY).await, Err(ProviderError::Backend)));
|
||||
}
|
||||
|
||||
/// Negative — a `keystone:` key never reaches IAM and never resolves to a secret.
|
||||
///
|
||||
/// Deliberate change from the legacy s3s path, which answered such a key with an empty
|
||||
/// secret: Keystone authenticates by token, so there is no AWS secret to verify against.
|
||||
#[tokio::test]
|
||||
async fn credentials_keystone_prefix_is_not_found_without_iam() {
|
||||
let iam = fake(true, || found(user("on"), true));
|
||||
let result = lookup(iam.clone(), "keystone:project-user").await;
|
||||
assert!(is_not_found(&result));
|
||||
assert_eq!(iam.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
/// Negative — a switched-off or otherwise invalid user resolves, but disabled, and the same
|
||||
/// user enabled does not.
|
||||
#[tokio::test]
|
||||
async fn credentials_disabled_user_is_disabled() {
|
||||
let off = expect_found(lookup(fake(true, || found(user("off"), false)), ACCESS_KEY).await);
|
||||
assert!(off.is_disabled());
|
||||
assert!(off.admit(None, RequestNow::from_unix_seconds(0)).is_err());
|
||||
|
||||
let invalid = expect_found(lookup(fake(true, || found(user("on"), false)), ACCESS_KEY).await);
|
||||
assert!(invalid.is_disabled());
|
||||
|
||||
let on = expect_found(lookup(fake(true, || found(user("on"), true)), ACCESS_KEY).await);
|
||||
assert!(!on.is_disabled());
|
||||
}
|
||||
|
||||
/// Positive — an STS credential carries its token and its lifetime as one session binding.
|
||||
#[tokio::test]
|
||||
async fn credentials_sts_carries_session_binding() {
|
||||
let expires_at = 4_000_000_000_i64;
|
||||
let iam = fake(true, move || {
|
||||
let mut cred = user("on");
|
||||
cred.session_token = "sts-session-token".to_owned();
|
||||
cred.parent_user = "parent".to_owned();
|
||||
cred.expiration = Some(OffsetDateTime::from_unix_timestamp(expires_at).expect("valid timestamp"));
|
||||
found(cred, true)
|
||||
});
|
||||
let credentials = expect_found(lookup(iam, ACCESS_KEY).await);
|
||||
let binding = credentials.session_binding().expect("an STS credential has a session");
|
||||
assert_eq!(binding.expires_at_unix_seconds(), expires_at);
|
||||
assert_eq!(binding.issuer(), STS_SESSION_ISSUER);
|
||||
let now = RequestNow::from_unix_seconds(expires_at - 1);
|
||||
assert!(credentials.admit(Some(b"sts-session-token"), now).is_ok());
|
||||
assert!(credentials.admit(None, now).is_err());
|
||||
}
|
||||
|
||||
/// Negative — an STS credential with no expiry cannot be expressed as a session the gateway
|
||||
/// would ever expire, so it is a backend fault rather than a session that never ends.
|
||||
#[tokio::test]
|
||||
async fn credentials_sts_without_expiry_is_backend() {
|
||||
let iam = fake(true, || {
|
||||
let mut cred = user("on");
|
||||
cred.session_token = "sts-session-token".to_owned();
|
||||
found(cred, true)
|
||||
});
|
||||
assert!(matches!(lookup(iam, ACCESS_KEY).await, Err(ProviderError::Backend)));
|
||||
}
|
||||
|
||||
/// Positive — a service account carries a token internally but authenticates as a long-term
|
||||
/// credential: clients never present that token.
|
||||
#[tokio::test]
|
||||
async fn credentials_service_account_is_long_term() {
|
||||
let iam = fake(true, || {
|
||||
let mut cred = user("on");
|
||||
cred.session_token = "service-account-claims-token".to_owned();
|
||||
cred.parent_user = "parent".to_owned();
|
||||
cred.claims = Some([(rustfs_credentials::IAM_POLICY_CLAIM_NAME_SA.to_owned(), "inherited-policy".into())].into());
|
||||
found(cred, true)
|
||||
});
|
||||
let credentials = expect_found(lookup(iam, ACCESS_KEY).await);
|
||||
assert!(credentials.session_binding().is_none());
|
||||
assert!(credentials.admit(None, RequestNow::from_unix_seconds(0)).is_ok());
|
||||
}
|
||||
|
||||
/// Negative — before IAM is ready a lookup is `Unavailable`, not `NotFound`: a key must not
|
||||
/// be negatively cached because the store had not loaded yet.
|
||||
#[tokio::test]
|
||||
async fn credentials_iam_not_ready_is_unavailable() {
|
||||
let iam = fake(false, || found(user("on"), true));
|
||||
assert!(matches!(lookup(iam.clone(), ACCESS_KEY).await, Err(ProviderError::Unavailable)));
|
||||
assert_eq!(iam.calls.load(Ordering::SeqCst), 0);
|
||||
|
||||
let uninitialized = fake(true, || Err(IamError::IamSysNotInitialized));
|
||||
assert!(matches!(lookup(uninitialized, ACCESS_KEY).await, Err(ProviderError::Unavailable)));
|
||||
}
|
||||
|
||||
/// Positive — the injected root credentials resolve without IAM, even before it is ready, and
|
||||
/// nothing else is answered with the root secret.
|
||||
#[tokio::test]
|
||||
async fn credentials_root_resolves_without_iam() {
|
||||
let iam = fake(false, || found(user("on"), true));
|
||||
let provider = IamCredentials::new(iam.clone(), root_cell());
|
||||
let root = expect_found(provider.lookup("rootadmin").await);
|
||||
assert_eq!(root.secret().expose(), b"root-secret-key");
|
||||
assert!(matches!(provider.lookup("ROOTADMIN").await, Err(ProviderError::Unavailable)));
|
||||
assert_eq!(iam.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
/// Negative — a lookup that calls an identity valid but holds an empty or short secret is not
|
||||
/// trusted: such a secret is not a credential, so the key is `NotFound`, never `Found`.
|
||||
#[tokio::test]
|
||||
async fn credentials_short_secret_is_never_found() {
|
||||
for weak in ["", "1234567", "\0\0\0\0\0\0\0\0", "abc\0\0\0\0\0"] {
|
||||
let iam = fake(true, move || {
|
||||
let mut cred = user("on");
|
||||
cred.secret_key = weak.to_owned();
|
||||
found(cred, true)
|
||||
});
|
||||
let result = lookup(iam, ACCESS_KEY).await;
|
||||
assert!(is_not_found(&result), "{weak:?} must be NotFound");
|
||||
}
|
||||
for usable in ["12345678", "abc\0defg"] {
|
||||
let iam = fake(true, move || {
|
||||
let mut cred = user("on");
|
||||
cred.secret_key = usable.to_owned();
|
||||
found(cred, true)
|
||||
});
|
||||
let credentials = expect_found(lookup(iam, ACCESS_KEY).await);
|
||||
assert_eq!(credentials.secret().expose(), usable.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative — a switched-off account is disabled even when the lookup calls it valid.
|
||||
#[tokio::test]
|
||||
async fn credentials_off_status_alone_disables() {
|
||||
let off = expect_found(lookup(fake(true, || found(user("off"), true)), ACCESS_KEY).await);
|
||||
assert!(off.is_disabled());
|
||||
assert!(off.admit(None, RequestNow::from_unix_seconds(0)).is_err());
|
||||
}
|
||||
|
||||
/// Negative — an STS credential admits only its own session's token: the token of another
|
||||
/// session, or none, is refused.
|
||||
#[tokio::test]
|
||||
async fn credentials_sts_refuses_another_sessions_token() {
|
||||
let expires_at = 4_000_000_000_i64;
|
||||
let iam = fake(true, move || {
|
||||
let mut cred = user("on");
|
||||
cred.session_token = "session-token-a".to_owned();
|
||||
cred.parent_user = "parent".to_owned();
|
||||
cred.expiration = Some(OffsetDateTime::from_unix_timestamp(expires_at).expect("valid timestamp"));
|
||||
found(cred, true)
|
||||
});
|
||||
let credentials = expect_found(lookup(iam, ACCESS_KEY).await);
|
||||
let now = RequestNow::from_unix_seconds(expires_at - 1);
|
||||
assert!(credentials.admit(Some(b"session-token-b"), now).is_err());
|
||||
assert!(credentials.admit(Some(b"session-token-a"), now).is_ok());
|
||||
}
|
||||
|
||||
/// Negative — without root credentials no key can be classified, so every lookup is
|
||||
/// `Unavailable` and IAM is not asked.
|
||||
#[tokio::test]
|
||||
async fn credentials_without_root_are_unavailable() {
|
||||
let iam = fake(true, || found(user("on"), true));
|
||||
let provider = IamCredentials::new(iam.clone(), Arc::new(RootCell::new(None)));
|
||||
assert!(matches!(provider.lookup(ACCESS_KEY).await, Err(ProviderError::Unavailable)));
|
||||
assert!(matches!(provider.lookup("rootadmin").await, Err(ProviderError::Unavailable)));
|
||||
assert_eq!(iam.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! The gateway's [`HostResolver`], built from RustFS's configured domain list.
|
||||
//!
|
||||
//! Responsible for: turning the `RUSTFS_SERVER_DOMAINS` list into the gateway's legacy RustFS
|
||||
//! virtual-host resolver.
|
||||
//! NOT responsible for: parsing a `Host` header, matching a domain, validating bucket names (all
|
||||
//! done by [`LegacyRustfsVirtualHosts`]), reading the environment, or deciding that a console
|
||||
//! deployment serves no domains (the caller passes an empty list then, as `s3_host_domains` in
|
||||
//! `rustfs/src/server/http.rs` returns `None`). An empty list needs no branch here:
|
||||
//! [`LegacyRustfsVirtualHosts`] built from no domain reads every host path-style, exactly as the
|
||||
//! gateway's `PathStyleOnly` does.
|
||||
//! Upstream: the RustFS configuration (`Config::server_domains`). Downstream: the gateway's
|
||||
//! `ServiceBuilder`, which holds the resolver as `Arc<dyn HostResolver>`.
|
||||
//!
|
||||
//! # What "the same as `MultiDomain`" means
|
||||
//!
|
||||
//! RustFS today hands the same list to the s3s framework's multi-domain host, which ignores ports
|
||||
//! on both sides when matching a domain, takes the whole prefix in front of a matched domain as
|
||||
//! the bucket, compares a domain exactly (so a host is matched on the case it arrives in), and,
|
||||
//! for a host no domain matches, falls back to CNAME-style: a host that is itself a bucket name
|
||||
//! (lower-cased) is that bucket. [`LegacyRustfsVirtualHosts`] reproduces exactly that.
|
||||
//!
|
||||
//! What reads **path-style**: an empty domain list; a configured domain itself, with or without a
|
||||
//! port; an IP address, or a socket address (`10.0.0.1`, `[::1]:9000`); a host that is a valid
|
||||
//! domain but, lower-cased, is no bucket name (it carries a port, is over 63 bytes, or starts
|
||||
//! with `xn--`). What reads as a **bucket**: anything under a configured domain, and any other
|
||||
//! bucket-shaped host, which is a bucket of its own (`localhost`, `badexample.com`, `EXAMPLE.COM`).
|
||||
//! What is **refused** before routing: a host that is no domain (`under_score.org`, a bare
|
||||
//! `[::1]`, a trailing dot), and a bucket under a domain that the bucket rules refuse
|
||||
//! (`PHOTOS.example.com`, `ab.example.com`). The tests below pin each of those readings.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustfs_gateway::{HostResolver, LegacyDomainError, LegacyRustfsVirtualHosts};
|
||||
|
||||
/// Builds the host resolver for RustFS's configured domain list.
|
||||
///
|
||||
/// An empty list reads every host path-style. Otherwise the list is read as legacy RustFS reads
|
||||
/// it: a later entry whose name without its port repeats an earlier one is dropped, and a list
|
||||
/// that legacy RustFS refuses at start-up is refused here.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`LegacyDomainError`] for an entry that is not a domain, or for two entries where one equals or
|
||||
/// is a subdomain of the other.
|
||||
pub fn rustfs_host_resolver<I, S>(domains: I) -> Result<Arc<dyn HostResolver>, LegacyDomainError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
Ok(Arc::new(LegacyRustfsVirtualHosts::new(domains)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http::Method;
|
||||
use rustfs_gateway::{Addressing, HostQuery, HostRefusal, TargetKind};
|
||||
|
||||
/// What the resolver reads `GET path` with `Host: host` as: the host-named bucket (`None` for
|
||||
/// path style), the routed target, and the refusal, if any.
|
||||
fn read(resolver: &dyn HostResolver, path: &str, host: &str) -> (Option<String>, TargetKind, Option<HostRefusal>) {
|
||||
let method = Method::GET;
|
||||
let request = http::Request::builder()
|
||||
.method(method.clone())
|
||||
.uri(path)
|
||||
.header("host", host)
|
||||
.body(())
|
||||
.expect("a valid request");
|
||||
let effective = rustfs_gateway_http::effective_host(&request).expect("an acceptable host");
|
||||
let query = HostQuery {
|
||||
host: &effective,
|
||||
path,
|
||||
method: &method,
|
||||
};
|
||||
let resolved = resolver.resolve(&query);
|
||||
let bucket = match &resolved.addressing {
|
||||
Addressing::VirtualHosted { bucket, .. } => Some(bucket.as_str().to_owned()),
|
||||
Addressing::Path => None,
|
||||
};
|
||||
(bucket, resolved.target, resolver.refusal(&query))
|
||||
}
|
||||
|
||||
fn with_domains(domains: &[&str]) -> Arc<dyn HostResolver> {
|
||||
rustfs_host_resolver(domains).expect("a list RustFS accepts")
|
||||
}
|
||||
|
||||
fn bucket(domains: &[&str], host: &str) -> Option<String> {
|
||||
read(&*with_domains(domains), "/key", host).0
|
||||
}
|
||||
|
||||
/// Positive: a host under a configured domain names the bucket in front of it.
|
||||
#[test]
|
||||
fn host_under_domain_names_the_bucket() {
|
||||
assert_eq!(
|
||||
read(&*with_domains(&["example.com"]), "/key", "photos.example.com"),
|
||||
(Some("photos".to_owned()), TargetKind::Object, None)
|
||||
);
|
||||
assert_eq!(read(&*with_domains(&["example.com"]), "/", "photos.example.com").1, TargetKind::Bucket);
|
||||
}
|
||||
|
||||
/// Positive: the port on the host, or on the configured domain, does not take part in matching.
|
||||
#[test]
|
||||
fn ports_are_ignored_on_both_sides() {
|
||||
assert_eq!(bucket(&["example.com"], "photos.example.com:9000").as_deref(), Some("photos"));
|
||||
assert_eq!(bucket(&["example.com:9000"], "photos.example.com").as_deref(), Some("photos"));
|
||||
assert_eq!(bucket(&["example.com:9000"], "photos.example.com:1234").as_deref(), Some("photos"));
|
||||
}
|
||||
|
||||
/// Positive: the whole prefix in front of the domain is the bucket, dots kept.
|
||||
#[test]
|
||||
fn whole_prefix_is_the_bucket() {
|
||||
assert_eq!(bucket(&["example.com"], "my.dotted.bkt.example.com").as_deref(), Some("my.dotted.bkt"));
|
||||
}
|
||||
|
||||
/// Positive: with several domains, each one resolves its own subdomains.
|
||||
#[test]
|
||||
fn every_configured_domain_resolves() {
|
||||
let domains = ["example.com", "s3.local"];
|
||||
assert_eq!(bucket(&domains, "abc.example.com").as_deref(), Some("abc"));
|
||||
assert_eq!(bucket(&domains, "xyz.s3.local").as_deref(), Some("xyz"));
|
||||
}
|
||||
|
||||
/// Positive: the domain itself is the endpoint, so the bucket comes from the path.
|
||||
#[test]
|
||||
fn the_domain_itself_is_path_style() {
|
||||
assert_eq!(bucket(&["example.com"], "example.com"), None);
|
||||
assert_eq!(bucket(&["example.com"], "example.com:9000"), None);
|
||||
}
|
||||
|
||||
/// Negative: no configured domain means path style only, for every host shape.
|
||||
#[test]
|
||||
fn no_domain_is_path_style_only() {
|
||||
for host in ["photos.example.com", "localhost", "localhost:9000", "10.0.0.1"] {
|
||||
assert_eq!(bucket(&[], host), None, "{host}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative: an address, and a host carrying a port that no domain matches, are path style.
|
||||
#[test]
|
||||
fn addresses_and_unmatched_ports_are_path_style() {
|
||||
for host in ["10.0.0.1", "10.0.0.1:9000", "localhost:9000", "other.org:9000"] {
|
||||
assert_eq!(bucket(&["example.com"], host), None, "{host}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative: a host that only ends in the same letters as a domain is not under it. It is not
|
||||
/// stripped to `bad`; the only reading it gets is the CNAME-style one, the whole host.
|
||||
#[test]
|
||||
fn suffix_lookalike_is_not_under_the_domain() {
|
||||
assert_eq!(bucket(&["example.com"], "badexample.com").as_deref(), Some("badexample.com"));
|
||||
assert_eq!(bucket(&["example.com"], "example.com.evil.org").as_deref(), Some("example.com.evil.org"));
|
||||
}
|
||||
|
||||
/// Negative: matching a configured domain is exact on case, as the legacy multi-domain host does it, so a
|
||||
/// mixed-case host is not stripped to its prefix; it is the lower-cased whole host.
|
||||
#[test]
|
||||
fn mixed_case_host_is_not_stripped_to_a_prefix() {
|
||||
assert_eq!(bucket(&["example.com"], "Photos.Example.COM").as_deref(), Some("photos.example.com"));
|
||||
}
|
||||
|
||||
/// Negative: a host that is no domain at all is refused, not routed.
|
||||
#[test]
|
||||
fn a_host_that_is_no_domain_is_refused() {
|
||||
let resolver = with_domains(&["example.com"]);
|
||||
assert_eq!(read(&*resolver, "/k", "under_score.org").2, Some(HostRefusal::UnusableHost));
|
||||
}
|
||||
|
||||
/// Negative: a bucket the host names that bucket rules refuse is refused.
|
||||
#[test]
|
||||
fn a_host_named_bucket_the_rules_refuse_is_refused() {
|
||||
let resolver = with_domains(&["example.com"]);
|
||||
assert_eq!(read(&*resolver, "/k", "ab.example.com").2, Some(HostRefusal::RefusedBucket));
|
||||
assert_eq!(read(&*resolver, "/k", "bad_host.example.com").2, Some(HostRefusal::RefusedBucket));
|
||||
}
|
||||
|
||||
/// Negative: a list legacy RustFS refuses at start-up is refused.
|
||||
#[test]
|
||||
fn domain_lists_legacy_rustfs_refuses_are_refused() {
|
||||
for list in [
|
||||
vec!["example.com", "a.example.com"],
|
||||
vec!["example.com", "com"],
|
||||
vec!["bad_domain"],
|
||||
vec![""],
|
||||
vec!["example.com:notaport"],
|
||||
] {
|
||||
assert!(rustfs_host_resolver(&list).is_err(), "{list:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Positive: a later repeat of a name, whatever its port, is dropped rather than refused, as
|
||||
/// `s3_host_domains` drops it before the legacy multi-domain host sees the list.
|
||||
#[test]
|
||||
fn a_repeat_of_a_domain_is_dropped() {
|
||||
assert_eq!(
|
||||
bucket(&["example.com:9411", "example.com:9000"], "xyz.example.com").as_deref(),
|
||||
Some("xyz")
|
||||
);
|
||||
}
|
||||
|
||||
/// The refusal alone, for hosts whose bucket reading is not the point.
|
||||
fn refusal(domains: &[&str], host: &str) -> Option<HostRefusal> {
|
||||
read(&*with_domains(domains), "/k", host).2
|
||||
}
|
||||
|
||||
/// Positive: a bucket-shaped host no domain matches is a bucket of its own, `localhost` and
|
||||
/// an upper-case spelling of the domain among them.
|
||||
#[test]
|
||||
fn bucket_shaped_hosts_outside_every_domain_are_their_own_bucket() {
|
||||
assert_eq!(bucket(&["example.com"], "localhost").as_deref(), Some("localhost"));
|
||||
assert_eq!(bucket(&["example.com"], "EXAMPLE.COM").as_deref(), Some("example.com"));
|
||||
assert_eq!(bucket(&["example.com"], "cdn.other.org").as_deref(), Some("cdn.other.org"));
|
||||
}
|
||||
|
||||
/// Negative: a bucket the rules refuse under a domain is refused, and reads no bucket.
|
||||
#[test]
|
||||
fn an_upper_case_bucket_under_the_domain_is_refused() {
|
||||
assert_eq!(bucket(&["example.com"], "PHOTOS.example.com"), None);
|
||||
assert_eq!(refusal(&["example.com"], "PHOTOS.example.com"), Some(HostRefusal::RefusedBucket));
|
||||
}
|
||||
|
||||
/// Negative: an IPv6 literal. The bare form is no domain and is refused; with a port it is a
|
||||
/// socket address and reads path-style.
|
||||
#[test]
|
||||
fn ipv6_literals_follow_the_address_rules() {
|
||||
assert_eq!(refusal(&["example.com"], "[::1]"), Some(HostRefusal::UnusableHost));
|
||||
assert_eq!(bucket(&["example.com"], "[::1]:9000"), None);
|
||||
assert_eq!(refusal(&["example.com"], "[::1]:9000"), None);
|
||||
}
|
||||
|
||||
/// Negative: a trailing root dot makes the host no domain, so it is refused, not stripped.
|
||||
#[test]
|
||||
fn a_trailing_dot_is_refused() {
|
||||
assert_eq!(refusal(&["example.com"], "photos.example.com."), Some(HostRefusal::UnusableHost));
|
||||
assert_eq!(bucket(&["example.com"], "photos.example.com."), None);
|
||||
}
|
||||
|
||||
/// Negative: an empty label never reaches the resolver; the gateway's wire layer refuses the
|
||||
/// host first (legacy answers 400 as well).
|
||||
#[test]
|
||||
fn an_empty_label_is_refused_before_resolution() {
|
||||
for host in ["a..b.example.com", ".example.com"] {
|
||||
let request = http::Request::builder()
|
||||
.uri("/k")
|
||||
.header("host", host)
|
||||
.body(())
|
||||
.expect("a valid request");
|
||||
assert!(rustfs_gateway_http::effective_host(&request).is_err(), "{host}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative: a punycode label is no bucket under a domain; outside every domain the host is
|
||||
/// a valid domain that is no bucket name, so it reads path-style.
|
||||
#[test]
|
||||
fn a_punycode_label_is_never_a_bucket() {
|
||||
assert_eq!(refusal(&["example.com"], "xn--bcher-kva.example.com"), Some(HostRefusal::RefusedBucket));
|
||||
assert_eq!(bucket(&["example.com"], "xn--bcher-kva.de"), None);
|
||||
assert_eq!(refusal(&["example.com"], "xn--bcher-kva.de"), None);
|
||||
}
|
||||
|
||||
/// Negative: a label over 63 bytes is no bucket, under a domain or outside one.
|
||||
#[test]
|
||||
fn a_label_over_63_bytes_is_never_a_bucket() {
|
||||
let long = "a".repeat(64);
|
||||
let under = format!("{long}.example.com");
|
||||
assert_eq!(refusal(&["example.com"], &under), Some(HostRefusal::RefusedBucket));
|
||||
let outside = format!("{long}.org");
|
||||
assert_eq!(bucket(&["example.com"], &outside), None);
|
||||
assert_eq!(refusal(&["example.com"], &outside), None);
|
||||
let at_limit = "a".repeat(63);
|
||||
assert_eq!(bucket(&["example.com"], &format!("{at_limit}.example.com")), Some(at_limit));
|
||||
}
|
||||
|
||||
/// Positive and negative: two configured domains where one merely ends in the other's letters
|
||||
/// both work, and each domain itself is path-style.
|
||||
#[test]
|
||||
fn a_domain_and_its_suffix_lookalike_are_both_served() {
|
||||
let domains = ["example.com", "badexample.com"];
|
||||
assert_eq!(bucket(&domains, "photos.example.com").as_deref(), Some("photos"));
|
||||
assert_eq!(bucket(&domains, "photos.badexample.com").as_deref(), Some("photos"));
|
||||
assert_eq!(bucket(&domains, "badexample.com"), None);
|
||||
assert_eq!(bucket(&domains, "bad.example.com").as_deref(), Some("bad"));
|
||||
}
|
||||
|
||||
/// Negative: a configured domain is matched exactly, case included.
|
||||
#[test]
|
||||
fn a_configured_domain_is_matched_on_its_case() {
|
||||
assert_eq!(bucket(&["Example.COM"], "photos.Example.COM").as_deref(), Some("photos"));
|
||||
assert_eq!(bucket(&["Example.COM"], "photos.example.com").as_deref(), Some("photos.example.com"));
|
||||
assert_eq!(bucket(&["Example.COM"], "Example.COM"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! RustFS implementations of the `rustfs-gateway` extension traits.
|
||||
//!
|
||||
//! This is the gateway's ring 2: it may depend on RustFS business crates, and nothing in the
|
||||
//! gateway depends on it. Each module adapts one extension point to the RustFS subsystem that
|
||||
//! owns the answer.
|
||||
|
||||
pub mod bucket_owner;
|
||||
pub mod credentials;
|
||||
pub mod host;
|
||||
pub mod policy;
|
||||
pub mod root;
|
||||
pub mod trace;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustfs_gateway::{Authenticator, CredentialProvider, ServiceBuilder};
|
||||
|
||||
pub use bucket_owner::{BucketLookup, OwnerLookupError, RustfsBucketOwners};
|
||||
pub use credentials::{IamCredentials, IamKeyLookup, STS_SESSION_ISSUER};
|
||||
pub use host::rustfs_host_resolver;
|
||||
pub use policy::{IamPolicySource, IamPrincipalLookup, PrincipalKind, RustfsPrincipal};
|
||||
pub use root::{MIN_SECRET_KEY_LEN, RootCredentials, RootCredentialsError, RootSource};
|
||||
pub use trace::{AttachOutcome, RequestIdCarrier, attach_host_request_id, host_request_id};
|
||||
|
||||
/// The IAM-backed credential provider and policy source, assembled together.
|
||||
///
|
||||
/// Both halves read the same IAM handle and the same live [`RootSource`], so a key cannot be root
|
||||
/// to one and an ordinary user to the other. Neither half can be taken out on its own: the fields
|
||||
/// are private, and [`IamAdapters::install`] consumes the pair and hands both to one builder.
|
||||
///
|
||||
/// Mixing the halves of two assemblies does not compile:
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// fn mix(a: rustfs_gateway_adapter::IamAdapters, b: rustfs_gateway_adapter::IamAdapters) {
|
||||
/// let _mixed = (a.credentials, b.policy);
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Nor does building a half outside the pair:
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// use std::sync::Arc;
|
||||
/// use rustfs_gateway_adapter::{IamPolicySource, IamPrincipalLookup, RootSource};
|
||||
/// fn build(iam: Arc<dyn IamPrincipalLookup>, root: Arc<dyn RootSource>) {
|
||||
/// let _alone = IamPolicySource::new(iam, root);
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Installing the pair is the one supported path:
|
||||
///
|
||||
/// ```no_run
|
||||
/// use std::sync::Arc;
|
||||
/// use rustfs_gateway::{RegionSet, ServiceBuilder, SigV4Authenticator};
|
||||
/// use rustfs_gateway_adapter::{IamAdapters, IamPrincipalLookup, RootSource};
|
||||
/// fn assemble(iam: Arc<dyn IamPrincipalLookup>, root: Arc<dyn RootSource>, regions: RegionSet) -> ServiceBuilder {
|
||||
/// IamAdapters::new(iam, root).install(ServiceBuilder::new(), |provider| SigV4Authenticator::new(provider, regions))
|
||||
/// }
|
||||
/// ```
|
||||
pub struct IamAdapters {
|
||||
credentials: IamCredentials,
|
||||
policy: IamPolicySource,
|
||||
}
|
||||
|
||||
impl IamAdapters {
|
||||
/// Assembles both adapters over one IAM handle and one root source. The binary passes a
|
||||
/// source that reads its live root credentials on every call (see [`RootSource`]).
|
||||
#[must_use]
|
||||
pub fn new(iam: Arc<dyn IamPrincipalLookup>, root: Arc<dyn RootSource>) -> Self {
|
||||
Self {
|
||||
credentials: IamCredentials::new(iam.clone(), root.clone()),
|
||||
policy: IamPolicySource::new(iam, root),
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs both halves into `builder`: the credential provider through the authenticator
|
||||
/// `authenticator` builds over it (for example `SigV4Authenticator::new(provider, regions)`
|
||||
/// with the RustFS profile applied), and the policy source.
|
||||
///
|
||||
/// Consumes the pair and gives both halves to this one builder. The `authenticator` closure
|
||||
/// receives the provider handle and may keep a clone of it; nothing here prevents that, so
|
||||
/// the closure must not hand it to a second builder.
|
||||
#[must_use]
|
||||
pub fn install<A: Authenticator>(
|
||||
self,
|
||||
builder: ServiceBuilder,
|
||||
authenticator: impl FnOnce(Arc<dyn CredentialProvider>) -> A,
|
||||
) -> ServiceBuilder {
|
||||
let authenticator = authenticator(Arc::new(self.credentials));
|
||||
builder.authenticator(authenticator).policy_source(self.policy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! The gateway's [`PolicySource`], answered by RustFS IAM.
|
||||
//!
|
||||
//! Responsible for: the one reading of the caller's IAM facts a request is judged against — which
|
||||
//! principal kind it is, the user it acts for, its groups, its token claims, and whether it owns
|
||||
//! the deployment — packed as a [`RustfsPrincipal`] into the gateway's [`PolicySnapshot`].
|
||||
//! NOT responsible for: evaluating any policy, choosing which policy documents apply (that stays
|
||||
//! in `IamSys::is_allowed`, which owns the STS, OIDC, session-policy and service-account rules), or
|
||||
//! verifying the signature or the session token (the gateway did both before calling this).
|
||||
//! Upstream: the gateway's verified [`Identity`]. Downstream: the RustFS authorizer, which reads
|
||||
//! the payload back with `PolicySnapshot::get::<RustfsPrincipal>()`.
|
||||
//!
|
||||
//! # Failure classes
|
||||
//!
|
||||
//! Every refusal is a [`PolicyError`] with a constant label and nothing from IAM or the request:
|
||||
//! IAM not ready, not initialized, or failing, or root credentials not initialized, is
|
||||
//! [`PolicyError::unavailable`]; a principal IAM no longer knows is [`UNKNOWN_PRINCIPAL`]; a
|
||||
//! disabled one, or one whose secret fails the effective-length rule, is
|
||||
//! [`DISABLED_PRINCIPAL`]; an identity
|
||||
//! whose credential kind disagrees with IAM's record is [`SESSION_MISMATCH`]; a token whose claims
|
||||
//! cannot be read is [`PolicyError::malformed`]. None of them is ever an empty snapshot: an empty
|
||||
//! snapshot is what an anonymous caller gets, and answering a fault with it would judge an
|
||||
//! authenticated caller as anonymous.
|
||||
//!
|
||||
//! # Secret handling
|
||||
//!
|
||||
//! [`RustfsPrincipal`] holds no secret key and no session token, and implements no `Debug`, so a
|
||||
//! snapshot formatted into a log cannot print its claims either. The root credentials and the STS
|
||||
//! token-signing key are read per request from the shared [`RootSource`] and never stored here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustfs_credentials::Credentials as IamCredential;
|
||||
use rustfs_gateway::{BoxFuture, Identity, PolicyError, PolicySnapshot, PolicySource};
|
||||
use rustfs_iam::error::Error as IamError;
|
||||
use rustfs_iam::store::Store;
|
||||
use rustfs_iam::sys::{
|
||||
IamSys, SESSION_POLICY_NAME, get_claims_from_token_with_secret, get_claims_from_token_with_secret_allow_missing_exp,
|
||||
is_rustfs_oidc_claims,
|
||||
};
|
||||
use rustfs_policy::auth::ACCOUNT_OFF;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::credentials::IamKeyLookup;
|
||||
use crate::root::{RootCredentials, RootSource, is_usable_secret};
|
||||
|
||||
/// [`PolicyError`] label: IAM does not know the authenticated principal (any longer).
|
||||
pub const UNKNOWN_PRINCIPAL: &str = "unknown-principal";
|
||||
/// [`PolicyError`] label: IAM holds the principal disabled, expired, or otherwise invalid.
|
||||
pub const DISABLED_PRINCIPAL: &str = "disabled-principal";
|
||||
/// [`PolicyError`] label: the identity's credential kind (session or long-term) disagrees with
|
||||
/// what IAM holds for its access key.
|
||||
pub const SESSION_MISMATCH: &str = "session-mismatch";
|
||||
|
||||
/// The narrow slice of IAM a policy reading needs, on top of [`IamKeyLookup`].
|
||||
///
|
||||
/// Implemented for [`IamSys`]; exists so the source can be tested against a fake.
|
||||
pub trait IamPrincipalLookup: IamKeyLookup {
|
||||
/// The built-in IAM groups `user` belongs to now, as `IamSys::user_group_memberships`
|
||||
/// answers. These may include groups granted after a credential was issued.
|
||||
fn group_memberships(&self, user: &str) -> Vec<String>;
|
||||
}
|
||||
|
||||
impl<T: Store> IamPrincipalLookup for IamSys<T> {
|
||||
fn group_memberships(&self, user: &str) -> Vec<String> {
|
||||
IamSys::user_group_memberships(self, user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which kind of credential the principal authenticated with.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PrincipalKind {
|
||||
/// The deployment's root credentials.
|
||||
Root,
|
||||
/// A long-term IAM user.
|
||||
User,
|
||||
/// A service account, acting for its parent user.
|
||||
ServiceAccount,
|
||||
/// An STS temporary credential, acting for its parent user.
|
||||
Temporary,
|
||||
}
|
||||
|
||||
/// The caller's IAM facts: the payload of every [`PolicySnapshot`] [`IamPolicySource`] produces.
|
||||
///
|
||||
/// Carries no secret key and no session token, and deliberately implements no `Debug`.
|
||||
pub struct RustfsPrincipal {
|
||||
kind: PrincipalKind,
|
||||
account: String,
|
||||
username: String,
|
||||
parent_user: String,
|
||||
credential_groups: Option<Vec<String>>,
|
||||
group_memberships: Vec<String>,
|
||||
claims: Option<HashMap<String, Value>>,
|
||||
is_owner: bool,
|
||||
}
|
||||
|
||||
impl RustfsPrincipal {
|
||||
/// The kind of credential the caller authenticated with.
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> PrincipalKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// The access key the caller signed with (IAM `Args::account`).
|
||||
#[must_use]
|
||||
pub fn account(&self) -> &str {
|
||||
&self.account
|
||||
}
|
||||
|
||||
/// The user the caller acts as: the parent user for an STS credential or a service account,
|
||||
/// the access key otherwise.
|
||||
#[must_use]
|
||||
pub fn username(&self) -> &str {
|
||||
&self.username
|
||||
}
|
||||
|
||||
/// The parent user IAM records for the credential; empty for root and long-term users.
|
||||
#[must_use]
|
||||
pub fn parent_user(&self) -> &str {
|
||||
&self.parent_user
|
||||
}
|
||||
|
||||
/// The groups recorded on the credential itself (IAM `Args::groups`).
|
||||
#[must_use]
|
||||
pub fn credential_groups(&self) -> &Option<Vec<String>> {
|
||||
&self.credential_groups
|
||||
}
|
||||
|
||||
/// The groups IAM currently lists for [`Self::username`], read once with the snapshot.
|
||||
#[must_use]
|
||||
pub fn group_memberships(&self) -> &[String] {
|
||||
&self.group_memberships
|
||||
}
|
||||
|
||||
/// The claims the credential's token carries; `None` when it has none.
|
||||
#[must_use]
|
||||
pub fn claims(&self) -> Option<&HashMap<String, Value>> {
|
||||
self.claims.as_ref()
|
||||
}
|
||||
|
||||
/// Whether the caller owns the deployment (IAM `Args::is_owner`): root, or a credential of
|
||||
/// root that no session policy or external OIDC identity narrows.
|
||||
#[must_use]
|
||||
pub fn is_owner(&self) -> bool {
|
||||
self.is_owner
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`PolicySource`] over RustFS IAM and the live root credentials.
|
||||
///
|
||||
/// Built only by [`crate::IamAdapters::new`], so it always reads the same [`RootSource`] as the
|
||||
/// credential provider it was assembled with.
|
||||
pub struct IamPolicySource {
|
||||
iam: Arc<dyn IamPrincipalLookup>,
|
||||
root: Arc<dyn RootSource>,
|
||||
}
|
||||
|
||||
impl IamPolicySource {
|
||||
pub(crate) fn new(iam: Arc<dyn IamPrincipalLookup>, root: Arc<dyn RootSource>) -> Self {
|
||||
Self { iam, root }
|
||||
}
|
||||
|
||||
async fn read(&self, identity: Option<&Identity>) -> Result<PolicySnapshot, PolicyError> {
|
||||
let Some(identity) = identity else {
|
||||
return Ok(PolicySnapshot::empty());
|
||||
};
|
||||
let principal = self.principal(identity).await?;
|
||||
Ok(PolicySnapshot::of(Arc::new(principal)))
|
||||
}
|
||||
|
||||
async fn principal(&self, identity: &Identity) -> Result<RustfsPrincipal, PolicyError> {
|
||||
let access_key = identity.access_key_id();
|
||||
let has_session = identity.session().is_some();
|
||||
// Without root credentials neither ownership nor STS claims can be decided.
|
||||
let Some(root) = self.root.current() else {
|
||||
return Err(PolicyError::unavailable());
|
||||
};
|
||||
let root_access_key = root.access_key();
|
||||
|
||||
if access_key == root_access_key {
|
||||
// Root has no session token, so a session identity for its key is not one IAM issued.
|
||||
if has_session {
|
||||
return Err(PolicyError::because(SESSION_MISMATCH));
|
||||
}
|
||||
return Ok(RustfsPrincipal {
|
||||
kind: PrincipalKind::Root,
|
||||
account: access_key.to_owned(),
|
||||
username: access_key.to_owned(),
|
||||
parent_user: String::new(),
|
||||
credential_groups: None,
|
||||
group_memberships: Vec::new(),
|
||||
claims: None,
|
||||
is_owner: true,
|
||||
});
|
||||
}
|
||||
|
||||
if !self.iam.is_ready() {
|
||||
return Err(PolicyError::unavailable());
|
||||
}
|
||||
let cred = match self.iam.check_key(access_key).await {
|
||||
Ok((Some(identity), true)) => identity.credentials,
|
||||
Ok((Some(_), false)) => return Err(PolicyError::because(DISABLED_PRINCIPAL)),
|
||||
Ok((None, _)) => return Err(PolicyError::because(UNKNOWN_PRINCIPAL)),
|
||||
Err(error) => return Err(classify_iam_error(&error)),
|
||||
};
|
||||
if cred.status == ACCOUNT_OFF || !is_usable_secret(cred.secret_key.as_bytes()) {
|
||||
return Err(PolicyError::because(DISABLED_PRINCIPAL));
|
||||
}
|
||||
|
||||
let kind = if cred.is_service_account() {
|
||||
PrincipalKind::ServiceAccount
|
||||
} else if !cred.session_token.is_empty() {
|
||||
PrincipalKind::Temporary
|
||||
} else {
|
||||
PrincipalKind::User
|
||||
};
|
||||
// The gateway admitted this identity against the credential IAM returned, so the kinds
|
||||
// agree unless IAM's record changed in between; refuse rather than judge a stale reading.
|
||||
if has_session != (kind == PrincipalKind::Temporary) {
|
||||
return Err(PolicyError::because(SESSION_MISMATCH));
|
||||
}
|
||||
|
||||
let claims = read_claims(kind, &cred, &root)?;
|
||||
let username = match kind {
|
||||
PrincipalKind::Temporary | PrincipalKind::ServiceAccount => cred.parent_user.clone(),
|
||||
PrincipalKind::Root | PrincipalKind::User => cred.access_key.clone(),
|
||||
};
|
||||
let membership_subject = if username.is_empty() { access_key } else { username.as_str() };
|
||||
let group_memberships = self.iam.group_memberships(membership_subject);
|
||||
let is_owner = acts_for_root(&cred, claims.as_ref(), root_access_key);
|
||||
|
||||
Ok(RustfsPrincipal {
|
||||
kind,
|
||||
account: access_key.to_owned(),
|
||||
username,
|
||||
parent_user: cred.parent_user,
|
||||
credential_groups: cred.groups,
|
||||
group_memberships,
|
||||
claims,
|
||||
is_owner,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The claims of the credential's own token. The gateway admitted the presented token only when it
|
||||
/// equals the stored one, so reading the stored token reads the presented one.
|
||||
fn read_claims(
|
||||
kind: PrincipalKind,
|
||||
cred: &IamCredential,
|
||||
root: &RootCredentials,
|
||||
) -> Result<Option<HashMap<String, Value>>, PolicyError> {
|
||||
// As in legacy `check_claims_from_token_with_context`: no token, no claims.
|
||||
if cred.session_token.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let claims = match kind {
|
||||
PrincipalKind::ServiceAccount => {
|
||||
get_claims_from_token_with_secret_allow_missing_exp(&cred.session_token, &cred.secret_key)
|
||||
.map_err(|_| PolicyError::malformed())?
|
||||
}
|
||||
PrincipalKind::Temporary => get_claims_from_token_with_secret(&cred.session_token, root.sts_signing_key())
|
||||
.map_err(|_| PolicyError::malformed())?,
|
||||
PrincipalKind::Root | PrincipalKind::User => return Ok(None),
|
||||
};
|
||||
Ok(if claims.is_empty() { None } else { Some(claims) })
|
||||
}
|
||||
|
||||
/// Classifies an IAM failure without keeping any of its text.
|
||||
fn classify_iam_error(error: &IamError) -> PolicyError {
|
||||
match error {
|
||||
IamError::NoSuchUser(_)
|
||||
| IamError::NoSuchServiceAccount(_)
|
||||
| IamError::NoSuchTempAccount(_)
|
||||
| IamError::NoSuchAccount(_) => PolicyError::because(UNKNOWN_PRINCIPAL),
|
||||
_ => PolicyError::unavailable(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Root ownership as the legacy edge decides it (`rustfs/src/auth.rs` `has_root_access`): the
|
||||
/// credential is root's or acts for root, and neither a session policy nor an external OIDC
|
||||
/// identity narrows it.
|
||||
fn acts_for_root(cred: &IamCredential, claims: Option<&HashMap<String, Value>>, root_access_key: &str) -> bool {
|
||||
(cred.access_key == root_access_key || cred.parent_user == root_access_key)
|
||||
&& !claims.is_some_and(|claims| claims.contains_key(SESSION_POLICY_NAME) || is_rustfs_oidc_claims(claims))
|
||||
}
|
||||
|
||||
impl PolicySource for IamPolicySource {
|
||||
fn snapshot<'a>(&'a self, identity: Option<&'a Identity>) -> BoxFuture<'a, Result<PolicySnapshot, PolicyError>> {
|
||||
Box::pin(self.read(identity))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::root::RootCell;
|
||||
use rustfs_credentials::{Credentials as IamCredential, IAM_POLICY_CLAIM_NAME_SA};
|
||||
use rustfs_gateway::SessionBinding;
|
||||
use rustfs_iam::error::Error as IamError;
|
||||
use rustfs_iam::sys::SESSION_POLICY_NAME;
|
||||
use rustfs_iam::utils::generate_jwt;
|
||||
use rustfs_policy::auth::UserIdentity;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
const ROOT_ACCESS_KEY: &str = "rootadmin";
|
||||
const ROOT_SECRET: &str = "root-secret-key-0001";
|
||||
/// Deliberately not the root secret, so every STS test proves which key claims are read with.
|
||||
const SIGNING_KEY: &str = "sts-signing-key-0001";
|
||||
const ACCESS_KEY: &str = "AKIDUSER0001";
|
||||
const SECRET: &str = "user-secret-key-0001";
|
||||
const PARENT: &str = "alice";
|
||||
const EXPIRES_AT: i64 = 4_000_000_000;
|
||||
|
||||
type KeyAnswer = Result<(Option<UserIdentity>, bool), IamError>;
|
||||
type Answer = Box<dyn Fn() -> KeyAnswer + Send + Sync>;
|
||||
|
||||
struct FakeIam {
|
||||
ready: bool,
|
||||
answer: Answer,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl IamKeyLookup for FakeIam {
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn check_key<'a>(&'a self, _access_key: &'a str) -> BoxFuture<'a, KeyAnswer> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
let answer = (self.answer)();
|
||||
Box::pin(async move { answer })
|
||||
}
|
||||
}
|
||||
|
||||
impl IamPrincipalLookup for FakeIam {
|
||||
/// Distinct groups per name, so a test can tell which name the source asked about.
|
||||
fn group_memberships(&self, user: &str) -> Vec<String> {
|
||||
match user {
|
||||
PARENT => vec!["parent-group".to_owned()],
|
||||
ACCESS_KEY => vec!["key-group".to_owned()],
|
||||
ROOT_ACCESS_KEY => vec!["root-group".to_owned()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fake(ready: bool, answer: impl Fn() -> KeyAnswer + Send + Sync + 'static) -> Arc<FakeIam> {
|
||||
Arc::new(FakeIam {
|
||||
ready,
|
||||
answer: Box::new(answer),
|
||||
calls: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn root() -> RootCredentials {
|
||||
RootCredentials::new(ROOT_ACCESS_KEY, ROOT_SECRET, SIGNING_KEY).expect("valid root credentials")
|
||||
}
|
||||
|
||||
fn source(iam: Arc<FakeIam>) -> IamPolicySource {
|
||||
IamPolicySource::new(iam, Arc::new(RootCell::new(Some(root()))))
|
||||
}
|
||||
|
||||
fn user() -> IamCredential {
|
||||
IamCredential {
|
||||
access_key: ACCESS_KEY.to_owned(),
|
||||
secret_key: SECRET.to_owned(),
|
||||
status: "on".to_owned(),
|
||||
groups: Some(vec!["cred-group".to_owned()]),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn sts_token(claims: &[(&str, Value)], secret: &str) -> String {
|
||||
let mut map: HashMap<String, Value> = claims.iter().map(|(k, v)| ((*k).to_owned(), v.clone())).collect();
|
||||
map.insert("exp".to_owned(), Value::from(EXPIRES_AT));
|
||||
generate_jwt(&map, secret).expect("a token")
|
||||
}
|
||||
|
||||
fn sts(parent: &str, token: String) -> IamCredential {
|
||||
IamCredential {
|
||||
session_token: token,
|
||||
parent_user: parent.to_owned(),
|
||||
expiration: Some(OffsetDateTime::from_unix_timestamp(EXPIRES_AT).expect("valid timestamp")),
|
||||
..user()
|
||||
}
|
||||
}
|
||||
|
||||
fn found(cred: IamCredential) -> KeyAnswer {
|
||||
Ok((Some(UserIdentity::new(cred)), true))
|
||||
}
|
||||
|
||||
fn long_term(access_key: &str) -> Identity {
|
||||
Identity::new(access_key).expect("valid access key")
|
||||
}
|
||||
|
||||
fn session(access_key: &str) -> Identity {
|
||||
let binding = SessionBinding::new(crate::STS_SESSION_ISSUER, EXPIRES_AT).expect("valid binding");
|
||||
long_term(access_key).with_session_binding(binding)
|
||||
}
|
||||
|
||||
async fn principal_of(source: &IamPolicySource, identity: &Identity) -> PolicySnapshot {
|
||||
source.snapshot(Some(identity)).await.expect("a snapshot")
|
||||
}
|
||||
|
||||
fn payload(snapshot: &PolicySnapshot) -> &RustfsPrincipal {
|
||||
snapshot.get::<RustfsPrincipal>().expect("the payload is a RustfsPrincipal")
|
||||
}
|
||||
|
||||
fn refusal(result: Result<PolicySnapshot, PolicyError>) -> PolicyError {
|
||||
match result {
|
||||
Ok(snapshot) => panic!("expected a refusal, got a snapshot (empty: {})", snapshot.is_empty()),
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile-time: `RustfsPrincipal` has no `Debug`, so no formatting of a snapshot payload can
|
||||
/// reach its claims. The two blanket impls make the path ambiguous, and so a compile error, as
|
||||
/// soon as `RustfsPrincipal: Debug` holds.
|
||||
#[test]
|
||||
fn policy_principal_has_no_debug() {
|
||||
trait AmbiguousIfDebug<A> {
|
||||
fn some_item() {}
|
||||
}
|
||||
impl<T: ?Sized> AmbiguousIfDebug<()> for T {}
|
||||
impl<T: ?Sized + core::fmt::Debug> AmbiguousIfDebug<u8> for T {}
|
||||
let _ = <RustfsPrincipal as AmbiguousIfDebug<_>>::some_item;
|
||||
}
|
||||
|
||||
/// Positive — a long-term user's facts: itself as username, both group sources, no claims,
|
||||
/// not the owner.
|
||||
#[tokio::test]
|
||||
async fn policy_user_snapshot_carries_iam_facts() {
|
||||
let iam = fake(true, || found(user()));
|
||||
let snapshot = principal_of(&source(iam), &long_term(ACCESS_KEY)).await;
|
||||
let principal = payload(&snapshot);
|
||||
assert!(principal.kind() == PrincipalKind::User);
|
||||
assert_eq!(principal.account(), ACCESS_KEY);
|
||||
assert_eq!(principal.username(), ACCESS_KEY);
|
||||
assert_eq!(principal.parent_user(), "");
|
||||
assert_eq!(principal.credential_groups(), &Some(vec!["cred-group".to_owned()]));
|
||||
assert_eq!(principal.group_memberships(), ["key-group".to_owned()]);
|
||||
assert!(principal.claims().is_none());
|
||||
assert!(!principal.is_owner());
|
||||
}
|
||||
|
||||
/// Positive — an STS credential acts for its parent user, and its claims are the ones its
|
||||
/// token carries, read with the root secret that signed it.
|
||||
#[tokio::test]
|
||||
async fn policy_sts_snapshot_carries_parent_user_and_claims() {
|
||||
let token = sts_token(&[("sub", Value::from("alice-session"))], SIGNING_KEY);
|
||||
let iam = fake(true, move || found(sts(PARENT, token.clone())));
|
||||
let snapshot = principal_of(&source(iam), &session(ACCESS_KEY)).await;
|
||||
let principal = payload(&snapshot);
|
||||
assert!(principal.kind() == PrincipalKind::Temporary);
|
||||
assert_eq!(principal.account(), ACCESS_KEY);
|
||||
assert_eq!(principal.parent_user(), PARENT);
|
||||
assert_eq!(principal.username(), PARENT);
|
||||
let claims = principal.claims().expect("an STS principal has claims");
|
||||
assert_eq!(claims.get("sub"), Some(&Value::from("alice-session")));
|
||||
assert_eq!(principal.group_memberships(), ["parent-group".to_owned()]);
|
||||
assert!(!principal.is_owner());
|
||||
}
|
||||
|
||||
/// Positive — a service account's claims are read with its own secret, and it acts for its
|
||||
/// parent.
|
||||
#[tokio::test]
|
||||
async fn policy_service_account_snapshot_reads_its_own_token() {
|
||||
let mut token_claims: HashMap<String, Value> = HashMap::new();
|
||||
token_claims.insert(IAM_POLICY_CLAIM_NAME_SA.to_owned(), Value::from("inherited-policy"));
|
||||
token_claims.insert("parent".to_owned(), Value::from(PARENT));
|
||||
let token = generate_jwt(&token_claims, SECRET).expect("a token");
|
||||
let iam = fake(true, move || {
|
||||
let mut cred = user();
|
||||
cred.session_token = token.clone();
|
||||
cred.parent_user = PARENT.to_owned();
|
||||
cred.claims = Some([(IAM_POLICY_CLAIM_NAME_SA.to_owned(), Value::from("inherited-policy"))].into());
|
||||
found(cred)
|
||||
});
|
||||
let snapshot = principal_of(&source(iam), &long_term(ACCESS_KEY)).await;
|
||||
let principal = payload(&snapshot);
|
||||
assert!(principal.kind() == PrincipalKind::ServiceAccount);
|
||||
assert_eq!(principal.username(), PARENT);
|
||||
assert_eq!(principal.claims().and_then(|c| c.get("parent")), Some(&Value::from(PARENT)));
|
||||
assert_eq!(principal.group_memberships(), ["parent-group".to_owned()]);
|
||||
assert!(!principal.is_owner());
|
||||
}
|
||||
|
||||
/// Positive and negative — root is the owner without asking IAM, even before IAM is ready;
|
||||
/// an STS credential of root is the owner too, but not once a session policy narrows it.
|
||||
#[tokio::test]
|
||||
async fn policy_owner_is_root_or_unrestricted_root_session() {
|
||||
let not_ready = fake(false, || found(user()));
|
||||
let snapshot = principal_of(&source(not_ready.clone()), &long_term(ROOT_ACCESS_KEY)).await;
|
||||
let root = payload(&snapshot);
|
||||
assert!(root.kind() == PrincipalKind::Root);
|
||||
assert!(root.is_owner());
|
||||
assert_eq!(not_ready.calls.load(Ordering::SeqCst), 0);
|
||||
|
||||
let plain = sts_token(&[], SIGNING_KEY);
|
||||
let iam = fake(true, move || found(sts(ROOT_ACCESS_KEY, plain.clone())));
|
||||
assert!(payload(&principal_of(&source(iam), &session(ACCESS_KEY)).await).is_owner());
|
||||
|
||||
let narrowed = sts_token(&[(SESSION_POLICY_NAME, Value::from("e30"))], SIGNING_KEY);
|
||||
let iam = fake(true, move || found(sts(ROOT_ACCESS_KEY, narrowed.clone())));
|
||||
assert!(!payload(&principal_of(&source(iam), &session(ACCESS_KEY)).await).is_owner());
|
||||
}
|
||||
|
||||
/// Negative — an anonymous caller is an empty snapshot, and IAM is not asked.
|
||||
#[tokio::test]
|
||||
async fn policy_anonymous_is_empty_snapshot() {
|
||||
let iam = fake(true, || found(user()));
|
||||
let snapshot = source(iam.clone()).snapshot(None).await.expect("an anonymous snapshot");
|
||||
assert!(snapshot.is_empty());
|
||||
assert_eq!(iam.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
/// Negative — IAM that is not ready, not initialized, or failing is `unavailable`: never an
|
||||
/// empty snapshot, which an authorizer would judge as an anonymous caller.
|
||||
#[tokio::test]
|
||||
async fn policy_iam_unavailable_is_unavailable() {
|
||||
let not_ready = fake(false, || found(user()));
|
||||
let error = refusal(source(not_ready.clone()).snapshot(Some(&long_term(ACCESS_KEY))).await);
|
||||
assert_eq!(error, PolicyError::unavailable());
|
||||
assert_eq!(not_ready.calls.load(Ordering::SeqCst), 0);
|
||||
|
||||
let failures: [fn() -> IamError; 2] = [
|
||||
|| IamError::IamSysNotInitialized,
|
||||
|| IamError::StringError("storage read failed".to_owned()),
|
||||
];
|
||||
for failure in failures {
|
||||
let iam = fake(true, move || Err(failure()));
|
||||
let error = refusal(source(iam).snapshot(Some(&long_term(ACCESS_KEY))).await);
|
||||
assert_eq!(error, PolicyError::unavailable(), "{:?}", failure());
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative — a principal IAM no longer knows is refused as unknown, not as unavailable and
|
||||
/// not as an empty snapshot.
|
||||
#[tokio::test]
|
||||
async fn policy_unknown_principal_is_refused() {
|
||||
let answers: [fn() -> KeyAnswer; 2] = [|| Ok((None, false)), || Err(IamError::NoSuchUser(ACCESS_KEY.to_owned()))];
|
||||
for answer in answers {
|
||||
let error = refusal(source(fake(true, answer)).snapshot(Some(&long_term(ACCESS_KEY))).await);
|
||||
assert_eq!(error, PolicyError::because(UNKNOWN_PRINCIPAL));
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative — a disabled or invalid principal is refused rather than given a snapshot.
|
||||
#[tokio::test]
|
||||
async fn policy_disabled_principal_is_refused() {
|
||||
let invalid = fake(true, || Ok((Some(UserIdentity::new(user())), false)));
|
||||
let error = refusal(source(invalid).snapshot(Some(&long_term(ACCESS_KEY))).await);
|
||||
assert_eq!(error, PolicyError::because(DISABLED_PRINCIPAL));
|
||||
|
||||
let off = fake(true, || {
|
||||
let mut cred = user();
|
||||
cred.status = "off".to_owned();
|
||||
found(cred)
|
||||
});
|
||||
let error = refusal(source(off).snapshot(Some(&long_term(ACCESS_KEY))).await);
|
||||
assert_eq!(error, PolicyError::because(DISABLED_PRINCIPAL));
|
||||
}
|
||||
|
||||
/// Negative — an STS token not signed by root yields no claims at all: it is `malformed`.
|
||||
#[tokio::test]
|
||||
async fn policy_sts_token_with_foreign_signature_is_malformed() {
|
||||
let forged = sts_token(&[("sub", Value::from("alice-session"))], "not-the-root-secret");
|
||||
let iam = fake(true, move || found(sts(PARENT, forged.clone())));
|
||||
let error = refusal(source(iam).snapshot(Some(&session(ACCESS_KEY))).await);
|
||||
assert_eq!(error, PolicyError::malformed());
|
||||
}
|
||||
|
||||
/// Negative — the identity's credential kind must match what IAM holds: a session identity for
|
||||
/// a long-term key, or a long-term identity for an STS key, is refused.
|
||||
#[tokio::test]
|
||||
async fn policy_session_kind_mismatch_is_refused() {
|
||||
let token = sts_token(&[], SIGNING_KEY);
|
||||
let iam = fake(true, move || found(sts(PARENT, token.clone())));
|
||||
let error = refusal(source(iam).snapshot(Some(&long_term(ACCESS_KEY))).await);
|
||||
assert_eq!(error, PolicyError::because(SESSION_MISMATCH));
|
||||
|
||||
let iam = fake(true, || found(user()));
|
||||
let error = refusal(source(iam).snapshot(Some(&session(ACCESS_KEY))).await);
|
||||
assert_eq!(error, PolicyError::because(SESSION_MISMATCH));
|
||||
|
||||
let iam = fake(false, || found(user()));
|
||||
let error = refusal(source(iam).snapshot(Some(&session(ROOT_ACCESS_KEY))).await);
|
||||
assert_eq!(error, PolicyError::because(SESSION_MISMATCH));
|
||||
}
|
||||
|
||||
fn service_account(parent: &str, extra_claims: &[(&str, Value)]) -> KeyAnswer {
|
||||
let mut token_claims: HashMap<String, Value> = extra_claims.iter().map(|(k, v)| ((*k).to_owned(), v.clone())).collect();
|
||||
token_claims.insert(IAM_POLICY_CLAIM_NAME_SA.to_owned(), Value::from("inherited-policy"));
|
||||
let mut cred = user();
|
||||
cred.session_token = generate_jwt(&token_claims, SECRET).expect("a token");
|
||||
cred.parent_user = parent.to_owned();
|
||||
cred.claims = Some([(IAM_POLICY_CLAIM_NAME_SA.to_owned(), Value::from("inherited-policy"))].into());
|
||||
found(cred)
|
||||
}
|
||||
|
||||
/// Negative — a valid-flagged identity whose secret is empty or short gets no snapshot.
|
||||
#[tokio::test]
|
||||
async fn policy_short_secret_principal_is_refused() {
|
||||
for weak in ["", "1234567", "\0\0\0\0\0\0\0\0", "abc\0\0\0\0\0"] {
|
||||
let iam = fake(true, move || {
|
||||
let mut cred = user();
|
||||
cred.secret_key = weak.to_owned();
|
||||
found(cred)
|
||||
});
|
||||
let error = refusal(source(iam).snapshot(Some(&long_term(ACCESS_KEY))).await);
|
||||
assert_eq!(error, PolicyError::because(DISABLED_PRINCIPAL), "{weak:?}");
|
||||
}
|
||||
for usable in ["12345678", "abc\0defg"] {
|
||||
let iam = fake(true, move || {
|
||||
let mut cred = user();
|
||||
cred.secret_key = usable.to_owned();
|
||||
found(cred)
|
||||
});
|
||||
assert!(payload(&principal_of(&source(iam), &long_term(ACCESS_KEY)).await).kind() == PrincipalKind::User);
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative — a root session issued through external OIDC is not the owner, though its parent
|
||||
/// is root; the same session without the OIDC issuer is.
|
||||
#[tokio::test]
|
||||
async fn policy_oidc_root_session_is_not_owner() {
|
||||
let oidc = sts_token(
|
||||
&[
|
||||
("iss", Value::from("rustfs-oidc")),
|
||||
("oidc_provider", Value::from("corp-idp")),
|
||||
("sub", Value::from("alice@corp")),
|
||||
],
|
||||
SIGNING_KEY,
|
||||
);
|
||||
let iam = fake(true, move || found(sts(ROOT_ACCESS_KEY, oidc.clone())));
|
||||
assert!(!payload(&principal_of(&source(iam), &session(ACCESS_KEY)).await).is_owner());
|
||||
|
||||
let local = sts_token(
|
||||
&[
|
||||
("iss", Value::from("rustfs-sts")),
|
||||
("oidc_provider", Value::from("corp-idp")),
|
||||
("sub", Value::from("alice@corp")),
|
||||
],
|
||||
SIGNING_KEY,
|
||||
);
|
||||
let iam = fake(true, move || found(sts(ROOT_ACCESS_KEY, local.clone())));
|
||||
assert!(payload(&principal_of(&source(iam), &session(ACCESS_KEY)).await).is_owner());
|
||||
}
|
||||
|
||||
/// Positive and negative — a service account of root is the owner; one narrowed by a session
|
||||
/// policy is not, and neither is a service account of an ordinary user.
|
||||
#[tokio::test]
|
||||
async fn policy_service_account_of_root_is_owner() {
|
||||
let iam = fake(true, || service_account(ROOT_ACCESS_KEY, &[]));
|
||||
let principal = principal_of(&source(iam), &long_term(ACCESS_KEY)).await;
|
||||
assert!(payload(&principal).kind() == PrincipalKind::ServiceAccount);
|
||||
assert!(payload(&principal).is_owner());
|
||||
|
||||
let iam = fake(true, || service_account(ROOT_ACCESS_KEY, &[(SESSION_POLICY_NAME, Value::from("e30"))]));
|
||||
assert!(!payload(&principal_of(&source(iam), &long_term(ACCESS_KEY)).await).is_owner());
|
||||
|
||||
let iam = fake(true, || service_account(PARENT, &[]));
|
||||
assert!(!payload(&principal_of(&source(iam), &long_term(ACCESS_KEY)).await).is_owner());
|
||||
}
|
||||
|
||||
/// Negative — STS claims are read with the STS signing key, not with the root secret: a token
|
||||
/// signed with the root secret while the signing key differs is `malformed`.
|
||||
#[tokio::test]
|
||||
async fn policy_sts_claims_use_the_signing_key() {
|
||||
let root_signed = sts_token(&[("sub", Value::from("alice-session"))], ROOT_SECRET);
|
||||
let iam = fake(true, move || found(sts(PARENT, root_signed.clone())));
|
||||
let error = refusal(source(iam).snapshot(Some(&session(ACCESS_KEY))).await);
|
||||
assert_eq!(error, PolicyError::malformed());
|
||||
}
|
||||
|
||||
/// Negative — without root credentials no principal can be judged: `unavailable`, never an
|
||||
/// empty snapshot, and IAM is not asked.
|
||||
#[tokio::test]
|
||||
async fn policy_without_root_is_unavailable() {
|
||||
let iam = fake(true, || found(user()));
|
||||
let source = IamPolicySource::new(iam.clone(), Arc::new(RootCell::new(None)));
|
||||
for identity in [long_term(ACCESS_KEY), long_term(ROOT_ACCESS_KEY)] {
|
||||
assert_eq!(refusal(source.snapshot(Some(&identity)).await), PolicyError::unavailable());
|
||||
}
|
||||
assert_eq!(iam.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
type LiveRoot = Arc<std::sync::Mutex<Option<(String, String)>>>;
|
||||
|
||||
/// The primary path: a source that reads a live accessor on every call, as the binary's does,
|
||||
/// with nothing cached in between.
|
||||
fn live_root_source(live: &LiveRoot) -> Arc<dyn RootSource> {
|
||||
let accessor = live.clone();
|
||||
Arc::new(move || {
|
||||
let guard = accessor.lock().ok()?;
|
||||
let (access_key, secret_key) = guard.as_ref()?;
|
||||
RootCredentials::new(access_key, secret_key, SIGNING_KEY).ok().map(Arc::new)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_live(live: &LiveRoot, value: Option<(&str, &str)>) {
|
||||
*live.lock().expect("live root lock") = value.map(|(ak, sk)| (ak.to_owned(), sk.to_owned()));
|
||||
}
|
||||
|
||||
/// Positive and negative — the two adapters read one live root accessor: a root change is seen
|
||||
/// by both on the next request with no second step, the old root key stops being root to
|
||||
/// both, and an uninitialized or unusable root makes both unavailable.
|
||||
#[tokio::test]
|
||||
async fn adapters_share_one_live_root() {
|
||||
use rustfs_gateway::{CredentialLookup, CredentialProvider, ProviderError};
|
||||
|
||||
let live: LiveRoot = Arc::default();
|
||||
set_live(&live, Some((ROOT_ACCESS_KEY, ROOT_SECRET)));
|
||||
let iam = fake(true, || Ok((None, false)));
|
||||
let adapters = crate::IamAdapters::new(iam, live_root_source(&live));
|
||||
|
||||
let found = adapters.credentials.lookup(ROOT_ACCESS_KEY).await;
|
||||
assert!(matches!(found, Ok(CredentialLookup::Found(_))));
|
||||
let snapshot = principal_of(&adapters.policy, &long_term(ROOT_ACCESS_KEY)).await;
|
||||
assert!(payload(&snapshot).is_owner());
|
||||
|
||||
set_live(&live, Some(("newroot", "new-root-secret-01")));
|
||||
match adapters.credentials.lookup("newroot").await {
|
||||
Ok(CredentialLookup::Found(credentials)) => assert_eq!(credentials.secret().expose(), b"new-root-secret-01"),
|
||||
_ => panic!("the new root key must resolve to the new secret"),
|
||||
}
|
||||
assert!(matches!(
|
||||
adapters.credentials.lookup(ROOT_ACCESS_KEY).await,
|
||||
Ok(CredentialLookup::NotFound)
|
||||
));
|
||||
assert!(payload(&principal_of(&adapters.policy, &long_term("newroot")).await).is_owner());
|
||||
let old = refusal(adapters.policy.snapshot(Some(&long_term(ROOT_ACCESS_KEY))).await);
|
||||
assert_eq!(old, PolicyError::because(UNKNOWN_PRINCIPAL));
|
||||
|
||||
for unusable in [None, Some(("newroot", "\0\0\0\0\0\0\0\0"))] {
|
||||
set_live(&live, unusable);
|
||||
assert!(matches!(adapters.credentials.lookup("newroot").await, Err(ProviderError::Unavailable)));
|
||||
assert_eq!(
|
||||
refusal(adapters.policy.snapshot(Some(&long_term("newroot"))).await),
|
||||
PolicyError::unavailable()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Positive — `install` builds the authenticator exactly once, over a provider that reads the
|
||||
/// pair's root source.
|
||||
#[tokio::test]
|
||||
async fn adapters_install_hands_over_the_paired_provider_once() {
|
||||
use rustfs_gateway::{CredentialLookup, CredentialProvider, RegionSet, ServiceBuilder, SigV4Authenticator};
|
||||
|
||||
let live: LiveRoot = Arc::default();
|
||||
set_live(&live, Some((ROOT_ACCESS_KEY, ROOT_SECRET)));
|
||||
let adapters = crate::IamAdapters::new(fake(true, || Ok((None, false))), live_root_source(&live));
|
||||
let mut handed = Vec::new();
|
||||
let _builder = adapters.install(ServiceBuilder::new(), |provider| {
|
||||
handed.push(provider.clone());
|
||||
SigV4Authenticator::new(provider, RegionSet::new(["us-east-1"]).expect("a region"))
|
||||
});
|
||||
assert_eq!(handed.len(), 1);
|
||||
let found = handed[0].lookup(ROOT_ACCESS_KEY).await;
|
||||
assert!(matches!(found, Ok(CredentialLookup::Found(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! The deployment's root credentials, as every adapter in this crate reads them.
|
||||
//!
|
||||
//! Responsible for: [`RootCredentials`], the validated root key pair plus the key STS session
|
||||
//! tokens are signed with; [`RootSource`], the one live handle both the credential provider and
|
||||
//! the policy source read on every request; and the effective-secret-length rule every secret in
|
||||
//! this crate is held to.
|
||||
//! NOT responsible for: where the root keys come from or when they change. The binary implements
|
||||
//! [`RootSource`] directly over its live root accessor — a closure is enough — so there is no
|
||||
//! second copy of root to keep in step.
|
||||
//! Upstream: the binary's runtime credentials. Downstream: `crate::credentials`, `crate::policy`.
|
||||
//!
|
||||
//! # Why one source
|
||||
//!
|
||||
//! Legacy reads the current root credentials on every request. Two copies taken at assembly could
|
||||
//! disagree, or go stale after a root change, and a key one adapter still treats as root would be
|
||||
//! answered with the root secret or judged as the owner. Both adapters therefore hold the same
|
||||
//! `Arc<dyn RootSource>`, handed to them together by [`crate::IamAdapters::new`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustfs_gateway::Credentials;
|
||||
|
||||
/// The shortest effective secret key, in bytes, that any credential in this crate may verify a
|
||||
/// signature with. The same bound IAM applies to its own credentials; a signature over a shorter
|
||||
/// (or empty) key is one anybody can compute.
|
||||
pub const MIN_SECRET_KEY_LEN: usize = 8;
|
||||
|
||||
/// The number of bytes of `secret` a signature actually depends on: its length after trailing NUL
|
||||
/// bytes are removed.
|
||||
///
|
||||
/// HMAC pads a key shorter than its block with zero bytes, and SigV4's first key is `"AWS4"`
|
||||
/// followed by the secret, so trailing NULs add nothing: eight NUL bytes sign exactly like the
|
||||
/// empty secret, and `"abc"` followed by five NULs signs like `"abc"`. A secret of NULs only has
|
||||
/// an effective length of zero. An interior NUL is followed by real key bytes, which change the
|
||||
/// padded key, so it counts like any other byte.
|
||||
pub(crate) fn effective_secret_len(secret: &[u8]) -> usize {
|
||||
secret.iter().rposition(|byte| *byte != 0).map_or(0, |last| last + 1)
|
||||
}
|
||||
|
||||
/// Whether `secret` may verify a signature: its [`effective_secret_len`] is at least
|
||||
/// [`MIN_SECRET_KEY_LEN`]. The one rule for root, IAM credentials and the STS signing key.
|
||||
pub(crate) fn is_usable_secret(secret: &[u8]) -> bool {
|
||||
effective_secret_len(secret) >= MIN_SECRET_KEY_LEN
|
||||
}
|
||||
|
||||
/// Why [`RootCredentials::new`] refused its input. Carries no input text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RootCredentialsError {
|
||||
/// The access key id is not one the gateway can hold.
|
||||
InvalidAccessKey,
|
||||
/// The root secret key fails the effective-length rule.
|
||||
WeakSecretKey,
|
||||
/// The STS token-signing key fails the effective-length rule.
|
||||
WeakSigningKey,
|
||||
}
|
||||
|
||||
impl core::fmt::Display for RootCredentialsError {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::InvalidAccessKey => "root access key is not usable",
|
||||
Self::WeakSecretKey => "root secret key is shorter than the minimum length",
|
||||
Self::WeakSigningKey => "STS token-signing key is shorter than the minimum length",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RootCredentialsError {}
|
||||
|
||||
/// The root key pair and the STS token-signing key, validated together.
|
||||
///
|
||||
/// Implements no `Debug`; the secrets are readable only inside this crate.
|
||||
pub struct RootCredentials {
|
||||
credentials: Credentials,
|
||||
sts_signing_key: Box<str>,
|
||||
}
|
||||
|
||||
impl RootCredentials {
|
||||
/// Validates the root credentials and the key IAM signs STS session tokens with (today the
|
||||
/// root secret, `rustfs_iam::manager::get_token_signing_key`; pass it separately so a future
|
||||
/// key separation changes no adapter).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`RootCredentialsError`] when either secret fails the effective-length rule (fewer than
|
||||
/// [`MIN_SECRET_KEY_LEN`] bytes once trailing NUL bytes are removed) or the access key id is not
|
||||
/// one the gateway can hold.
|
||||
pub fn new(access_key: &str, secret_key: &str, sts_signing_key: &str) -> Result<Self, RootCredentialsError> {
|
||||
if !is_usable_secret(secret_key.as_bytes()) {
|
||||
return Err(RootCredentialsError::WeakSecretKey);
|
||||
}
|
||||
if !is_usable_secret(sts_signing_key.as_bytes()) {
|
||||
return Err(RootCredentialsError::WeakSigningKey);
|
||||
}
|
||||
let credentials =
|
||||
Credentials::new(access_key, secret_key.as_bytes()).map_err(|_| RootCredentialsError::InvalidAccessKey)?;
|
||||
Ok(Self {
|
||||
credentials,
|
||||
sts_signing_key: sts_signing_key.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The root access key id, a public identifier.
|
||||
#[must_use]
|
||||
pub fn access_key(&self) -> &str {
|
||||
self.credentials.identity().access_key_id()
|
||||
}
|
||||
|
||||
pub(crate) fn credentials(&self) -> &Credentials {
|
||||
&self.credentials
|
||||
}
|
||||
|
||||
pub(crate) fn sts_signing_key(&self) -> &str {
|
||||
&self.sts_signing_key
|
||||
}
|
||||
}
|
||||
|
||||
/// The live root credentials, read on every request by both adapters.
|
||||
///
|
||||
/// The binary implements it directly over RustFS's live root accessor, so a root change needs no
|
||||
/// second step. Any `Fn() -> Option<Arc<RootCredentials>>` is a source; for example, over the
|
||||
/// server context:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let root = move || {
|
||||
/// let cred = ctx.action_credentials().get()?;
|
||||
/// let signing_key = ctx.token_signing_key()?;
|
||||
/// RootCredentials::new(&cred.access_key, &cred.secret_key, &signing_key).ok().map(Arc::new)
|
||||
/// };
|
||||
/// let adapters = IamAdapters::new(iam, Arc::new(root));
|
||||
/// ```
|
||||
///
|
||||
/// Root credentials that fail validation read as `None`, which both adapters answer as
|
||||
/// unavailable.
|
||||
pub trait RootSource: Send + Sync + 'static {
|
||||
/// The current root credentials; `None` while they are not initialized, which both adapters
|
||||
/// answer as unavailable rather than guessing.
|
||||
fn current(&self) -> Option<Arc<RootCredentials>>;
|
||||
}
|
||||
|
||||
impl<F> RootSource for F
|
||||
where
|
||||
F: Fn() -> Option<Arc<RootCredentials>> + Send + Sync + 'static,
|
||||
{
|
||||
fn current(&self) -> Option<Arc<RootCredentials>> {
|
||||
self()
|
||||
}
|
||||
}
|
||||
|
||||
/// Test helper: a [`RootSource`] holding fixed root credentials. Production code reads the live
|
||||
/// accessor instead, so that there is no copy of root to keep in step.
|
||||
#[cfg(test)]
|
||||
pub(crate) struct RootCell(Option<Arc<RootCredentials>>);
|
||||
|
||||
#[cfg(test)]
|
||||
impl RootCell {
|
||||
pub(crate) fn new(root: Option<RootCredentials>) -> Self {
|
||||
Self(root.map(Arc::new))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl RootSource for RootCell {
|
||||
fn current(&self) -> Option<Arc<RootCredentials>> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Negative — an empty or short root secret is refused at construction, so no source can ever
|
||||
/// hand out a root key anybody could sign with; eight bytes is accepted.
|
||||
#[test]
|
||||
fn root_short_secret_is_refused() {
|
||||
for weak in ["", "1234567", "\0\0\0\0\0\0\0\0", "abc\0\0\0\0\0", "abcdefg\0"] {
|
||||
let refused = RootCredentials::new("rootadmin", weak, "signing-key-0001");
|
||||
assert!(matches!(refused, Err(RootCredentialsError::WeakSecretKey)), "{} bytes", weak.len());
|
||||
}
|
||||
assert!(RootCredentials::new("rootadmin", "12345678", "signing-key-0001").is_ok());
|
||||
}
|
||||
|
||||
/// Positive — an interior NUL is followed by real key bytes and counts: `"abc\0defg"` has an
|
||||
/// effective length of eight and is accepted, as is the same secret with trailing NULs.
|
||||
#[test]
|
||||
fn root_interior_nul_counts_toward_length() {
|
||||
for secret in ["abc\0defg", "abc\0defg\0\0"] {
|
||||
assert!(RootCredentials::new("rootadmin", secret, "signing-key-0001").is_ok(), "{secret:?}");
|
||||
assert!(RootCredentials::new("rootadmin", "root-secret-key-0001", secret).is_ok(), "{secret:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative — an empty or short STS signing key is refused at construction.
|
||||
#[test]
|
||||
fn root_short_signing_key_is_refused() {
|
||||
for weak in ["", "1234567", "\0\0\0\0\0\0\0\0", "abc\0\0\0\0\0"] {
|
||||
let refused = RootCredentials::new("rootadmin", "root-secret-key-0001", weak);
|
||||
assert!(matches!(refused, Err(RootCredentialsError::WeakSigningKey)), "{} bytes", weak.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! The request id RustFS's outer layer minted, handed to the gateway as its own when that is safe.
|
||||
//!
|
||||
//! Responsible for: reading the id from the context the outer layer put in the request's
|
||||
//! extensions ([`RequestIdCarrier`]) and, when it is inside the gateway's identifier alphabet and
|
||||
//! length, inserting it as the gateway's [`HostRequestId`].
|
||||
//! NOT responsible for: minting any id, refusing any request (nothing here can fail a request),
|
||||
//! choosing the response headers (the gateway writes `x-amz-request-id` from the id it was
|
||||
//! handed, and RustFS's outer layer overwrites that header on S3 paths), or the layer that mints
|
||||
//! (`rustfs/src/server/layer.rs`). It never reads a header itself.
|
||||
//! Upstream: RustFS's `RequestContext`, which the binary makes a [`RequestIdCarrier`]. Downstream:
|
||||
//! the gateway service, which reads the [`HostRequestId`] extension once, at the top.
|
||||
//!
|
||||
//! # Whose id it is
|
||||
//!
|
||||
//! On S3 paths the outer layer mints a server-owned UUID. On admin, STS and console paths the
|
||||
//! context copies the caller's `x-request-id` (or `x-amz-request-id`), as legacy does, so there
|
||||
//! the id can be caller-chosen. Either way the id is attached only when it is inert, that is 1 to
|
||||
//! 64 bytes of ASCII letters, digits, `-`, `.`, `_` and `~`, which is safe in a header, an XML
|
||||
//! document and a log line. Anything else is not attached and the request is **forwarded
|
||||
//! unchanged**: the gateway then mints its own id. No request is ever refused because of its id.
|
||||
//!
|
||||
//! # Known difference when nothing is attached
|
||||
//!
|
||||
//! The gateway answers with the id it minted (error body `<RequestId>`, its events and audit
|
||||
//! records) while, on S3 paths, the outer layer overwrites the response header with RustFS's id.
|
||||
//! So for such a request the header and the error body name different ids. That needs a caller-
|
||||
//! chosen id outside the alphabet, or a request that never passed the outer layer.
|
||||
//!
|
||||
//! # Why this is not a `TraceSource`
|
||||
//!
|
||||
//! The gateway's `TraceSource::mint` takes no request, so a source cannot read the id RustFS
|
||||
//! already minted; the gateway's documented path for a host that identifies its own requests is
|
||||
//! the [`HostRequestId`] extension, and this module fills it.
|
||||
//!
|
||||
//! # Why a carrier trait
|
||||
//!
|
||||
//! `RequestContext` lives in the `rustfs` binary crate, which a library crate cannot depend on.
|
||||
//! The binary implements [`RequestIdCarrier`] for it, and this module looks it up in the
|
||||
//! extensions by that type.
|
||||
|
||||
use rustfs_gateway::HostRequestId;
|
||||
|
||||
/// A type in the request extensions that holds the id the outer layer minted or propagated.
|
||||
pub trait RequestIdCarrier: Send + Sync + 'static {
|
||||
/// The canonical request id: the value RustFS logs and the response should carry.
|
||||
fn request_id(&self) -> &str;
|
||||
}
|
||||
|
||||
/// What [`attach_host_request_id`] did. Ignoring it is harmless: every outcome forwards the request.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AttachOutcome {
|
||||
/// The carrier's id was inserted as the request's [`HostRequestId`].
|
||||
Attached,
|
||||
/// The request carries no context from the outer layer; nothing was inserted.
|
||||
NoContext,
|
||||
/// The carrier's id is empty, too long, or outside the gateway's alphabet; nothing was
|
||||
/// inserted and the id was not repaired.
|
||||
UnusableId,
|
||||
}
|
||||
|
||||
/// The gateway's [`HostRequestId`] for the id `carrier` holds, or `None` when it is not inert.
|
||||
#[must_use]
|
||||
pub fn host_request_id<C: RequestIdCarrier + ?Sized>(carrier: &C) -> Option<HostRequestId> {
|
||||
HostRequestId::new(carrier.request_id()).ok()
|
||||
}
|
||||
|
||||
/// Reads the `C` the outer layer put in `request`'s extensions and, when its id is inert, inserts
|
||||
/// it as the request's [`HostRequestId`].
|
||||
///
|
||||
/// Never refuses the request: the caller forwards it whatever the outcome.
|
||||
pub fn attach_host_request_id<C: RequestIdCarrier, B>(request: &mut http::Request<B>) -> AttachOutcome {
|
||||
let Some(carrier) = request.extensions().get::<C>() else {
|
||||
return AttachOutcome::NoContext;
|
||||
};
|
||||
let Some(id) = host_request_id(carrier) else {
|
||||
return AttachOutcome::UnusableId;
|
||||
};
|
||||
request.extensions_mut().insert(id);
|
||||
AttachOutcome::Attached
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Ctx(String);
|
||||
|
||||
impl RequestIdCarrier for Ctx {
|
||||
fn request_id(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
const UUID: &str = "7c9e6679-7425-40de-944b-e07fc1f90ae7";
|
||||
|
||||
fn request_with(id: &str) -> http::Request<()> {
|
||||
let mut request = http::Request::new(());
|
||||
request.extensions_mut().insert(Ctx(id.to_owned()));
|
||||
request
|
||||
}
|
||||
|
||||
fn handed_over(request: &http::Request<()>) -> Option<String> {
|
||||
request
|
||||
.extensions()
|
||||
.get::<HostRequestId>()
|
||||
.map(|id| id.request_id().as_str().to_owned())
|
||||
}
|
||||
|
||||
fn attach(request: &mut http::Request<()>) -> AttachOutcome {
|
||||
attach_host_request_id::<Ctx, _>(request)
|
||||
}
|
||||
|
||||
/// Positive: the id RustFS minted is the id the gateway is handed, byte for byte.
|
||||
#[test]
|
||||
fn the_minted_id_is_handed_over_unchanged() {
|
||||
let mut request = request_with(UUID);
|
||||
assert_eq!(attach(&mut request), AttachOutcome::Attached);
|
||||
assert_eq!(handed_over(&request).as_deref(), Some(UUID));
|
||||
}
|
||||
|
||||
/// Positive: every shape RustFS mints is accepted, including its two fallbacks.
|
||||
#[test]
|
||||
fn every_shape_rustfs_mints_is_accepted() {
|
||||
for id in [UUID, "trace-0123456789abcdef", "req-0123456789abcdef"] {
|
||||
let mut request = request_with(id);
|
||||
assert_eq!(attach(&mut request), AttachOutcome::Attached, "{id}");
|
||||
assert_eq!(handed_over(&request).as_deref(), Some(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Positive: a caller-supplied id that is inert (admin and STS paths copy it) is attached, and
|
||||
/// the longest allowed one is accepted.
|
||||
#[test]
|
||||
fn an_inert_caller_supplied_id_is_attached() {
|
||||
let longest = "a".repeat(64);
|
||||
for id in ["abc", "my.trace_id~1-2", longest.as_str()] {
|
||||
let mut request = request_with(id);
|
||||
assert_eq!(attach(&mut request), AttachOutcome::Attached, "{id}");
|
||||
assert_eq!(handed_over(&request).as_deref(), Some(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Positive: two requests keep their own ids; nothing is shared or minted between them.
|
||||
#[test]
|
||||
fn each_request_keeps_its_own_id() {
|
||||
let mut first = request_with("first-id");
|
||||
let mut second = request_with("second-id");
|
||||
attach(&mut first);
|
||||
attach(&mut second);
|
||||
assert_eq!(handed_over(&first).as_deref(), Some("first-id"));
|
||||
assert_eq!(handed_over(&second).as_deref(), Some("second-id"));
|
||||
}
|
||||
|
||||
/// Positive: with both a carrier and an `x-amz-request-id` header, the carrier's id wins.
|
||||
#[test]
|
||||
fn the_carrier_wins_over_a_request_id_header() {
|
||||
let mut request = http::Request::builder()
|
||||
.header("x-amz-request-id", "caller-chosen")
|
||||
.header("x-request-id", "caller-chosen")
|
||||
.body(())
|
||||
.expect("a request");
|
||||
request.extensions_mut().insert(Ctx(UUID.to_owned()));
|
||||
assert_eq!(attach(&mut request), AttachOutcome::Attached);
|
||||
assert_eq!(handed_over(&request).as_deref(), Some(UUID));
|
||||
}
|
||||
|
||||
/// Negative: a request the outer layer never saw gets no id, and the request is not refused.
|
||||
#[test]
|
||||
fn a_missing_context_hands_over_nothing() {
|
||||
let mut request = http::Request::new(());
|
||||
assert_eq!(attach(&mut request), AttachOutcome::NoContext);
|
||||
assert_eq!(handed_over(&request), None);
|
||||
}
|
||||
|
||||
/// Negative: a header named like the id is not the id; only the context is read.
|
||||
#[test]
|
||||
fn a_request_id_header_alone_is_not_read() {
|
||||
let mut request = http::Request::builder()
|
||||
.header("x-amz-request-id", "caller-chosen")
|
||||
.header("x-request-id", "caller-chosen")
|
||||
.body(())
|
||||
.expect("a request");
|
||||
assert_eq!(attach(&mut request), AttachOutcome::NoContext);
|
||||
assert_eq!(handed_over(&request), None);
|
||||
}
|
||||
|
||||
/// Negative: an id outside the alphabet or the length is never attached or repaired, and the
|
||||
/// outcome says so rather than failing the request.
|
||||
#[test]
|
||||
fn an_id_the_gateway_cannot_carry_is_not_attached() {
|
||||
let too_long = "a".repeat(65);
|
||||
for id in [
|
||||
"",
|
||||
"has space",
|
||||
"line\r\nbreak",
|
||||
"<RequestId>",
|
||||
"quo\"te",
|
||||
"Root=1-5759e988",
|
||||
too_long.as_str(),
|
||||
] {
|
||||
let mut request = request_with(id);
|
||||
assert_eq!(attach(&mut request), AttachOutcome::UnusableId, "{id:?}");
|
||||
assert_eq!(handed_over(&request), None, "{id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative: the unusable id is not repaired into a shorter or cleaned-up one.
|
||||
#[test]
|
||||
fn host_request_id_does_not_repair() {
|
||||
assert!(host_request_id(&Ctx("has space".to_owned())).is_none());
|
||||
assert!(host_request_id(&Ctx(format!("{UUID} "))).is_none());
|
||||
assert!(host_request_id(&Ctx(UUID.to_owned())).is_some());
|
||||
}
|
||||
}
|
||||
@@ -1873,4 +1873,123 @@ mod tests {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(err.to_string().contains("invalid checksum value"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
fn software_crc32_bitwise(reflected_poly: u32, data: &[u8]) -> [u8; 4] {
|
||||
let mut crc = !0u32;
|
||||
for &b in data {
|
||||
crc ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
let mask = (crc & 1).wrapping_neg();
|
||||
crc = (crc >> 1) ^ (reflected_poly & mask);
|
||||
}
|
||||
}
|
||||
(!crc).to_be_bytes()
|
||||
}
|
||||
|
||||
fn software_crc64_nvme_bitwise(data: &[u8]) -> [u8; 8] {
|
||||
let reflected_poly = super::CRC64_NVME_POLYNOMIAL.reverse_bits();
|
||||
let mut crc = !0u64;
|
||||
for &b in data {
|
||||
crc ^= u64::from(b);
|
||||
for _ in 0..8 {
|
||||
let mask = (crc & 1).wrapping_neg();
|
||||
crc = (crc >> 1) ^ (reflected_poly & mask);
|
||||
}
|
||||
}
|
||||
(!crc).to_be_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rio_crc_simd_matches_bitwise_scalar_across_tail_and_vector_boundaries() {
|
||||
for &size in &[
|
||||
0usize,
|
||||
1,
|
||||
7,
|
||||
8,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
63,
|
||||
64,
|
||||
65,
|
||||
127,
|
||||
128,
|
||||
129,
|
||||
255,
|
||||
256,
|
||||
257,
|
||||
1024,
|
||||
4096 + 17,
|
||||
64 * 1024,
|
||||
] {
|
||||
let payload: Vec<u8> = (0..size).map(|i| ((i * 31 + 17) % 251) as u8).collect();
|
||||
let crc32_simd = Checksum::new_from_data(ChecksumType::CRC32, &payload).expect("crc32");
|
||||
let crc32c_simd = Checksum::new_from_data(ChecksumType::CRC32C, &payload).expect("crc32c");
|
||||
let crc64_simd = Checksum::new_from_data(ChecksumType::CRC64_NVME, &payload).expect("crc64nvme");
|
||||
|
||||
let crc32_sw = software_crc32_bitwise(0xEDB88320, &payload);
|
||||
let crc32c_sw = software_crc32_bitwise(0x82F63B78, &payload);
|
||||
let crc64_sw = software_crc64_nvme_bitwise(&payload);
|
||||
|
||||
assert_eq!(crc32_simd.raw.as_slice(), &crc32_sw, "CRC32 SIMD vs scalar mismatch at {size}B");
|
||||
assert_eq!(crc32c_simd.raw.as_slice(), &crc32c_sw, "CRC32C SIMD vs scalar mismatch at {size}B");
|
||||
assert_eq!(crc64_simd.raw.as_slice(), &crc64_sw, "CRC64_NVME SIMD vs scalar mismatch at {size}B");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "utility throughput microbenchmark; run via cargo test -p rustfs-rio --lib --release -- --ignored --nocapture"]
|
||||
fn microbench_rio_checksum_simd_vs_scalar_and_all_base_algorithms() {
|
||||
use std::hint::black_box;
|
||||
use std::io::Write as _;
|
||||
use std::time::Instant;
|
||||
|
||||
for &(size, iters) in &[(1024usize, 100usize), (64 * 1024, 20), (1024 * 1024, 4)] {
|
||||
let payload: Vec<u8> = (0..size).map(|i| ((i * 31 + 17) % 251) as u8).collect();
|
||||
let total_mib = (size * iters) as f64 / (1024.0 * 1024.0);
|
||||
|
||||
// Verify SIMD-accelerated CRC32 / CRC32C / CRC64_NVME against bitwise scalar reference at 1 KiB & 64 KiB.
|
||||
if size <= 64 * 1024 {
|
||||
let crc32_simd = Checksum::new_from_data(ChecksumType::CRC32, &payload).expect("crc32");
|
||||
let crc32c_simd = Checksum::new_from_data(ChecksumType::CRC32C, &payload).expect("crc32c");
|
||||
let crc64_simd = Checksum::new_from_data(ChecksumType::CRC64_NVME, &payload).expect("crc64nvme");
|
||||
|
||||
let t_sw = Instant::now();
|
||||
let crc32_sw = software_crc32_bitwise(0xEDB88320, black_box(&payload));
|
||||
let crc32c_sw = software_crc32_bitwise(0x82F63B78, black_box(&payload));
|
||||
let crc64_sw = software_crc64_nvme_bitwise(black_box(&payload));
|
||||
let sw_elapsed = t_sw.elapsed();
|
||||
|
||||
assert_eq!(crc32_simd.raw.as_slice(), &crc32_sw, "CRC32 SIMD vs scalar mismatch at {size}B");
|
||||
assert_eq!(crc32c_simd.raw.as_slice(), &crc32c_sw, "CRC32C SIMD vs scalar mismatch at {size}B");
|
||||
assert_eq!(crc64_simd.raw.as_slice(), &crc64_sw, "CRC64_NVME SIMD vs scalar mismatch at {size}B");
|
||||
println!(
|
||||
"[microbench][rio::checksum] size={size}B bitwise scalar reference check passed in {:?}",
|
||||
sw_elapsed
|
||||
);
|
||||
}
|
||||
|
||||
for &checksum_type in super::BASE_CHECKSUM_TYPES {
|
||||
let mut hasher = checksum_type.hasher().expect("base checksum type must have hasher");
|
||||
let t0 = Instant::now();
|
||||
let mut digest = Vec::new();
|
||||
for _ in 0..iters {
|
||||
hasher.reset();
|
||||
hasher.write_all(black_box(&payload)).expect("hasher write_all");
|
||||
digest = hasher.finalize();
|
||||
black_box(&digest);
|
||||
}
|
||||
let elapsed = t0.elapsed();
|
||||
assert_eq!(digest.len(), checksum_type.raw_byte_len());
|
||||
let mibs = total_mib / elapsed.as_secs_f64().max(1e-9);
|
||||
println!(
|
||||
"[microbench][rio::checksum] size={size}B algo={} throughput={mibs:.1} MiB/s",
|
||||
checksum_type
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +425,10 @@ enum FolderScanSource {
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum FolderScanMode {
|
||||
Namespace,
|
||||
ErasureCandidate { reuse_namespace: bool },
|
||||
ErasureCandidate {
|
||||
reuse_namespace: bool,
|
||||
parent_has_metadata: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -2065,7 +2068,9 @@ impl FolderScanner {
|
||||
if candidate_unclassified {
|
||||
// Apply namespace reuse only after the current listing
|
||||
// proves there is no direct erasure part.
|
||||
if let FolderScanMode::ErasureCandidate { reuse_namespace: true } = mode
|
||||
if let FolderScanMode::ErasureCandidate {
|
||||
reuse_namespace: true, ..
|
||||
} = mode
|
||||
&& !into.compacted
|
||||
&& self.old_cache.find(&folder.name).is_some()
|
||||
{
|
||||
@@ -2079,10 +2084,23 @@ impl FolderScanner {
|
||||
return Ok(FolderScanOutcome::Namespace);
|
||||
}
|
||||
candidate_unclassified = false;
|
||||
if !self.budget.try_start_directory() {
|
||||
return Err(ScannerError::Other("Operation cancelled".to_string()));
|
||||
// An empty UUID directory beside object metadata has
|
||||
// no namespace work. Its listing was already charged;
|
||||
// nonempty namespaces still need directory admission.
|
||||
let empty_object_child = matches!(
|
||||
mode,
|
||||
FolderScanMode::ErasureCandidate {
|
||||
parent_has_metadata: true,
|
||||
..
|
||||
}
|
||||
) && deferred_metadata.is_none()
|
||||
&& queued_folders.is_empty();
|
||||
if !empty_object_child {
|
||||
if !self.budget.try_start_directory() {
|
||||
return Err(ScannerError::Other("Operation cancelled".to_string()));
|
||||
}
|
||||
wait_for_scanner_io(&ctx, self.sleeper.sleep_folder_preset()).await?;
|
||||
}
|
||||
wait_for_scanner_io(&ctx, self.sleeper.sleep_folder_preset()).await?;
|
||||
}
|
||||
let Some(entry) = deferred_metadata.take() else { break };
|
||||
(entry, true)
|
||||
@@ -2552,20 +2570,6 @@ impl FolderScanner {
|
||||
return Err(ScannerError::Other("Operation cancelled".to_string()));
|
||||
}
|
||||
|
||||
if found_object_metadata && self.is_erasure_mode {
|
||||
// If we found an object in erasure mode, we skip subdirs (only datadirs)...
|
||||
debug!(
|
||||
target: "rustfs::scanner::folder",
|
||||
event = EVENT_SCANNER_FOLDER_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_FOLDER,
|
||||
folder = %folder.name,
|
||||
state = "erasure_object_found",
|
||||
"Scanner folder descent stopped after erasure object"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Checkpointed sweeps need child entries to validate their resume
|
||||
// frontier (#8292). Outside that, also compact a wide bucket root:
|
||||
// excluding it left #8961's ~170k top-level prefixes unbounded until
|
||||
@@ -2654,6 +2658,7 @@ impl FolderScanner {
|
||||
let mut child_mode = if self.is_erasure_mode && is_erasure_candidate(&folder_item.name) {
|
||||
FolderScanMode::ErasureCandidate {
|
||||
reuse_namespace: reuse_covered,
|
||||
parent_has_metadata: found_object_metadata,
|
||||
}
|
||||
} else {
|
||||
FolderScanMode::Namespace
|
||||
@@ -2721,7 +2726,10 @@ impl FolderScanner {
|
||||
}
|
||||
// Sampling may skip namespace work, never the
|
||||
// classification that protects its parent object.
|
||||
child_mode = FolderScanMode::ErasureCandidate { reuse_namespace: true };
|
||||
child_mode = FolderScanMode::ErasureCandidate {
|
||||
reuse_namespace: true,
|
||||
parent_has_metadata: found_object_metadata,
|
||||
};
|
||||
} else {
|
||||
folder_item.object_heal_prob_div = data_usage_update_dir_cycles();
|
||||
}
|
||||
@@ -2816,6 +2824,11 @@ impl FolderScanner {
|
||||
updates.cache.delete_recursive(&h);
|
||||
}
|
||||
into.children.remove(&clone_cache_value(&h.0));
|
||||
// Object keys can also be prefixes. A classified payload
|
||||
// directory must not hide their other namespace children.
|
||||
if found_object_metadata {
|
||||
continue;
|
||||
}
|
||||
// Earlier namespace siblings may have completed, but none
|
||||
// can certify coverage of this unresolved parent object.
|
||||
self.coverage_gap |= forward_sweep;
|
||||
|
||||
@@ -183,6 +183,117 @@ fn charged_ops(root: &std::path::Path) -> u64 {
|
||||
status.charged_ops
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn erasure_object_metadata_preserves_child_object_namespaces() {
|
||||
for (corrupt_parent, cached_parent) in [(false, false), (true, false), (false, true)] {
|
||||
let (mut scanner, root) = build_test_scanner().await;
|
||||
let _guard = TestGuard::new(60, 100, &mut scanner, root.clone());
|
||||
scanner.old_cache.info.name = "bucket".into();
|
||||
scanner.new_cache.info.name = "bucket".into();
|
||||
scanner.is_erasure_mode = true;
|
||||
bind_budget(&mut scanner, &root, 1_000_000);
|
||||
|
||||
let namespace = format!("parent/{}/nested", Uuid::new_v4());
|
||||
for object in ["parent", "parent/child", namespace.as_str()] {
|
||||
write_test_object_metadata_bytes(&root, "bucket", object, &metadata_for_object_version("bucket", object, None)).await;
|
||||
}
|
||||
let payload = root.join("bucket/parent").join(Uuid::new_v4().to_string());
|
||||
tokio::fs::create_dir_all(&payload)
|
||||
.await
|
||||
.expect("create parent payload directory");
|
||||
tokio::fs::write(payload.join("part.1"), b"shard")
|
||||
.await
|
||||
.expect("create actual payload evidence");
|
||||
if corrupt_parent {
|
||||
tokio::fs::write(root.join("bucket/parent/xl.meta"), b"")
|
||||
.await
|
||||
.expect("corrupt only the parent metadata");
|
||||
}
|
||||
if cached_parent {
|
||||
scanner.old_cache.replace("bucket", "", DataUsageEntry::default());
|
||||
scanner.old_cache.replace(
|
||||
"bucket/parent",
|
||||
"bucket",
|
||||
DataUsageEntry {
|
||||
objects: 1,
|
||||
size: 1,
|
||||
compacted: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let interval = data_usage_update_dir_cycles();
|
||||
scanner.old_cache.info.next_cycle = u64::from(
|
||||
(0..interval)
|
||||
.find(|cycle| hash_path("bucket/parent").mod_(*cycle, interval))
|
||||
.expect("one normal sampling cycle revisits the old parent-only cache"),
|
||||
);
|
||||
}
|
||||
|
||||
let mut into = DataUsageEntry::default();
|
||||
scanner
|
||||
.scan_folder(
|
||||
CancellationToken::new(),
|
||||
CachedFolder {
|
||||
name: "bucket".into(),
|
||||
parent: None,
|
||||
object_heal_prob_div: 1,
|
||||
},
|
||||
&mut into,
|
||||
)
|
||||
.await
|
||||
.expect("scan object and child namespaces independently");
|
||||
let total = scanner.new_cache.size_recursive("bucket").expect("retain bucket accounting");
|
||||
assert_eq!(total.objects, if corrupt_parent { 2 } else { 3 });
|
||||
assert_eq!(total.size, if corrupt_parent { 2 } else { 3 });
|
||||
assert_eq!(total.failed_objects, usize::from(corrupt_parent));
|
||||
assert_eq!(
|
||||
scanner.new_cache.info.failed_objects.len(),
|
||||
usize::from(corrupt_parent),
|
||||
"payload evidence must not invent missing parent metadata"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn erasure_object_prefix_children_keep_the_object_budget() {
|
||||
let (mut scanner, root) = build_test_scanner().await;
|
||||
let _guard = TestGuard::new(60, 100, &mut scanner, root.clone());
|
||||
scanner.old_cache.info.name = "bucket".into();
|
||||
scanner.new_cache.info.name = "bucket".into();
|
||||
scanner.is_erasure_mode = true;
|
||||
bind_budget(&mut scanner, &root, 1_000_000);
|
||||
for object in ["parent", "parent/child", "parent/other"] {
|
||||
write_test_object_metadata(&root, "bucket", object).await;
|
||||
}
|
||||
let visits = metadata_visits::observe(&mut scanner);
|
||||
let parent = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new_with_progress_tracking(
|
||||
&parent,
|
||||
crate::scanner_budget::ScannerCycleBudgetConfig {
|
||||
max_objects: Some(2),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
scanner.budget = budget.clone();
|
||||
let result = scanner
|
||||
.scan_folder(
|
||||
budget.token(),
|
||||
CachedFolder {
|
||||
name: "bucket".into(),
|
||||
parent: None,
|
||||
object_heal_prob_div: 1,
|
||||
},
|
||||
&mut DataUsageEntry::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "parent metadata cannot hide children or bypass their budget");
|
||||
assert_eq!(budget.reason(), Some(crate::scanner_budget::ScannerCycleBudgetReason::Objects));
|
||||
assert_eq!(budget.progress().0, 2);
|
||||
assert_eq!(visits.load(Ordering::Relaxed), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn erasure_directory_probe_reaches_eof_across_budgeted_batches() {
|
||||
@@ -204,7 +315,10 @@ async fn erasure_directory_probe_reaches_eof_across_budgeted_batches() {
|
||||
ctx,
|
||||
folder,
|
||||
&mut DataUsageEntry::default(),
|
||||
FolderScanMode::ErasureCandidate { reuse_namespace: false },
|
||||
FolderScanMode::ErasureCandidate {
|
||||
reuse_namespace: false,
|
||||
parent_has_metadata: false,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await
|
||||
@@ -271,7 +385,10 @@ async fn scanner_directory_probe_budget_wait_cancels_without_rate_delay() {
|
||||
ctx.clone(),
|
||||
folder,
|
||||
&mut DataUsageEntry::default(),
|
||||
FolderScanMode::ErasureCandidate { reuse_namespace: false },
|
||||
FolderScanMode::ErasureCandidate {
|
||||
reuse_namespace: false,
|
||||
parent_has_metadata: false,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
@@ -311,7 +428,10 @@ async fn scanner_directory_probe_cancels_during_entry_budget_wait() {
|
||||
ctx.clone(),
|
||||
folder,
|
||||
&mut DataUsageEntry::default(),
|
||||
FolderScanMode::ErasureCandidate { reuse_namespace: false },
|
||||
FolderScanMode::ErasureCandidate {
|
||||
reuse_namespace: false,
|
||||
parent_has_metadata: false,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
@@ -382,7 +502,16 @@ async fn erasure_candidate_defers_metadata_until_part_classification_finishes()
|
||||
scanner.budget = ScannerCycleBudget::new_with_progress_tracking(&ctx, Default::default());
|
||||
let mut into = DataUsageEntry::default();
|
||||
let outcome = scanner
|
||||
.scan_folder_inner(ctx, folder, &mut into, FolderScanMode::ErasureCandidate { reuse_namespace: false }, false)
|
||||
.scan_folder_inner(
|
||||
ctx,
|
||||
folder,
|
||||
&mut into,
|
||||
FolderScanMode::ErasureCandidate {
|
||||
reuse_namespace: false,
|
||||
parent_has_metadata: false,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("classify candidate");
|
||||
assert_eq!(outcome, FolderScanOutcome::ErasureData);
|
||||
@@ -594,7 +723,10 @@ async fn erasure_candidate_part_symlink_is_not_data_evidence() {
|
||||
CancellationToken::new(),
|
||||
folder,
|
||||
&mut DataUsageEntry::default(),
|
||||
FolderScanMode::ErasureCandidate { reuse_namespace: false },
|
||||
FolderScanMode::ErasureCandidate {
|
||||
reuse_namespace: false,
|
||||
parent_has_metadata: false,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -735,4 +735,166 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highwayhash_simd_matches_portable_scalar_and_chunked_slices_across_unaligned_boundaries() {
|
||||
use highway::PortableHash;
|
||||
|
||||
for &size in &[
|
||||
0usize,
|
||||
1,
|
||||
7,
|
||||
15,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
63,
|
||||
64,
|
||||
65,
|
||||
127,
|
||||
128,
|
||||
129,
|
||||
255,
|
||||
257,
|
||||
1024,
|
||||
4096 + 17,
|
||||
64 * 1024,
|
||||
] {
|
||||
let payload = generate_compat_test_data(size);
|
||||
|
||||
let mut h_simd = HighwayHasher::new(MAGIC_HIGHWAY_HASH256_PARSED_KEY);
|
||||
h_simd.append(&payload);
|
||||
let simd_digest = u8x32_from_u64x4(h_simd.finalize256());
|
||||
|
||||
let mut h_scalar = PortableHash::new(MAGIC_HIGHWAY_HASH256_PARSED_KEY);
|
||||
h_scalar.append(&payload);
|
||||
let scalar_digest = u8x32_from_u64x4(h_scalar.finalize256());
|
||||
|
||||
assert_eq!(
|
||||
simd_digest, scalar_digest,
|
||||
"HighwayHasher SIMD must match PortableHash scalar byte-for-byte at {size}B"
|
||||
);
|
||||
|
||||
for &chunk_size in &[17usize, 61, 4096] {
|
||||
if size > 4096 && chunk_size < 61 {
|
||||
continue;
|
||||
}
|
||||
let slices: Vec<&[u8]> = payload.chunks(chunk_size).collect();
|
||||
for algo in [
|
||||
HashAlgorithm::HighwayHash256S,
|
||||
HashAlgorithm::HighwayHash256SLegacy,
|
||||
HashAlgorithm::SHA256,
|
||||
HashAlgorithm::BLAKE2b512,
|
||||
HashAlgorithm::Md5,
|
||||
] {
|
||||
let contig_out = algo.hash_encode(&payload);
|
||||
let sliced_out = algo.hash_encode_slices(slices.iter().copied());
|
||||
assert_eq!(
|
||||
contig_out.as_ref(),
|
||||
sliced_out.as_ref(),
|
||||
"{algo:?} contiguous vs {chunk_size}B sliced digest mismatch at {size}B"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "utility throughput microbenchmark; run via cargo test -p rustfs-utils --lib --features hash --release -- --ignored --nocapture"]
|
||||
fn microbench_highwayhash_simd_vs_portable_scalar_and_hash_algorithms() {
|
||||
use highway::PortableHash;
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
for &(size, iters) in &[(1024usize, 200usize), (64 * 1024, 40), (1024 * 1024, 8)] {
|
||||
let payload = generate_compat_test_data(size);
|
||||
|
||||
// 1. Compare HighwayHasher (runtime SIMD: AVX2/SSE4.1/NEON) against PortableHash scalar reference.
|
||||
let t_simd = Instant::now();
|
||||
let mut simd_digest = [0u8; 32];
|
||||
for _ in 0..iters {
|
||||
let mut h = HighwayHasher::new(MAGIC_HIGHWAY_HASH256_PARSED_KEY);
|
||||
h.append(black_box(&payload));
|
||||
simd_digest = u8x32_from_u64x4(h.finalize256());
|
||||
black_box(&simd_digest);
|
||||
}
|
||||
let simd_elapsed = t_simd.elapsed();
|
||||
|
||||
let t_scalar = Instant::now();
|
||||
let mut scalar_digest = [0u8; 32];
|
||||
for _ in 0..iters {
|
||||
let mut h = PortableHash::new(MAGIC_HIGHWAY_HASH256_PARSED_KEY);
|
||||
h.append(black_box(&payload));
|
||||
scalar_digest = u8x32_from_u64x4(h.finalize256());
|
||||
black_box(&scalar_digest);
|
||||
}
|
||||
let scalar_elapsed = t_scalar.elapsed();
|
||||
|
||||
assert_eq!(
|
||||
simd_digest, scalar_digest,
|
||||
"HighwayHasher SIMD must match PortableHash scalar byte-for-byte at {size}B"
|
||||
);
|
||||
let total_mib = (size * iters) as f64 / (1024.0 * 1024.0);
|
||||
let simd_mibs = total_mib / simd_elapsed.as_secs_f64().max(1e-9);
|
||||
let scalar_mibs = total_mib / scalar_elapsed.as_secs_f64().max(1e-9);
|
||||
println!(
|
||||
"[microbench][utils::hash] size={size}B HighwayHash256S SIMD={simd_mibs:.1} MiB/s vs PortableScalar={scalar_mibs:.1} MiB/s (speedup={:.2}x)",
|
||||
simd_mibs / scalar_mibs.max(1e-9)
|
||||
);
|
||||
|
||||
// 2. Compare contiguous hash_encode vs 4 KiB chunked hash_encode_slices across all bitrot algorithms.
|
||||
let slices: Vec<&[u8]> = payload.chunks(4096).collect();
|
||||
for algo in [
|
||||
HashAlgorithm::HighwayHash256S,
|
||||
HashAlgorithm::HighwayHash256SLegacy,
|
||||
HashAlgorithm::SHA256,
|
||||
HashAlgorithm::BLAKE2b512,
|
||||
HashAlgorithm::Md5,
|
||||
] {
|
||||
let t_contig = Instant::now();
|
||||
let mut contig_out = Vec::new();
|
||||
for _ in 0..iters {
|
||||
contig_out = algo.hash_encode(black_box(&payload)).as_ref().to_vec();
|
||||
black_box(&contig_out);
|
||||
}
|
||||
let contig_elapsed = t_contig.elapsed();
|
||||
|
||||
let t_sliced = Instant::now();
|
||||
let mut sliced_out = Vec::new();
|
||||
for _ in 0..iters {
|
||||
sliced_out = algo.hash_encode_slices(black_box(slices.iter().copied())).as_ref().to_vec();
|
||||
black_box(&sliced_out);
|
||||
}
|
||||
let sliced_elapsed = t_sliced.elapsed();
|
||||
|
||||
assert_eq!(contig_out, sliced_out, "{algo:?} contiguous vs 4KiB sliced digest mismatch at {size}B");
|
||||
let contig_mibs = total_mib / contig_elapsed.as_secs_f64().max(1e-9);
|
||||
let sliced_mibs = total_mib / sliced_elapsed.as_secs_f64().max(1e-9);
|
||||
println!(
|
||||
"[microbench][utils::hash] size={size}B algo={algo:?} contiguous={contig_mibs:.1} MiB/s vs sliced(4KiB)={sliced_mibs:.1} MiB/s"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Key-routing microbenchmark: crc_hash (CRC32) vs sip_hash (SipHash-1-3).
|
||||
let keys: Vec<String> = (0..1024)
|
||||
.map(|i| format!("bucket/prefix-{i}/object-key-{i}.parquet"))
|
||||
.collect();
|
||||
let t_crc = Instant::now();
|
||||
let mut crc_acc = 0usize;
|
||||
for key in &keys {
|
||||
crc_acc ^= crc_hash(black_box(key), 16);
|
||||
}
|
||||
let crc_ns = t_crc.elapsed().as_nanos();
|
||||
|
||||
let t_sip = Instant::now();
|
||||
let mut sip_acc = 0usize;
|
||||
for key in &keys {
|
||||
sip_acc ^= sip_hash(black_box(key), 16, &[0u8; 16]);
|
||||
}
|
||||
black_box(sip_acc);
|
||||
let sip_ns = t_sip.elapsed().as_nanos();
|
||||
assert!(black_box(crc_acc) < 16 && black_box(sip_acc) < 16);
|
||||
println!("[microbench][utils::hash] 1024 object keys: crc_hash={crc_ns}ns total, sip_hash={sip_ns}ns total");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ collection:
|
||||
|
||||
## Operations
|
||||
|
||||
- [Candidate release pipeline](operations/release-pipeline.md): frozen inputs, staged acceptance, immutable publication and recovery.
|
||||
|
||||
- [Sampled local batch CPU diagnostics](operations/local-batch-cpu-diagnostics.md) — default-off request-correlated allocation and worker wall/CPU diagnosis.
|
||||
|
||||
- [Retained MRF responsibilities](operations/mrf-responsibilities.md) — unknown bucket identities, bounded probes, and unreadable checkpoint recovery.
|
||||
|
||||
@@ -130,7 +130,7 @@ These behaviors are client-visible and differ from AWS S3 or MinIO. They bound h
|
||||
| Bucket tag key or value outside the AWS tag character class (`[\p{L}\p{N}\p{Z}+\-=._:/@]`) | `400 InvalidTag` on `PutBucketTagging` | AWS enforces the same class; MinIO is more permissive | AWS parity. Legacy stored tags still load. |
|
||||
| `CopyObject` with the default `x-amz-tagging-directive: COPY` from a source that carries tags | `403 AccessDenied` unless the caller also holds `s3:GetObjectTagging` (or `s3:GetObjectVersionTagging`) on the source; the destination's `s3:RequestObjectTag/*` conditions are evaluated against the copied tag set | AWS and MinIO copy the tags with `s3:GetObject` alone | A copy would otherwise disclose source tags to a principal that may not read them, and source tags could satisfy destination conditions the caller cannot assert (rustfs/backlog#3034). Use `REPLACE` with an explicit `x-amz-tagging` to copy without the tagging grant. |
|
||||
| `ListObjectVersions` / `ListObjectsV2` with the include-deleted extension | Requires `s3:ListBucketVersions` | Vendor extension | Delete markers and non-current versions are version-listing data. |
|
||||
| `DeleteObject` of a missing key that names a prefix | `204 NoContent` when the recovery probe of the prefix fits the request budget (`DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES`, 4096 entries per drive, one entry per 64 plain children); `503 SlowDown` beyond that, deterministically, and for the duration of a rolling upgrade while a drive in the set still runs a release without the bounded recovery RPC | `204 NoContent` | The probe looks for interrupted delete transactions under the prefix and is bounded so a hostile prefix cannot pin the request. Prefixes with more than 262,144 direct children per drive, or roughly 1,400 to 4,096 UUID-named children, exceed the bound (rustfs/backlog#3038 residual). |
|
||||
| `DeleteObject` of a missing key that names a prefix | `204 NoContent` when the recovery probe of the prefix fits its per-drive allowance (`DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES`, 262,144 entries: one per UUID-named direct child, one per 64 other names), its per-call time limit (a third of the drive timeout, at most 10 seconds) and the request budget (`DeleteRecoveryWorkBudget`, shared by every key and drive of the request); `503 SlowDown` beyond that, and for the duration of a rolling upgrade while a drive in the set still runs a release without the bounded recovery RPC | `204 NoContent` | The probe looks for interrupted delete transactions of the key and is bounded so a hostile prefix cannot pin the request. A folder completes with up to 262,144 UUID-named direct children or about 16.7 million other direct children per drive when the drive can walk them within the time limit (a warm cache walks roughly 25,000 UUID-named children per second; a cold HDD far fewer); inside a UUID-named child only journal files and data-directory markers are examined, so a child key's own `xl.meta`, data directories or children cost 1/64 each. |
|
||||
| `PutObject` / `UploadPart` body delivering less than 64 KiB per 60 seconds of server wait | `400 RequestTimeout` (the minimum-progress floor, logged as `upload_body_progress_stalled`); independent of `RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT`, including when that timeout is 0 | No minimum upload rate | A trickle upload otherwise pins upload admission and its buffers indefinitely; roughly 1.1 KiB/s is below any practical client. |
|
||||
| First write after `SetBucketQuota`, or after the quota changes, on a bucket without an authoritative usage ledger | `503 SlowDown` (retryable) until a background scan of the bucket publishes the exact ledger; a failed scan retries after 30 seconds | AWS has no bucket quota; MinIO admits with unknown usage | Quota admission uses only the authoritative ledger (scanner reporting is informational, see `reporting_retirement` tests), and the request must not hold its bucket metadata guard across a whole-bucket listing. The window is one bucket listing long. |
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Candidate acceptance and release publication
|
||||
|
||||
RustFS builds the final product version before publication. A candidate is identified by its Actions run and `<version>-preview.<run_number>`; this identifier is not a Git tag or the executable's displayed version. Failed candidates consume no public version. A public tag is created only after the complete artifact manifest is approved.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Workflow | Input | Result | Public writes |
|
||||
|---|---|---|---|
|
||||
| `release-candidate.yml` | Exact final version and full source SHA | Four Linux archives, native checks, upgrade results, Linux manifest | None |
|
||||
| `release-complete.yml` | Successful candidate run, accepted Linux manifest SHA-256, backlog acceptance report | Desktop archives, DEB/RPM, OCI images, Helm chart, full certification and sealed manifest | None |
|
||||
| `release-publish.yml` | Successful completion run and approved final manifest SHA-256 | Annotated tag, GitHub Release, R2 files, registry images, stable pointers and Helm index | Explicit publication |
|
||||
|
||||
The builder workflows (`build.yml`, `package.yml`, `docker.yml`, `helm-package.yml`) are reusable stages. Only `release-publish.yml` has release write credentials. Development builds retain their artifact names and download channel. Daily development builds now select Linux only; macOS/Windows development artifacts are refreshed by an explicit manual build, and both remain mandatory for a completed release candidate. Tag pushes no longer start a second compilation.
|
||||
|
||||
## Freeze and build
|
||||
|
||||
Select a reviewed source commit whose Cargo version equals the intended final version. Finish exact-source CI before dispatching the candidate. The candidate workflow itself must run from upstream main. Unrelated main commits do not require rebuilding an accepted source.
|
||||
|
||||
```bash
|
||||
gh workflow run release-candidate.yml --ref main \
|
||||
-f version='<version>' -f source_sha='<full-source-sha>'
|
||||
```
|
||||
|
||||
The initial job records the source and workflow SHAs, release recipe, dependency lock, exact stable Rust version, Console tag/asset/SHA-256, CLI assets, previous stable release and validation plan. All platforms consume that record; none independently follows `latest`. The source commit must contain the same reviewed release recipe as the dispatcher. Relevant recipe changes require a new candidate; unrelated source changes on main do not invalidate the frozen record.
|
||||
|
||||
Linux is built first. Each archived executable must display the final version, expected target, source commit and compiler version, then start natively, serve the Console, and pass an authenticated S3 bucket/object round trip. The upgrade lane runs the existing pinned compatibility matrix plus the stable version frozen at candidate creation. It compiles the harness and runs the accepted executable; it does not rebuild a substitute server. A zero-test invocation fails.
|
||||
|
||||
The Linux manifest is partial evidence and cannot authorize publication. Download the exact artifacts for field acceptance. Record the source, manifest digest, run URL and actual tested binary digests in backlog. Required field acceptance includes Scanner/Heal and multi-pool behavior, major features, memory, high-concurrency 503s, performance against the agreed baseline, previous-version data integrity and upgrade recovery. The agreed stability duration is a release-specific requirement, not an implicit workflow default.
|
||||
|
||||
## Complete the accepted candidate
|
||||
|
||||
After the Linux report passes, dispatch `release-complete.yml` with the exact digest printed by the candidate run. The `release-linux-acceptance` environment is the operator gate. Configure its protection and allowed main branch in repository settings; a typed digest and report identify what is being accepted, while the workflow independently revalidates automated evidence.
|
||||
|
||||
```bash
|
||||
gh workflow run release-complete.yml --ref main \
|
||||
-f candidate_run_id='<run-id>' \
|
||||
-f linux_manifest_sha256='<accepted-linux-manifest-sha256>' \
|
||||
-f acceptance_report='https://github.com/rustfs/backlog/issues/<issue>#issuecomment-<comment>'
|
||||
```
|
||||
|
||||
macOS and Windows now build and run the same native checks. Packages and images wrap the accepted Linux binaries. DEB/RPM extraction must reproduce the recorded binary digest. Both OCI variants are checked on amd64 and arm64 and scanned before sealing. Vulnerability scans retain the repository's report-only policy; scanner execution errors still fail the stage. Image OS packages are resolved during packaging, then the actual resulting OCI bytes are accepted and preserved; this is not a promise of reproducible package-manager output.
|
||||
|
||||
The Helm chart is tested and packaged before sealing, with chart and application versions equal to the final product version. Full certification requires every platform and upgrade case from the original plan. The seal includes artifact hashes, raw evidence, source/input identities, acceptance report, checksums, SBOM and provenance. Successful Linux receipts remain valid while desktop results are added because each receipt binds its own exact archive and executable, the frozen plan, candidate identity and trusted producer.
|
||||
|
||||
Native smoke checks are not a substitute for Console UI and rc acceptance. Complete the release skill's UI/client checks against these exact artifacts, including desktop checks applicable to the release, before asking for final publication approval.
|
||||
|
||||
## Approve and publish
|
||||
|
||||
Review the complete run and final `release-manifest.json`. Human approval binds its SHA-256, source and version. A changed manifest requires renewed approval. No source version bump, compilation, package generation or image build occurs during publication.
|
||||
|
||||
```bash
|
||||
gh workflow run release-publish.yml --ref main \
|
||||
-f complete_run_id='<successful-completion-run-id>' \
|
||||
-f manifest_sha256='<approved-final-manifest-sha256>'
|
||||
```
|
||||
|
||||
The publisher downloads the artifact from the exact successful main workflow run, checks GitHub's artifact digest, recomputes file hashes and reruns complete certification. It creates an annotated tag whose message binds the source and manifest. An existing tag or asset with different bytes is a hard failure; tags are never moved and uploads never clobber assets.
|
||||
|
||||
Versioned GitHub assets, R2 objects and Docker Hub/GHCR/Quay manifests are uploaded and read back before the Release becomes public. OCI promotion uses `--preserve-digests`; registry conversion fails rather than silently accepting a new image. R2 creation uses atomic `If-None-Match: *`. The small evidence bundle persists with the public Release after Actions artifacts expire. Provenance is descriptive, not a claim of cryptographic attestation.
|
||||
|
||||
Publication uses a per-version lock and a shared channel lock, both without cancelling an active run. Stable channels advance only after versioned delivery succeeds. GitHub Latest acts as a monotonic marker during partial channel updates, so an older retry cannot downgrade Docker tags, R2 aliases or installer pointers. Prereleases do not advance stable channels. Helm publishes the already accepted chart after versioned images exist. GitHub's legacy `*-latest.zip` download names remain aliases of the accepted bytes; they become default downloads only when GitHub Latest advances.
|
||||
|
||||
Cross-registry publication is not atomic. A channel job can fail after some verified destinations advance. Resume the same run/seal: matching destinations are reused and conflicting ones stop the workflow. Retrying never recompiles. Do not claim complete publication until the channel receipt and all public endpoints are verified.
|
||||
|
||||
## Failure and retention
|
||||
|
||||
- Infrastructure failure: rerun failed jobs in the same run. Frozen inputs, successful platform/package/image artifacts and completed seals are reused. Missing or expired artifacts fail closed.
|
||||
- Product or build-input change before tagging: create a new candidate run and repeat affected acceptance. Do not mutate the old candidate.
|
||||
- Failure after tagging: resume publication of exactly that seal. A code fix requires a new patch version; never move the tag.
|
||||
- New main commits: retain evidence for unchanged source, recipe and inputs. Recheck only affected boundaries; do not restart the entire matrix because an unrelated PR merged.
|
||||
- Artifacts are retained for 30 days while awaiting acceptance. Public-repository Actions artifacts and staging identifiers are not confidential storage. Preserve important failure evidence in task-owned storage and backlog.
|
||||
|
||||
## Migration and workflow maintenance
|
||||
|
||||
Finish any in-flight legacy tag release, including its downstream Docker/package/Helm jobs, before enabling this cutover on main. Existing tags and Releases retain their identities. Do not feed a legacy public preview binary into the new promotion path: it embeds a different version and was not built from the frozen-input contract.
|
||||
|
||||
All workflow responsibilities, triggers and schedule ownership remain registered in `.github/scheduled-validations.json`. The migration does not re-enable workflows disabled in GitHub. PR checks, cache warming, nightly development builds, functional chains, lab tests and monitoring retain their distinct purposes; shared source or cache does not make different feature/platform tests redundant. The watchdog allocates a runner only for relevant unsuccessful scheduled/chained runs, while its registry still determines ownership.
|
||||
|
||||
Run `actionlint`, the release contract suite and the workflow registry validator after changes. The Security Audit workflow also exercises actual GitHub artifact transport, DEB/RPM package extraction, both multi-platform OCI builders, local registry promotion and archive scanning. Native packaging jobs run only for relevant changes or a manual audit. Their payloads are explicit transport fixtures, not product release acceptance. See the [release skill](../../.agents/skills/rustfs-release-publish/SKILL.md) for field acceptance, human confirmation and post-release follow-up.
|
||||
@@ -101,6 +101,7 @@ Scheduled lanes never block a PR. Their workflow-local gate fails the run. The v
|
||||
| `coverage.yml` (weekly) | `coverage` | report-only trend; lcov and JSON artifact | yes (disabled) | `make coverage` |
|
||||
| `runner-hygiene.yml` (monthly) | `check-ephemerality` | runner ephemerality | yes | dispatch |
|
||||
| `e2e-upgrade.yml` (weekly, and on version tags) | `upgrade` (serial cases sharing one harness build) | upgrade and rollback gate; server logs | yes | `python3 scripts/e2e_binary.py build`, then the job's `python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test` commands with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
||||
| `reference-parity.yml` (nightly, and on PRs that change the lane's own files) | `plan`, `build-candidate`, `s3tests` (one job per target in `.config/reference-parity-targets.toml`), `compare` | report-only parity gate: fails on any per-node-id s3-tests difference between a branch candidate and the release pinned in `.config/reference-binary.toml` that `.config/reference-parity-tolerated.txt` does not list, and on a stale tolerated entry; per-target JUnit, logs and `diff.md`/`diff.json` | yes | `DEPLOY_MODE=binary RUSTFS_BINARY=<binary> MAXFAIL=0 XDIST=4 ./scripts/s3-tests/run.sh` once per target, then `python3 scripts/s3-tests/reference_parity.py compare` as in the `compare` job |
|
||||
| `oidc-keycloak.yml` (weekly) | `oidc-keycloak-live` | live OIDC gate | yes | see the PR row |
|
||||
| `targets-integration.yml` (nightly) | `targets-live` | live target gate; container logs | yes (disabled) | see the PR row |
|
||||
| `gateway-integration-sync.yml` (weekdays) | `sync` | merges `main` into `gateway/integration` as a `--no-ff` merge commit; a conflicting merge is aborted and opens a `sync/<source>-<date>` pull request listing the conflicting files, which keeps the run green; a rejected push or missing pull request fails it | yes | dispatch, optionally with `source_ref` |
|
||||
|
||||
@@ -8,6 +8,8 @@ The classifier reads the pull request base revision's planner and policy. It com
|
||||
|
||||
The committed policy is `enforced`: ordinary Rust changes select their reverse dependency closure plus the fixed core inventory, and unrelated changes can omit the Connect specialization. Package-owned fixtures, non-Rust inputs, build scripts, manifests, unknown paths and non-PR events retain full validation. Existing documentation and isolated E2E scopes remain conservative. Changing a PR's own policy cannot authorize skipping its base revision's requirements. A base planner predating the current workflow contract clears all narrower outputs and selects the full matrix. Set the policy back to `shadow` to retain proposed selections while restoring full ordinary-code execution; do not remove core inventories or negative checks as a rollback.
|
||||
|
||||
Stable installation-version alignment has one content-based exception in enforced mode: literal version fields in `flake.nix`, `helm/rustfs/Chart.yaml` and `rustfs.spec`, plus a standard prepended RPM release note, can use the documentation scope. Quick Checks, Typos, CLA and the required aggregate remain. The planner compares both complete Git blobs and requires ordinary non-executable files; changes to build logic, CLI pins, chart settings, RPM macros or older changelog entries retain the full gate. New/deleted files, mode changes, prerelease syntax, unavailable history, shadow rollout and explicit full-path policy overrides also retain full validation. Product-code changes in the same PR retain their normal dependency and core coverage, and main pushes, merge groups, scheduled and manual runs still validate the complete candidate. This exception does not establish artifact availability or authorize a release; post-release checks still verify the published target before updating defaults.
|
||||
|
||||
`Test and Lint` remains the required aggregate name. Every verification producer and consumer is a direct dependency. The verifier accepts only the effective plan's exact expected success/skipped outcomes; failure, cancellation, missing results, extra jobs or a plan for a different candidate fail. Quick Checks and CLA retain their separate required names.
|
||||
|
||||
## Build and execute
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
rustfs = rustPlatform.buildRustPackage {
|
||||
pname = "rustfs";
|
||||
version = "1.0.4";
|
||||
version = "1.0.5";
|
||||
|
||||
src = ./.;
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: rustfs
|
||||
description: RustFS helm chart to deploy RustFS on kubernetes cluster.
|
||||
type: application
|
||||
version: "1.0.4"
|
||||
appVersion: "1.0.4"
|
||||
version: "1.0.5"
|
||||
appVersion: "1.0.5"
|
||||
home: https://rustfs.com
|
||||
icon: https://media.sys.truenas.net/apps/rustfs/icons/icon.svg
|
||||
maintainers:
|
||||
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
%global _enable_debug_packages 0
|
||||
%global _empty_manifest_terminate_build 0
|
||||
Name: rustfs
|
||||
Version: 1.0.4
|
||||
Version: 1.0.5
|
||||
Release: 1
|
||||
Summary: High-performance distributed object storage for MinIO alternative
|
||||
|
||||
@@ -57,6 +57,9 @@ install %_builddir/%{name}-%{version}/target/%_arch/%_arch-unknown-linux-gnu/rel
|
||||
%_bindir/rustfs
|
||||
|
||||
%changelog
|
||||
* Wed Nov 11 2026 overtrue <anzhengchao@gmail.com>
|
||||
- Update RPM package to RustFS 1.0.5
|
||||
|
||||
* Mon Oct 26 2026 overtrue <anzhengchao@gmail.com>
|
||||
- Update RPM package to RustFS 1.0.4
|
||||
|
||||
|
||||
@@ -254,11 +254,15 @@ impl DefaultAdminUsecase {
|
||||
/// absent from `buckets_usage` — absent already means "unknown" on the
|
||||
/// wire, distinct from a present zero — and the snapshot is marked
|
||||
/// non-converged so clients can tell it is not the whole namespace. A
|
||||
/// snapshot that is structurally incomplete, or that measured nothing the
|
||||
/// namespace still contains, carries no usable information and is dropped
|
||||
/// exactly as before.
|
||||
fn narrow_data_usage_snapshot_to_measured_buckets(info: &mut DataUsageInfo, buckets: impl IntoIterator<Item = String>) {
|
||||
if !info.is_complete_bucket_usage_snapshot() && !info.is_valid_partial_snapshot() {
|
||||
/// snapshot that is structurally incomplete (`snapshot_measured` is false),
|
||||
/// or that measured nothing the namespace still contains, carries no usable
|
||||
/// information and is dropped exactly as before.
|
||||
fn narrow_data_usage_snapshot_to_measured_buckets(
|
||||
info: &mut DataUsageInfo,
|
||||
snapshot_measured: bool,
|
||||
buckets: impl IntoIterator<Item = String>,
|
||||
) {
|
||||
if !snapshot_measured {
|
||||
*info = DataUsageInfo::default();
|
||||
return;
|
||||
}
|
||||
@@ -275,13 +279,31 @@ impl DefaultAdminUsecase {
|
||||
|
||||
let covers_namespace = current_buckets.len() == info.buckets_usage.len();
|
||||
info.buckets_count = info.buckets_usage.len() as u64;
|
||||
if !covers_namespace {
|
||||
// Some bucket in the namespace was never measured, so the totals
|
||||
// below are a floor rather than the converged truth.
|
||||
if !covers_namespace || !info.is_complete_bucket_usage_snapshot() {
|
||||
// Some bucket in the namespace was never measured, or holds
|
||||
// mutations no scan has reconciled yet, so the totals below are
|
||||
// not the converged truth.
|
||||
info.usage_snapshot_converged = Some(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay single-process counts on a persisted snapshot and narrow it
|
||||
/// for display.
|
||||
///
|
||||
/// Whether the snapshot measured anything is decided before the overlay.
|
||||
/// The overlay clears `usage_snapshot_complete` when it holds counts it
|
||||
/// cannot reconcile with the snapshot, such as writes into a bucket that
|
||||
/// has no verified baseline. That makes the totals unconverged, not the
|
||||
/// scanned buckets unusable; treating it as structural incompleteness
|
||||
/// blanked usage for every bucket for as long as one such bucket kept
|
||||
/// receiving writes, because each write restarts its scanner fence.
|
||||
async fn present_data_usage_snapshot(mut info: DataUsageInfo, buckets: impl IntoIterator<Item = String>) -> DataUsageInfo {
|
||||
let snapshot_measured = info.is_complete_bucket_usage_snapshot() || info.is_valid_partial_snapshot();
|
||||
apply_bucket_usage_memory_overlay(&mut info).await;
|
||||
Self::narrow_data_usage_snapshot_to_measured_buckets(&mut info, snapshot_measured, buckets);
|
||||
info
|
||||
}
|
||||
|
||||
async fn rebalance_status_snapshot(store: &ECStore) -> AdminUsecaseResult<Option<RebalanceMeta>> {
|
||||
store.rebalance_status_meta_snapshot().await.map_err(|err| {
|
||||
error!("refresh rebalance metadata for pool status failed: {:?}", err);
|
||||
@@ -323,8 +345,7 @@ impl DefaultAdminUsecase {
|
||||
/// This path never triggers a live full-version listing
|
||||
/// (rustfs/backlog#1306); freshness is owned by the scanner.
|
||||
pub(crate) async fn query_data_usage_info_with_store(store: Arc<ECStore>) -> AdminUsecaseResult<DataUsageInfo> {
|
||||
let mut info = Self::map_data_usage_load_result(load_admin_data_usage_from_backend_cached(store.clone()).await)?;
|
||||
apply_bucket_usage_memory_overlay(&mut info).await;
|
||||
let info = Self::map_data_usage_load_result(load_admin_data_usage_from_backend_cached(store.clone()).await)?;
|
||||
let buckets = store
|
||||
.list_bucket(&BucketOptions {
|
||||
cached: true,
|
||||
@@ -333,11 +354,7 @@ impl DefaultAdminUsecase {
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Self::app_error(S3ErrorCode::InternalError, "list_bucket failed"))?;
|
||||
// Serve what the scan measured; buckets it never reached stay absent
|
||||
// (absent means unknown on the wire, unlike a present zero) and the
|
||||
// snapshot is flagged non-converged. See rustfs#5806 for why blanking
|
||||
// the whole document instead was worse than the rule it enforced.
|
||||
Self::narrow_data_usage_snapshot_to_measured_buckets(&mut info, buckets.into_iter().map(|bucket| bucket.name));
|
||||
let mut info = Self::present_data_usage_snapshot(info, buckets.into_iter().map(|bucket| bucket.name)).await;
|
||||
|
||||
let storage_info = StorageAdminApi::storage_info(store.as_ref()).await;
|
||||
|
||||
@@ -876,6 +893,12 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// Narrow a snapshot no overlay has touched, judging it as persisted.
|
||||
fn narrow_persisted_snapshot(info: &mut DataUsageInfo, buckets: impl IntoIterator<Item = String>) {
|
||||
let snapshot_measured = info.is_complete_bucket_usage_snapshot() || info.is_valid_partial_snapshot();
|
||||
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(info, snapshot_measured, buckets);
|
||||
}
|
||||
|
||||
/// rustfs#5806: a bucket the last scan never reached must not blank the
|
||||
/// usage the scan *did* measure — that turned every bucket creation into a
|
||||
/// deployment-wide usage blackout until the next cycle.
|
||||
@@ -897,10 +920,7 @@ mod tests {
|
||||
// A brand-new bucket the scan never saw: keep bucket-a, omit bucket-new,
|
||||
// and say so via the convergence flag.
|
||||
let mut info = measured("bucket-a");
|
||||
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(
|
||||
&mut info,
|
||||
["bucket-a".to_string(), "bucket-new".to_string(), ".rustfs.sys".to_string()],
|
||||
);
|
||||
narrow_persisted_snapshot(&mut info, ["bucket-a".to_string(), "bucket-new".to_string(), ".rustfs.sys".to_string()]);
|
||||
assert!(info.buckets_usage.contains_key("bucket-a"), "measured usage must survive");
|
||||
assert!(!info.buckets_usage.contains_key("bucket-new"), "unmeasured bucket stays absent");
|
||||
assert_eq!(info.usage_snapshot_converged, Some(false));
|
||||
@@ -909,22 +929,19 @@ mod tests {
|
||||
|
||||
// Full coverage leaves the snapshot untouched.
|
||||
let mut info = measured("bucket-a");
|
||||
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(
|
||||
&mut info,
|
||||
["bucket-a".to_string(), ".rustfs.sys".to_string()],
|
||||
);
|
||||
narrow_persisted_snapshot(&mut info, ["bucket-a".to_string(), ".rustfs.sys".to_string()]);
|
||||
assert_eq!(info.usage_snapshot_converged, None, "a covering snapshot is not downgraded");
|
||||
assert_eq!(info.buckets_count, 1);
|
||||
|
||||
// A bucket deleted since the scan must not linger in the response.
|
||||
let mut info = measured("bucket-gone");
|
||||
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(&mut info, ["bucket-live".to_string()]);
|
||||
narrow_persisted_snapshot(&mut info, ["bucket-live".to_string()]);
|
||||
assert_eq!(info, DataUsageInfo::default(), "nothing measured is still in the namespace");
|
||||
|
||||
// Structurally incomplete snapshots carry no usable information.
|
||||
let mut info = measured("bucket-a");
|
||||
info.usage_snapshot_complete = false;
|
||||
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(&mut info, ["bucket-a".to_string()]);
|
||||
narrow_persisted_snapshot(&mut info, ["bucket-a".to_string()]);
|
||||
assert_eq!(info, DataUsageInfo::default());
|
||||
|
||||
// A structurally valid partial admin view can still carry useful
|
||||
@@ -945,7 +962,7 @@ mod tests {
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: None,
|
||||
}];
|
||||
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(&mut info, ["bucket-a".to_string()]);
|
||||
narrow_persisted_snapshot(&mut info, ["bucket-a".to_string()]);
|
||||
assert_eq!(info.usage_snapshot_converged, Some(false));
|
||||
assert_eq!(info.buckets_count, 1);
|
||||
assert_eq!(info.objects_total_count, 7);
|
||||
@@ -957,11 +974,63 @@ mod tests {
|
||||
buckets_count: 0,
|
||||
..Default::default()
|
||||
};
|
||||
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(&mut info, [".rustfs.sys".to_string()]);
|
||||
narrow_persisted_snapshot(&mut info, [".rustfs.sys".to_string()]);
|
||||
assert_eq!(info.usage_snapshot_converged, None);
|
||||
assert_eq!(info.buckets_count, 0);
|
||||
}
|
||||
|
||||
/// A write into a bucket without a verified baseline leaves process-local
|
||||
/// counts the overlay cannot reconcile, so it clears
|
||||
/// `usage_snapshot_complete`. That must flag the response non-converged,
|
||||
/// not erase every bucket the persisted scan measured: under continuous
|
||||
/// writes the scanner fence never clears and the blackout never ends.
|
||||
#[tokio::test]
|
||||
async fn unverified_bucket_writes_do_not_blank_measured_usage() {
|
||||
let measured_bucket = "admin-usage-measured";
|
||||
let unverified_bucket = "admin-usage-unverified";
|
||||
let mut persisted = DataUsageInfo {
|
||||
last_update: Some(std::time::SystemTime::now() - std::time::Duration::from_secs(60)),
|
||||
usage_snapshot_complete: true,
|
||||
buckets_count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
persisted.buckets_usage.insert(
|
||||
measured_bucket.to_string(),
|
||||
rustfs_data_usage::BucketUsageInfo {
|
||||
objects_count: 4_000,
|
||||
versions_count: 4_000,
|
||||
size: 8_192_000,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
persisted.bucket_sizes.insert(measured_bucket.to_string(), 8_192_000);
|
||||
persisted.calculate_totals();
|
||||
assert!(persisted.is_complete_bucket_usage_snapshot());
|
||||
|
||||
crate::app::storage_api::test::data_usage::record_bucket_object_write_memory(unverified_bucket, None, 1_024).await;
|
||||
|
||||
let info = DefaultAdminUsecase::present_data_usage_snapshot(
|
||||
persisted,
|
||||
[measured_bucket.to_string(), unverified_bucket.to_string()],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
info.buckets_usage
|
||||
.get(measured_bucket)
|
||||
.map(|usage| (usage.objects_count, usage.size)),
|
||||
Some((4_000, 8_192_000)),
|
||||
"usage the scanner measured must survive an unrelated unverified bucket"
|
||||
);
|
||||
assert!(
|
||||
!info.buckets_usage.contains_key(unverified_bucket),
|
||||
"a bucket without a verified baseline stays absent (unknown)"
|
||||
);
|
||||
assert_eq!(info.usage_snapshot_converged, Some(false));
|
||||
assert!(!info.is_complete_bucket_usage_snapshot());
|
||||
assert_eq!(info.objects_total_count, 4_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_query_storage_info_returns_internal_error_when_store_uninitialized() {
|
||||
let usecase = DefaultAdminUsecase::without_context();
|
||||
|
||||
@@ -843,7 +843,7 @@ mod tests {
|
||||
};
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
DefaultBucketUsecase::from_global().execute_list_objects_v2(request),
|
||||
crate::app::test_seams::list_objects_v2(&DefaultBucketUsecase::from_global(), request),
|
||||
)
|
||||
.await
|
||||
.expect("listing must complete within its bounded source budget")
|
||||
@@ -1241,19 +1241,23 @@ mod tests {
|
||||
let mut list = get.clone().map_input(|_| input.clone());
|
||||
list.uri = http::Uri::from_static("/?list-type=2");
|
||||
let usecase = crate::app::object::DefaultObjectUsecase::from_global();
|
||||
let get_error = tokio::time::timeout(Duration::from_secs(10), usecase.execute_get_object(get))
|
||||
.await
|
||||
.expect("GET stays local")
|
||||
.expect_err("local object is absent");
|
||||
let get_error =
|
||||
tokio::time::timeout(Duration::from_secs(10), crate::app::test_seams::get_object(&usecase, get))
|
||||
.await
|
||||
.expect("GET stays local")
|
||||
.expect_err("local object is absent");
|
||||
assert_eq!(*get_error.code(), S3ErrorCode::NoSuchKey);
|
||||
let head_error = tokio::time::timeout(Duration::from_secs(10), usecase.execute_head_object(head))
|
||||
.await
|
||||
.expect("HEAD stays local")
|
||||
.expect_err("local object is absent");
|
||||
let head_error = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
crate::app::test_seams::head_object(&usecase, head),
|
||||
)
|
||||
.await
|
||||
.expect("HEAD stays local")
|
||||
.expect_err("local object is absent");
|
||||
assert_eq!(*head_error.code(), S3ErrorCode::NoSuchKey);
|
||||
let listing = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
DefaultBucketUsecase::from_global().execute_list_objects_v2(list),
|
||||
crate::app::test_seams::list_objects_v2(&DefaultBucketUsecase::from_global(), list),
|
||||
)
|
||||
.await
|
||||
.expect("LIST stays local")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Shared fixtures for the lifecycle integration tests; owned by no operation batch.
|
||||
|
||||
use super::*;
|
||||
|
||||
static GLOBAL_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new();
|
||||
|
||||
pub(super) const TRANSITION_WAIT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub(super) const RUSTFS_EXPECTED_CURRENT_VERSION_ID: &str = "x-rustfs-expected-current-version-id";
|
||||
|
||||
fn init_tracing() {
|
||||
rustfs_test_utils::init_tracing();
|
||||
}
|
||||
|
||||
pub(super) async fn setup_test_env() -> (Vec<PathBuf>, Arc<ECStore>) {
|
||||
init_tracing();
|
||||
|
||||
if let Some((paths, ecstore)) = GLOBAL_ENV.get() {
|
||||
return (paths.clone(), ecstore.clone());
|
||||
}
|
||||
|
||||
// Production startup installs this proof through the notification-system
|
||||
// fleet probe. This isolated single-node harness constructs ECStore
|
||||
// directly, so model that completed all-v6 probe before any journal permit
|
||||
// or background expiry worker can exist.
|
||||
install_all_v6_fleet_capability_proof();
|
||||
|
||||
let test_base_dir = format!("/tmp/rustfs_app_lifecycle_test_{}", Uuid::new_v4());
|
||||
let temp_dir = PathBuf::from(&test_base_dir);
|
||||
if temp_dir.exists() {
|
||||
fs::remove_dir_all(&temp_dir).await.ok();
|
||||
}
|
||||
fs::create_dir_all(&temp_dir).await.unwrap();
|
||||
|
||||
let disk_paths = vec![
|
||||
temp_dir.join("disk1"),
|
||||
temp_dir.join("disk2"),
|
||||
temp_dir.join("disk3"),
|
||||
temp_dir.join("disk4"),
|
||||
];
|
||||
|
||||
for disk_path in &disk_paths {
|
||||
fs::create_dir_all(disk_path).await.unwrap();
|
||||
}
|
||||
|
||||
let mut endpoints = Vec::new();
|
||||
for (i, disk_path) in disk_paths.iter().enumerate() {
|
||||
let mut endpoint = Endpoint::try_from(disk_path.to_str().unwrap()).unwrap();
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(i);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
|
||||
let pool_endpoints = PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "test".to_string(),
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
};
|
||||
|
||||
let endpoint_pools = EndpointServerPools(vec![pool_endpoints]);
|
||||
|
||||
super::super::storage_api::test::runtime::init_local_disks(endpoint_pools.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let server_addr: std::net::SocketAddr = "127.0.0.1:9003".parse().unwrap();
|
||||
let ecstore = ECStore::new(server_addr, endpoint_pools, CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let buckets_list = ecstore
|
||||
.list_bucket(&BucketOptions {
|
||||
no_metadata: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buckets = buckets_list.into_iter().map(|v| v.name).collect();
|
||||
metadata_sys::init_bucket_metadata_sys(ecstore.clone(), buckets).await;
|
||||
|
||||
lifecycle::bucket_lifecycle_ops::init_background_expiry(ecstore.clone()).await;
|
||||
|
||||
// The usecases resolve the object store through the ambient AppContext
|
||||
// since the backlog#1052 InstanceContext migration; without one installed
|
||||
// every store-touching usecase call fails with InternalError "Not init".
|
||||
// Publish a default-interface context wrapping this test store.
|
||||
crate::app::runtime_sources::install_test_app_context(ecstore.clone()).await;
|
||||
|
||||
let _ = GLOBAL_ENV.set((disk_paths.clone(), ecstore.clone()));
|
||||
|
||||
(disk_paths, ecstore)
|
||||
}
|
||||
|
||||
pub(super) async fn create_test_bucket(ecstore: &Arc<ECStore>, bucket_name: &str) {
|
||||
(**ecstore)
|
||||
.make_bucket(
|
||||
bucket_name,
|
||||
&MakeBucketOptions {
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create test bucket");
|
||||
}
|
||||
|
||||
pub(super) async fn upload_test_object(ecstore: &Arc<ECStore>, bucket: &str, object: &str, data: &[u8]) -> ObjectInfo {
|
||||
let mut reader = PutObjReader::from_vec(data.to_vec());
|
||||
(**ecstore)
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to upload test object")
|
||||
}
|
||||
|
||||
pub(super) async fn set_bucket_lifecycle_transition_with_tier(
|
||||
bucket_name: &str,
|
||||
storage_class: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let lifecycle_xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LifecycleConfiguration>
|
||||
<Rule>
|
||||
<ID>test-rule</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<Prefix>test/</Prefix>
|
||||
</Filter>
|
||||
<Transition>
|
||||
<Days>0</Days>
|
||||
<StorageClass>{storage_class}</StorageClass>
|
||||
</Transition>
|
||||
</Rule>
|
||||
</LifecycleConfiguration>"#
|
||||
);
|
||||
|
||||
metadata_sys::update(bucket_name, BUCKET_LIFECYCLE_CONFIG, lifecycle_xml.into_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn set_bucket_object_lock_enabled(bucket_name: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let object_lock_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ObjectLockConfiguration>
|
||||
<ObjectLockEnabled>Enabled</ObjectLockEnabled>
|
||||
</ObjectLockConfiguration>"#;
|
||||
metadata_sys::update(bucket_name, OBJECT_LOCK_CONFIG, object_lock_xml.as_bytes().to_vec()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register the shared [`MockWarmBackend`] into this instance's tier config
|
||||
/// manager. Thin wrapper over the shared `register_mock_tier` helper
|
||||
/// (rustfs/backlog#1148 ilm-6).
|
||||
pub(super) async fn register_mock_tier(tier_name: &str) -> MockWarmBackend {
|
||||
register_mock_tier_util(¤t_tier_config_handle(), tier_name).await
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_transition(
|
||||
ecstore: &Arc<ECStore>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<ObjectInfo, String> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
let observed = (**ecstore).get_object_info(bucket, object, &ObjectOptions::default()).await;
|
||||
let observed = match observed {
|
||||
Ok(info) if info.transitioned_object.status == "complete" => return Ok(info),
|
||||
other => other,
|
||||
};
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
let last_observation = match observed {
|
||||
Ok(info) => format!("transitioned_status={:?}", info.transitioned_object.status),
|
||||
Err(error) => format!("metadata_error={:?}", error.code()),
|
||||
};
|
||||
let state = get_global_transition_state();
|
||||
return Err(format!(
|
||||
"transition did not complete within {timeout:?}; {last_observation}; pending={}, active={}, compensation_pending={}, compensation_running={}",
|
||||
state.pending_tasks(),
|
||||
state.active_tasks(),
|
||||
state.compensation_pending_tasks(),
|
||||
state.compensation_running_tasks(),
|
||||
));
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: this helper is used only by `#[serial]` tests and runs under the single-threaded Tokio
|
||||
// runtime (`worker_threads = 1`), so no concurrent test can mutate process environment during the
|
||||
// `env::set_var` / `env::remove_var` window.
|
||||
#[allow(unsafe_code)]
|
||||
pub(super) async fn with_forced_immediate_enqueue_timeout<F, Fut>(backend: &MockWarmBackend, test_fn: F)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = ()>,
|
||||
{
|
||||
let transition_state = get_global_transition_state();
|
||||
let missed_before = transition_state.missed_immediate_tasks();
|
||||
let compensation_before = transition_state.compensation_scheduled_tasks();
|
||||
let put_barrier = backend.arm_put_barrier().await;
|
||||
let original = env::var_os(ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT);
|
||||
unsafe {
|
||||
env::set_var(ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT, "1");
|
||||
}
|
||||
let result = std::panic::AssertUnwindSafe(test_fn()).catch_unwind().await;
|
||||
match original {
|
||||
Some(value) => unsafe {
|
||||
env::set_var(ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT, value);
|
||||
},
|
||||
None => unsafe {
|
||||
env::remove_var(ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT);
|
||||
},
|
||||
}
|
||||
if let Err(err) = result {
|
||||
std::panic::resume_unwind(err);
|
||||
}
|
||||
|
||||
assert!(
|
||||
transition_state.missed_immediate_tasks() > missed_before,
|
||||
"the operation should exercise the forced immediate enqueue failure"
|
||||
);
|
||||
assert!(
|
||||
transition_state.compensation_scheduled_tasks() > compensation_before,
|
||||
"the operation should schedule automatic compensation backfill"
|
||||
);
|
||||
|
||||
// Separate automatic discovery and queue latency from the metadata commit deadline.
|
||||
put_barrier.wait_until_paused().await;
|
||||
assert_eq!(backend.object_count().await, 1, "compensation should store the body before committing");
|
||||
put_barrier.release();
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_delete_marker(ecstore: &Arc<ECStore>, bucket: &str, object: &str, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
if let Ok(info) = ecstore.get_object_info(bucket, object, &ObjectOptions::default()).await
|
||||
&& info.delete_marker
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_request<T>(input: T, method: Method) -> S3Request<T> {
|
||||
S3Request {
|
||||
input,
|
||||
method,
|
||||
uri: Uri::from_static("/"),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn streaming_blob_from_bytes(data: &[u8]) -> StreamingBlob {
|
||||
let body = Bytes::copy_from_slice(data);
|
||||
StreamingBlob::wrap::<_, Infallible>(stream::once(async move { Ok(body) }))
|
||||
}
|
||||
|
||||
pub(super) async fn read_object_bytes(ecstore: &Arc<ECStore>, bucket: &str, object: &str) -> Vec<u8> {
|
||||
let mut reader = (**ecstore)
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to read object");
|
||||
let mut buf = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut buf)
|
||||
.await
|
||||
.expect("Failed to drain object reader");
|
||||
buf
|
||||
}
|
||||
|
||||
pub(super) async fn live_object_version_count(ecstore: &Arc<ECStore>, bucket: &str, object: &str) -> usize {
|
||||
let versions = ecstore
|
||||
.clone()
|
||||
.list_object_versions(bucket, object, None, None, None, 1000)
|
||||
.await
|
||||
.expect("Failed to list object versions");
|
||||
|
||||
versions
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|info| info.name == object && !info.delete_marker)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub(super) async fn install_delete_replication_config(
|
||||
bucket: &str,
|
||||
delete_enabled: bool,
|
||||
replica_modifications: bool,
|
||||
required_tag: Option<(&str, &str)>,
|
||||
) {
|
||||
use super::super::storage_api::test::{bucket::utils::serialize, get_global_bucket_metadata_sys, set_bucket_metadata};
|
||||
|
||||
let sys = get_global_bucket_metadata_sys().expect("bucket metadata system should be initialized");
|
||||
let metadata = {
|
||||
let sys = sys.read().await;
|
||||
sys.get(bucket)
|
||||
.await
|
||||
.expect("bucket metadata should be cached before replication config injection")
|
||||
};
|
||||
let mut metadata = (*metadata).clone();
|
||||
let target = "arn:aws:s3:::target-bucket";
|
||||
let delete_status = if delete_enabled {
|
||||
Status::ENABLED.to_string()
|
||||
} else {
|
||||
Status::DISABLED.to_string()
|
||||
};
|
||||
|
||||
metadata.versioning_config_xml = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec();
|
||||
metadata.versioning_config = Some(rustfs_gateway_types::persistence::PersistedVersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
..Default::default()
|
||||
});
|
||||
let filter = required_tag.map(|(key, value)| PersistedReplicationFilter {
|
||||
tag: Some(PersistedReplicationTag {
|
||||
key: Some(key.to_string()),
|
||||
value: Some(value.to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let source_selection_criteria = replica_modifications.then(|| PersistedSourceSelectionCriteria {
|
||||
replica_modifications: Some(PersistedReplicationStatus {
|
||||
status: Status::ENABLED.to_string(),
|
||||
}),
|
||||
sse_kms_encrypted_objects: None,
|
||||
});
|
||||
let replication_config = PersistedReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![PersistedReplicationRule {
|
||||
delete_marker_replication: Some(PersistedOptionalReplicationStatus {
|
||||
status: Some(Status::DISABLED.to_string()),
|
||||
}),
|
||||
delete_replication: Some(PersistedReplicationStatus { status: delete_status }),
|
||||
destination: PersistedReplicationDestination {
|
||||
bucket: target.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
existing_object_replication: None,
|
||||
filter,
|
||||
id: Some("delete".to_string()),
|
||||
prefix: Some(String::new()),
|
||||
priority: Some(1),
|
||||
source_selection_criteria,
|
||||
status: Status::ENABLED.to_string(),
|
||||
}],
|
||||
};
|
||||
metadata.replication_config_xml = serialize(&replication_config).expect("replication test config should serialize");
|
||||
metadata.replication_config = Some(replication_config);
|
||||
set_bucket_metadata(bucket.to_string(), metadata)
|
||||
.await
|
||||
.expect("delete replication metadata should be installed for the request test");
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! CopyObject tests, including tests that also PUT (batch E owns CopyObject).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
use crate::app::test_seams;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn copy_object_if_none_match_existing_destination_returns_precondition_failed() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let src_bucket = format!("test-copy-if-none-match-src-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let dst_bucket = format!("test-copy-if-none-match-dst-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let src_object = "test/source.txt";
|
||||
let dst_object = "test/destination.txt";
|
||||
let src_payload = b"conditional copy source payload";
|
||||
let dst_payload = b"conditional copy destination payload";
|
||||
|
||||
create_test_bucket(&ecstore, src_bucket.as_str()).await;
|
||||
create_test_bucket(&ecstore, dst_bucket.as_str()).await;
|
||||
let _ = upload_test_object(&ecstore, src_bucket.as_str(), src_object, src_payload).await;
|
||||
let _ = upload_test_object(&ecstore, dst_bucket.as_str(), dst_object, dst_payload).await;
|
||||
|
||||
let dst_info = ecstore
|
||||
.get_object_info(dst_bucket.as_str(), dst_object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to fetch destination object info");
|
||||
let dst_etag = dst_info.etag.expect("destination object should have an ETag");
|
||||
let initial_versions = live_object_version_count(&ecstore, dst_bucket.as_str(), dst_object).await;
|
||||
|
||||
for (condition, expected_code) in [
|
||||
(ETagCondition::Any, S3ErrorCode::PreconditionFailed),
|
||||
(ETagCondition::ETag(ETag::Strong(dst_etag)), S3ErrorCode::NotImplemented),
|
||||
(
|
||||
ETagCondition::ETag(ETag::Strong("different-etag".to_string())),
|
||||
S3ErrorCode::NotImplemented,
|
||||
),
|
||||
] {
|
||||
let header = condition.to_http_header().expect("conditional COPY header should encode");
|
||||
// Cover header-only callers and the DTO-plus-header shape of parsed HTTP requests.
|
||||
for if_none_match in [None, Some(condition)] {
|
||||
let has_dto = if_none_match.is_some();
|
||||
let copy_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: src_bucket.clone().into(),
|
||||
key: src_object.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(dst_bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.if_none_match(if_none_match)
|
||||
.build()
|
||||
.expect("conditional COPY input should build");
|
||||
let mut req = build_request(copy_input, Method::PUT);
|
||||
req.headers.insert(IF_NONE_MATCH, header.clone());
|
||||
|
||||
let err = Box::pin(usecase.execute_copy_object(req))
|
||||
.await
|
||||
.expect_err("conditional COPY must reject the replacement");
|
||||
assert_eq!(err.code(), &expected_code, "header={header:?}, has_dto={has_dto}");
|
||||
assert_eq!(
|
||||
read_object_bytes(&ecstore, dst_bucket.as_str(), dst_object).await,
|
||||
dst_payload,
|
||||
"failed conditional CopyObject must not overwrite the current destination object"
|
||||
);
|
||||
assert_eq!(
|
||||
live_object_version_count(&ecstore, dst_bucket.as_str(), dst_object).await,
|
||||
initial_versions,
|
||||
"failed conditional CopyObject must not create a version"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state object-version integration test: runs serialized in the CI ILM Integration (serial) lane"]
|
||||
async fn undo_copy_requires_the_observed_current_version() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-undo-copy-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let put = |payload: &'static [u8]| {
|
||||
PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(payload)))
|
||||
.content_length(Some(payload.len() as i64))
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let original = Box::pin(test_seams::put_object(&usecase, &fs, build_request(put(b"original"), Method::PUT)))
|
||||
.await
|
||||
.expect("initial version should be written")
|
||||
.output
|
||||
.version_id
|
||||
.expect("versioned PUT should return a version ID");
|
||||
let observed_current = Box::pin(test_seams::put_object(&usecase, &fs, build_request(put(b"replacement"), Method::PUT)))
|
||||
.await
|
||||
.expect("replacement version should be written")
|
||||
.output
|
||||
.version_id
|
||||
.expect("versioned PUT should return a version ID");
|
||||
|
||||
let copy = || {
|
||||
CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: object.to_string().into(),
|
||||
version_id: Some(original.clone().into()),
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let mut stale_req = build_request(copy(), Method::PUT);
|
||||
stale_req.headers.insert(
|
||||
RUSTFS_EXPECTED_CURRENT_VERSION_ID,
|
||||
HeaderValue::from_str(&Uuid::new_v4().to_string()).unwrap(),
|
||||
);
|
||||
let versions_before = live_object_version_count(&ecstore, bucket.as_str(), object).await;
|
||||
let err = Box::pin(usecase.execute_copy_object(stale_req))
|
||||
.await
|
||||
.expect_err("stale destination must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed);
|
||||
assert_eq!(live_object_version_count(&ecstore, bucket.as_str(), object).await, versions_before);
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, b"replacement");
|
||||
|
||||
let mut matching_req = build_request(copy(), Method::PUT);
|
||||
matching_req
|
||||
.headers
|
||||
.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_str(&observed_current).unwrap());
|
||||
Box::pin(usecase.execute_copy_object(matching_req))
|
||||
.await
|
||||
.expect("matching destination version should permit undo copy");
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, b"original");
|
||||
assert_eq!(live_object_version_count(&ecstore, bucket.as_str(), object).await, versions_before + 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state object-version integration test: runs serialized in the CI ILM Integration (serial) lane"]
|
||||
async fn undo_copy_serializes_a_concurrent_put_until_after_commit() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("test-undo-race-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let put = |payload: &'static [u8]| {
|
||||
PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(payload)))
|
||||
.content_length(Some(payload.len() as i64))
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let original = Box::pin(test_seams::put_object(&usecase, &fs, build_request(put(b"original"), Method::PUT)))
|
||||
.await
|
||||
.expect("original should be written")
|
||||
.output
|
||||
.version_id
|
||||
.expect("original should be versioned");
|
||||
let observed = Box::pin(test_seams::put_object(&usecase, &fs, build_request(put(b"observed"), Method::PUT)))
|
||||
.await
|
||||
.expect("observed should be written")
|
||||
.output
|
||||
.version_id
|
||||
.expect("observed should be versioned");
|
||||
let encoded_object = encode_dir_object(object);
|
||||
let lock = ecstore
|
||||
.new_ns_lock(bucket.as_str(), &encoded_object)
|
||||
.await
|
||||
.expect("namespace lock should be available");
|
||||
let blocker = lock
|
||||
.get_write_lock(Duration::from_secs(10))
|
||||
.await
|
||||
.expect("test should acquire blocker");
|
||||
|
||||
let mut undo_req = build_request(
|
||||
CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: object.to_string().into(),
|
||||
version_id: Some(original.into()),
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
undo_req
|
||||
.headers
|
||||
.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_str(&observed).unwrap());
|
||||
|
||||
let undo = tokio::spawn(async move {
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
Box::pin(usecase.execute_copy_object(undo_req)).await
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
let put = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(b"concurrent")))
|
||||
.content_length(Some(10))
|
||||
.build()
|
||||
.unwrap();
|
||||
let concurrent_put = tokio::spawn(async move {
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
Box::pin(test_seams::put_object(&usecase, &fs, build_request(put, Method::PUT))).await
|
||||
});
|
||||
|
||||
drop(blocker);
|
||||
let undo_result = undo.await.expect("undo task should not panic");
|
||||
let put_result = concurrent_put.await.expect("PUT task should not panic");
|
||||
assert!(put_result.is_ok(), "ordinary PUT should eventually succeed: {put_result:?}");
|
||||
if let Err(err) = undo_result {
|
||||
assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed);
|
||||
}
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, b"concurrent");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn copy_object_allows_new_version_for_locked_destination_but_blocks_explicit_overwrite() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-copy-object-lock-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let src_object = "test/source.txt";
|
||||
let dst_object = "test/destination.txt";
|
||||
let locked_payload = b"locked destination payload";
|
||||
let source_payload = b"copy source payload";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_object_lock_enabled(bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enable object lock for bucket");
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), src_object, source_payload).await;
|
||||
|
||||
let retain_until = Timestamp::from(time::OffsetDateTime::now_utc().saturating_add(time::Duration::days(1)));
|
||||
let locked_input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(locked_payload)))
|
||||
.content_length(Some(locked_payload.len() as i64))
|
||||
.object_lock_mode(Some(ObjectLockMode::from_static(ObjectLockMode::COMPLIANCE)))
|
||||
.object_lock_retain_until_date(Some(retain_until))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
Box::pin(test_seams::put_object(&usecase, &fs, build_request(locked_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to upload locked destination object");
|
||||
|
||||
let locked_info = ecstore
|
||||
.get_object_info(bucket.as_str(), dst_object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to fetch locked destination object info");
|
||||
let locked_version_id = locked_info
|
||||
.version_id
|
||||
.expect("locked destination should have a version ID")
|
||||
.to_string();
|
||||
|
||||
let copy_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: src_object.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let copy_output = Box::pin(usecase.execute_copy_object(build_request(copy_input, Method::PUT)))
|
||||
.await
|
||||
.expect("CopyObject should create a new version over a locked current version");
|
||||
let copied_version_id = copy_output
|
||||
.output
|
||||
.version_id
|
||||
.expect("versioned CopyObject should return the created version ID");
|
||||
|
||||
assert_ne!(copied_version_id, locked_version_id);
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), dst_object).await, source_payload);
|
||||
assert_eq!(live_object_version_count(&ecstore, bucket.as_str(), dst_object).await, 2);
|
||||
|
||||
let explicit_overwrite_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: src_object.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.version_id(Some(locked_version_id))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let err = Box::pin(usecase.execute_copy_object(build_request(explicit_overwrite_input, Method::PUT)))
|
||||
.await
|
||||
.expect_err("explicit CopyObject overwrite of a locked version should be blocked");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
assert_eq!(
|
||||
err.message(),
|
||||
Some("Invalid argument: A destination versionId requires replication authorization")
|
||||
);
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), dst_object).await, source_payload);
|
||||
assert_eq!(live_object_version_count(&ecstore, bucket.as_str(), dst_object).await, 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn concurrent_reverse_copy_object_does_not_deadlock_with_reader_locks() {
|
||||
temp_env::async_with_vars([(ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE, Some("false"))], async {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
let bucket = format!("test-reverse-copy-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object_a = "test/a.bin";
|
||||
let object_b = "test/b.bin";
|
||||
let payload_a = vec![b'a'; 4 * 1024 * 1024];
|
||||
let payload_b = vec![b'b'; 4 * 1024 * 1024];
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object_a, &payload_a).await;
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object_b, &payload_b).await;
|
||||
|
||||
let a_to_b_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: object_a.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(object_b.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
let b_to_a_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: object_b.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(object_a.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let start = Arc::new(Barrier::new(3));
|
||||
let start_a = start.clone();
|
||||
let a_to_b = tokio::spawn(async move {
|
||||
start_a.wait().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
Box::pin(usecase.execute_copy_object(build_request(a_to_b_input, Method::PUT))).await
|
||||
});
|
||||
let start_b = start.clone();
|
||||
let b_to_a = tokio::spawn(async move {
|
||||
start_b.wait().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
Box::pin(usecase.execute_copy_object(build_request(b_to_a_input, Method::PUT))).await
|
||||
});
|
||||
|
||||
start.wait().await;
|
||||
|
||||
let (a_to_b_result, b_to_a_result) = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let (a_to_b_result, b_to_a_result) = tokio::join!(a_to_b, b_to_a);
|
||||
(
|
||||
a_to_b_result.expect("A-to-B CopyObject task should not panic"),
|
||||
b_to_a_result.expect("B-to-A CopyObject task should not panic"),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("reverse CopyObject operations should not deadlock");
|
||||
|
||||
a_to_b_result.expect("A-to-B CopyObject should succeed");
|
||||
b_to_a_result.expect("B-to-A CopyObject should succeed");
|
||||
|
||||
let final_a = read_object_bytes(&ecstore, bucket.as_str(), object_a).await;
|
||||
let final_b = read_object_bytes(&ecstore, bucket.as_str(), object_b).await;
|
||||
assert!(
|
||||
final_a == payload_a || final_a == payload_b,
|
||||
"object A must contain a complete copied payload"
|
||||
);
|
||||
assert!(
|
||||
final_b == payload_a || final_b == payload_b,
|
||||
"object B must contain a complete copied payload"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_and_copy_object_transition_immediately_via_usecases() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let put_bucket = format!("test-api-put-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let put_object = "test/object.txt";
|
||||
let put_payload = b"Hello, immediate transition through put API!";
|
||||
|
||||
create_test_bucket(&ecstore, put_bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(put_bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
let put_input = PutObjectInput::builder()
|
||||
.bucket(put_bucket.clone())
|
||||
.key(put_object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(put_payload)))
|
||||
.content_length(Some(put_payload.len() as i64))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
Box::pin(test_seams::put_object(&usecase, &fs, build_request(put_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to put object through usecase");
|
||||
|
||||
let put_info = wait_for_transition(&ecstore, put_bucket.as_str(), put_object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should transition immediately after put usecase");
|
||||
|
||||
assert_eq!(put_info.transitioned_object.status, "complete");
|
||||
assert_eq!(put_info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(&put_info.transitioned_object.name).await);
|
||||
|
||||
let src_bucket = format!("test-api-copy-src-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let dst_bucket = format!("test-api-copy-dst-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let src_object = "test/source.txt";
|
||||
let dst_object = "test/copied.txt";
|
||||
let copy_payload = b"copy object immediate transition through copy API";
|
||||
|
||||
create_test_bucket(&ecstore, src_bucket.as_str()).await;
|
||||
create_test_bucket(&ecstore, dst_bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(dst_bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set destination lifecycle configuration");
|
||||
let _ = upload_test_object(&ecstore, src_bucket.as_str(), src_object, copy_payload).await;
|
||||
|
||||
let copy_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: src_bucket.clone().into(),
|
||||
key: src_object.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(dst_bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
Box::pin(usecase.execute_copy_object(build_request(copy_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to copy object through usecase");
|
||||
|
||||
let copy_info = wait_for_transition(&ecstore, dst_bucket.as_str(), dst_object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("copied object should transition immediately after copy usecase");
|
||||
|
||||
assert_eq!(copy_info.transitioned_object.status, "complete");
|
||||
assert_eq!(copy_info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(©_info.transitioned_object.name).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn compensation_driven_copy_still_completes_transition() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let src_bucket = format!("test-comp-copy-src-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let dst_bucket = format!("test-comp-copy-dst-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let src_object = "test/source.txt";
|
||||
let dst_object = "test/copied.txt";
|
||||
let payload = b"copy object should still transition after compensation";
|
||||
|
||||
create_test_bucket(&ecstore, src_bucket.as_str()).await;
|
||||
create_test_bucket(&ecstore, dst_bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(dst_bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set destination lifecycle configuration");
|
||||
let _ = upload_test_object(&ecstore, src_bucket.as_str(), src_object, payload).await;
|
||||
|
||||
let copy_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: src_bucket.clone().into(),
|
||||
key: src_object.to_string().into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(dst_bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
Box::pin(usecase.execute_copy_object(build_request(copy_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to copy object through usecase");
|
||||
})
|
||||
.await;
|
||||
|
||||
let info = wait_for_transition(&ecstore, dst_bucket.as_str(), dst_object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("copied object should eventually transition after compensation backfill");
|
||||
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the copied version once");
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
assert_eq!(read_object_bytes(&ecstore, dst_bucket.as_str(), dst_object).await, payload);
|
||||
}
|
||||
|
||||
/// CopyObject creates a new object on the destination key, so it must join
|
||||
/// bucket replication exactly like PutObject: compute one replication decision
|
||||
/// that drives both the persisted pending marker and the post-commit schedule
|
||||
/// (MinIO CopyObjectHandler parity). A count of 0 means a copied object is
|
||||
/// never scheduled for replication and — because the copy path clones the
|
||||
/// source metadata wholesale — the destination silently inherits the source's
|
||||
/// replication bookkeeping, faking a COMPLETED/REPLICA state for an object
|
||||
/// that never replicated. The second half of this test pins the metadata
|
||||
/// cleanup (MinIO filterReplicationStatusMetadata parity).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state usecase integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn copy_object_computes_replication_decision_and_strips_stale_status() {
|
||||
use super::super::storage_api::object_usecase::bucket::replication::MUST_REPLICATE_OBJECT_CALLS;
|
||||
use rustfs_utils::http::{
|
||||
SUFFIX_REPLICA_STATUS, SUFFIX_REPLICA_TIMESTAMP, SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP, get_str,
|
||||
insert_str,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-copy-repl-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let src_object = "copy/source.txt";
|
||||
let dst_object = "copy/destination.txt";
|
||||
let payload = b"copy replication decision payload";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
// Seed the source object with stale replication bookkeeping, as if it had
|
||||
// already replicated elsewhere (internal status/timestamp under both
|
||||
// compatibility prefixes, replica state, and the surfaced x-amz header).
|
||||
let mut stale_metadata = HashMap::new();
|
||||
insert_str(
|
||||
&mut stale_metadata,
|
||||
SUFFIX_REPLICATION_STATUS,
|
||||
"arn:minio:replication::stale:dst=COMPLETED;".to_string(),
|
||||
);
|
||||
insert_str(&mut stale_metadata, SUFFIX_REPLICATION_TIMESTAMP, "2024-01-01T00:00:00Z".to_string());
|
||||
insert_str(&mut stale_metadata, SUFFIX_REPLICA_STATUS, "REPLICA".to_string());
|
||||
insert_str(&mut stale_metadata, SUFFIX_REPLICA_TIMESTAMP, "2024-01-01T00:00:00Z".to_string());
|
||||
stale_metadata.insert(AMZ_BUCKET_REPLICATION_STATUS.to_string(), "COMPLETED".to_string());
|
||||
let mut reader = PutObjReader::from_vec(payload.to_vec());
|
||||
(*ecstore)
|
||||
.put_object(
|
||||
bucket.as_str(),
|
||||
src_object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
user_defined: stale_metadata,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to upload source object with stale replication metadata");
|
||||
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
|
||||
let copy_input = CopyObjectInput::builder()
|
||||
.copy_source(CopySource::Bucket {
|
||||
bucket: bucket.clone().into(),
|
||||
key: src_object.into(),
|
||||
version_id: None,
|
||||
})
|
||||
.bucket(bucket.clone())
|
||||
.key(dst_object.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
Box::pin(usecase.execute_copy_object(build_request(copy_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to copy object through usecase");
|
||||
|
||||
assert_eq!(
|
||||
MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst),
|
||||
1,
|
||||
"CopyObject must compute the replication decision exactly once; 0 means the copied \
|
||||
object never enters bucket replication (P0-6, MinIO CopyObjectHandler parity)"
|
||||
);
|
||||
|
||||
let copied = ecstore
|
||||
.get_object_info(bucket.as_str(), dst_object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to read copied destination object info");
|
||||
assert!(
|
||||
get_str(&copied.user_defined, SUFFIX_REPLICATION_STATUS).is_none(),
|
||||
"destination must not inherit the source's replication status; got {:?}",
|
||||
get_str(&copied.user_defined, SUFFIX_REPLICATION_STATUS)
|
||||
);
|
||||
assert!(
|
||||
get_str(&copied.user_defined, SUFFIX_REPLICATION_TIMESTAMP).is_none(),
|
||||
"destination must not inherit the source's replication timestamp"
|
||||
);
|
||||
assert!(
|
||||
get_str(&copied.user_defined, SUFFIX_REPLICA_STATUS).is_none(),
|
||||
"destination must not inherit the source's replica status"
|
||||
);
|
||||
assert!(
|
||||
!copied
|
||||
.user_defined
|
||||
.keys()
|
||||
.any(|k| k.eq_ignore_ascii_case(AMZ_BUCKET_REPLICATION_STATUS)),
|
||||
"destination must not inherit the surfaced x-amz-replication-status header key"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! DeleteObject and DeleteObjects tests (batch E owns both).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
use crate::app::test_seams;
|
||||
|
||||
async fn wait_for_object_absence(ecstore: &Arc<ECStore>, bucket: &str, object: &str, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
if ecstore
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state object-version integration test: runs serialized in the CI ILM Integration (serial) lane"]
|
||||
async fn undo_delete_only_removes_the_current_matching_delete_marker() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-undo-delete-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let put = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(b"visible again")))
|
||||
.content_length(Some(13))
|
||||
.build()
|
||||
.unwrap();
|
||||
Box::pin(test_seams::put_object(&usecase, &fs, build_request(put, Method::PUT)))
|
||||
.await
|
||||
.expect("object version should be written");
|
||||
let marker = Box::pin(
|
||||
usecase.execute_delete_object(build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.expect("delete marker should be created")
|
||||
.output
|
||||
.version_id
|
||||
.expect("versioned delete should return the marker version");
|
||||
|
||||
let delete_marker = || {
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.version_id(Some(marker.clone()))
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let mut stale_req = build_request(delete_marker(), Method::DELETE);
|
||||
stale_req.headers.insert(
|
||||
RUSTFS_EXPECTED_CURRENT_VERSION_ID,
|
||||
HeaderValue::from_str(&Uuid::new_v4().to_string()).unwrap(),
|
||||
);
|
||||
let err = Box::pin(usecase.execute_delete_object(stale_req))
|
||||
.await
|
||||
.expect_err("mismatched delete marker must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed);
|
||||
|
||||
let mut matching_req = build_request(delete_marker(), Method::DELETE);
|
||||
matching_req
|
||||
.headers
|
||||
.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_str(&marker).unwrap());
|
||||
Box::pin(usecase.execute_delete_object(matching_req))
|
||||
.await
|
||||
.expect("current matching delete marker should be removed");
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, b"visible again");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn delete_transitioned_object_removes_remote_tier_copy_via_usecase() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-delete-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"delete transitioned object through delete API";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
|
||||
lifecycle::bucket_lifecycle_ops::enqueue_transition_for_existing_objects(ecstore.clone(), bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enqueue transitioned object");
|
||||
|
||||
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should transition before delete usecase runs");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let mut req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
);
|
||||
insert_header(&mut req.headers, SUFFIX_FORCE_DELETE, "true");
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Box::pin(usecase.execute_delete_object(req))
|
||||
.await
|
||||
.expect("Failed to delete object through usecase");
|
||||
|
||||
assert!(
|
||||
wait_for_object_absence(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"object should be removed from hot tier after delete usecase"
|
||||
);
|
||||
|
||||
assert!(
|
||||
backend.wait_for_remote_absence(&remote_object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"transitioned object should be removed from remote tier after delete usecase"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn compensation_driven_transition_still_cleans_remote_tier_on_delete() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-compensation-delete-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"compensation should still preserve delete cleanup";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should eventually transition after compensation backfill");
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let mut req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
);
|
||||
insert_header(&mut req.headers, SUFFIX_FORCE_DELETE, "true");
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Box::pin(usecase.execute_delete_object(req))
|
||||
.await
|
||||
.expect("Failed to delete object through usecase after compensation-driven transition");
|
||||
|
||||
assert!(
|
||||
wait_for_object_absence(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"object should be removed from hot tier after delete usecase"
|
||||
);
|
||||
|
||||
assert!(
|
||||
backend.wait_for_remote_absence(&remote_object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"transitioned object should be removed from remote tier after delete usecase"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn compensation_driven_versioned_delete_still_creates_delete_marker() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-comp-versioned-delete-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"versioned delete should preserve transitioned remote version behind delete marker";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should eventually transition after compensation backfill");
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
);
|
||||
|
||||
Box::pin(usecase.execute_delete_object(req))
|
||||
.await
|
||||
.expect("Failed to issue versioned delete after compensation-driven transition");
|
||||
|
||||
assert!(
|
||||
wait_for_delete_marker(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"versioned delete should create a delete marker after compensation-driven transition"
|
||||
);
|
||||
assert!(
|
||||
backend.contains(&remote_object).await,
|
||||
"creating a delete marker should not remove the transitioned remote object version"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression pin for the DeleteObjects hot path: the bucket's versioning
|
||||
/// configuration must be resolved exactly once per request — not once per
|
||||
/// key, which used to cost up to 1000 identical metadata lookups per call.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial"]
|
||||
async fn delete_objects_resolves_bucket_versioning_once_per_request() {
|
||||
use super::super::storage_api::test::bucket::replication::DELETE_CONFIG_SNAPSHOT_LOADS;
|
||||
use super::super::storage_api::test::{ReqInfo, VERSIONING_CONFIG_LOOKUPS};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-delobjs-vers-once-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let keys = ["batch/a.txt", "batch/b.txt", "batch/c.txt"];
|
||||
for key in keys {
|
||||
upload_test_object(&ecstore, bucket.as_str(), key, b"delete objects versioning payload").await;
|
||||
}
|
||||
|
||||
let input = DeleteObjectsInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.delete(Delete {
|
||||
objects: keys
|
||||
.iter()
|
||||
.map(|key| ObjectIdentifier {
|
||||
key: key.to_string(),
|
||||
version_id: None,
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
quiet: None,
|
||||
})
|
||||
.build()
|
||||
.expect("delete objects input should build");
|
||||
|
||||
let mut req = build_request(input, Method::POST);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let lookups_before = VERSIONING_CONFIG_LOOKUPS.load(Ordering::SeqCst);
|
||||
let snapshots_before = DELETE_CONFIG_SNAPSHOT_LOADS.load(Ordering::SeqCst);
|
||||
let response = usecase
|
||||
.execute_delete_objects(req)
|
||||
.await
|
||||
.expect("delete objects should succeed");
|
||||
|
||||
let output = response.output;
|
||||
let errors = output.errors.unwrap_or_default();
|
||||
assert!(errors.is_empty(), "DeleteObjects reported per-key errors: {errors:?}");
|
||||
assert_eq!(
|
||||
output.deleted.unwrap_or_default().len(),
|
||||
keys.len(),
|
||||
"every requested key should be deleted"
|
||||
);
|
||||
|
||||
let lookups = VERSIONING_CONFIG_LOOKUPS.load(Ordering::SeqCst) - lookups_before;
|
||||
let snapshots = DELETE_CONFIG_SNAPSHOT_LOADS.load(Ordering::SeqCst) - snapshots_before;
|
||||
assert_eq!(
|
||||
snapshots,
|
||||
1,
|
||||
"DeleteObjects must resolve delete admission config exactly once per request; got {snapshots} snapshots for {} keys",
|
||||
keys.len()
|
||||
);
|
||||
assert_eq!(lookups, 0, "DeleteObjects must not fall back to legacy per-key versioning lookups");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial"]
|
||||
async fn delete_objects_treats_empty_directory_version_as_absent() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delobjs-empty-version-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "directory/";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, b"").await;
|
||||
|
||||
let input = DeleteObjectsInput::builder()
|
||||
.bucket(bucket)
|
||||
.delete(Delete {
|
||||
objects: vec![ObjectIdentifier {
|
||||
key: object.to_string(),
|
||||
version_id: Some(" \t ".to_string()),
|
||||
..Default::default()
|
||||
}],
|
||||
quiet: None,
|
||||
})
|
||||
.build()
|
||||
.expect("delete objects input should build");
|
||||
let mut req = build_request(input, Method::POST);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let response = usecase
|
||||
.execute_delete_objects(req)
|
||||
.await
|
||||
.expect("empty VersionId should be treated as an absent version");
|
||||
let deleted = response
|
||||
.output
|
||||
.deleted
|
||||
.expect("delete response should contain the directory entry");
|
||||
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert_eq!(
|
||||
deleted[0].delete_marker, None,
|
||||
"the synthetic null version must be purged, not hidden by a new marker"
|
||||
);
|
||||
assert_eq!(deleted[0].version_id, None, "the synthetic null sentinel must stay off the wire");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial"]
|
||||
async fn delete_objects_preserves_explicit_null_directory_version() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delobjs-null-version-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "directory/";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, b"").await;
|
||||
|
||||
let input = DeleteObjectsInput::builder()
|
||||
.bucket(bucket)
|
||||
.delete(Delete {
|
||||
objects: vec![ObjectIdentifier {
|
||||
key: object.to_string(),
|
||||
version_id: Some("null".to_string()),
|
||||
..Default::default()
|
||||
}],
|
||||
quiet: None,
|
||||
})
|
||||
.build()
|
||||
.expect("delete objects input should build");
|
||||
let mut req = build_request(input, Method::POST);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let response = usecase
|
||||
.execute_delete_objects(req)
|
||||
.await
|
||||
.expect("explicit null VersionId should delete the directory's null version");
|
||||
let deleted = response
|
||||
.output
|
||||
.deleted
|
||||
.expect("delete response should contain the directory entry");
|
||||
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert_eq!(deleted[0].delete_marker, None);
|
||||
assert_eq!(deleted[0].version_id.as_deref(), Some("null"));
|
||||
}
|
||||
|
||||
async fn install_malformed_versioning_config(bucket: &str) {
|
||||
use super::super::storage_api::test::{get_global_bucket_metadata_sys, set_bucket_metadata};
|
||||
|
||||
let sys = get_global_bucket_metadata_sys().expect("bucket metadata system should be initialized");
|
||||
let metadata = {
|
||||
let sys = sys.read().await;
|
||||
sys.get(bucket)
|
||||
.await
|
||||
.expect("bucket metadata should be cached before corruption injection")
|
||||
};
|
||||
let mut metadata = (*metadata).clone();
|
||||
metadata.versioning_config_xml = b"<VersioningConfiguration>".to_vec();
|
||||
metadata.versioning_config = None;
|
||||
set_bucket_metadata(bucket.to_string(), metadata)
|
||||
.await
|
||||
.expect("malformed versioning metadata should be installed for the request test");
|
||||
}
|
||||
|
||||
async fn install_malformed_replication_config(bucket: &str) {
|
||||
use super::super::storage_api::test::{get_global_bucket_metadata_sys, set_bucket_metadata};
|
||||
|
||||
let sys = get_global_bucket_metadata_sys().expect("bucket metadata system should be initialized");
|
||||
let metadata = {
|
||||
let sys = sys.read().await;
|
||||
sys.get(bucket)
|
||||
.await
|
||||
.expect("bucket metadata should be cached before corruption injection")
|
||||
};
|
||||
let mut metadata = (*metadata).clone();
|
||||
metadata.replication_config_xml = b"<ReplicationConfiguration>".to_vec();
|
||||
metadata.replication_config = None;
|
||||
set_bucket_metadata(bucket.to_string(), metadata)
|
||||
.await
|
||||
.expect("malformed replication metadata should be installed for the request test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn delete_object_versioning_config_failure_leaves_latest_object_intact() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delete-versioning-fail-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
let payload = b"local delete must not commit before versioning admission";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, payload).await;
|
||||
install_malformed_versioning_config(&bucket).await;
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.expect("delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_delete_object(req)
|
||||
.await
|
||||
.expect_err("versioning config failure must reject DeleteObject");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, payload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn delete_object_replication_config_failure_leaves_latest_object_intact() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delete-repl-fail-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
let payload = b"local delete must not commit before replication admission";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, payload).await;
|
||||
install_malformed_replication_config(&bucket).await;
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.expect("delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_delete_object(req)
|
||||
.await
|
||||
.expect_err("replication config failure must reject DeleteObject");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, payload);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
async fn delete_object_uses_one_replication_config_generation() {
|
||||
use super::super::object_usecase::install_delete_snapshot_test_hook;
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
use super::super::storage_api::test::bucket::replication::take_scheduled_replication_deletes;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delete-config-generation-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
|
||||
let mut reader = PutObjReader::from_vec(b"generation-bound delete".to_vec());
|
||||
let uploaded = ecstore
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("versioned object should be uploaded");
|
||||
let version_id = uploaded.version_id.expect("versioned upload should return a version ID");
|
||||
let version_id_string = version_id.to_string();
|
||||
|
||||
install_delete_replication_config(&bucket, true, false, None).await;
|
||||
assert!(take_scheduled_replication_deletes().is_empty());
|
||||
|
||||
let loaded = Arc::new(Barrier::new(2));
|
||||
let resume = Arc::new(Barrier::new(2));
|
||||
install_delete_snapshot_test_hook(bucket.clone(), Arc::clone(&loaded), Arc::clone(&resume));
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.version_id(Some(version_id_string.clone()))
|
||||
.build()
|
||||
.expect("version delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let update_config = async {
|
||||
loaded.wait().await;
|
||||
install_delete_replication_config(&bucket, false, false, None).await;
|
||||
resume.wait().await;
|
||||
};
|
||||
let (response, ()) = tokio::join!(usecase.execute_delete_object(req), update_config);
|
||||
let response = response.expect("delete admitted by the first config generation should succeed");
|
||||
|
||||
assert_eq!(response.output.version_id.as_deref(), Some(version_id_string.as_str()));
|
||||
let scheduled = take_scheduled_replication_deletes();
|
||||
assert_eq!(scheduled.len(), 1, "the first config generation must control post-delete queueing");
|
||||
assert_eq!(scheduled[0].version_id, Some(version_id));
|
||||
let state = scheduled[0]
|
||||
.replication_state
|
||||
.as_ref()
|
||||
.expect("admitted version purge should carry replication state");
|
||||
assert_eq!(
|
||||
state.version_purge_status_internal.as_deref(),
|
||||
Some("arn:aws:s3:::target-bucket=PENDING;")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn replica_delete_uses_authorized_source_version_and_its_tags() {
|
||||
use super::super::object_usecase::install_delete_source_test_hook;
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
use super::super::storage_api::test::bucket::replication::{ReplicationStatusType, take_scheduled_replication_deletes};
|
||||
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
|
||||
use rustfs_utils::http::{SUFFIX_SOURCE_REPLICATION_REQUEST, SUFFIX_SOURCE_VERSION_ID};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-replica-source-version-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
install_delete_replication_config(&bucket, true, true, Some(("generation", "updated"))).await;
|
||||
|
||||
let mut old_opts = ObjectOptions {
|
||||
versioned: true,
|
||||
user_defined: HashMap::from([(AMZ_OBJECT_TAGGING.to_string(), "generation=old".to_string())]),
|
||||
..Default::default()
|
||||
};
|
||||
old_opts.set_replica_status(ReplicationStatusType::Replica);
|
||||
let mut old_reader = PutObjReader::from_vec(b"old replica version".to_vec());
|
||||
let old = ecstore
|
||||
.put_object(&bucket, object, &mut old_reader, &old_opts)
|
||||
.await
|
||||
.expect("old replica version should be uploaded");
|
||||
let old_version_id = old.version_id.expect("old replica version should have a version ID");
|
||||
let old_version_id_string = old_version_id.to_string();
|
||||
|
||||
let mut latest_opts = ObjectOptions {
|
||||
versioned: true,
|
||||
user_defined: HashMap::from([(AMZ_OBJECT_TAGGING.to_string(), "generation=latest".to_string())]),
|
||||
..Default::default()
|
||||
};
|
||||
latest_opts.set_replica_status(ReplicationStatusType::Replica);
|
||||
let mut latest_reader = PutObjReader::from_vec(b"latest replica version".to_vec());
|
||||
let latest = ecstore
|
||||
.put_object(&bucket, object, &mut latest_reader, &latest_opts)
|
||||
.await
|
||||
.expect("latest replica version should be uploaded");
|
||||
assert_ne!(latest.version_id, Some(old_version_id));
|
||||
assert!(take_scheduled_replication_deletes().is_empty());
|
||||
|
||||
let source_loaded = Arc::new(Barrier::new(2));
|
||||
let resume_delete = Arc::new(Barrier::new(2));
|
||||
install_delete_source_test_hook(bucket.clone(), Arc::clone(&source_loaded), Arc::clone(&resume_delete));
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.expect("replica delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
req.headers
|
||||
.insert(AMZ_BUCKET_REPLICATION_STATUS, HeaderValue::from_static("REPLICA"));
|
||||
insert_header(&mut req.headers, SUFFIX_SOURCE_VERSION_ID, old_version_id_string.clone());
|
||||
insert_header(&mut req.headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let update_tags = async {
|
||||
source_loaded.wait().await;
|
||||
ecstore
|
||||
.put_object_tags(
|
||||
&bucket,
|
||||
object,
|
||||
"generation=updated",
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(old_version_id_string.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("tag update ordered before the locked delete should succeed");
|
||||
resume_delete.wait().await;
|
||||
};
|
||||
let (response, ()) = tokio::join!(usecase.execute_delete_object(req), update_tags);
|
||||
let response = response.expect("authorized replica version delete should succeed");
|
||||
|
||||
assert_eq!(response.output.version_id.as_deref(), Some(old_version_id_string.as_str()));
|
||||
let scheduled = take_scheduled_replication_deletes();
|
||||
assert_eq!(
|
||||
scheduled.len(),
|
||||
1,
|
||||
"the tag committed after the advisory pre-read must control downstream fanout"
|
||||
);
|
||||
assert_eq!(scheduled[0].version_id, Some(old_version_id));
|
||||
assert_eq!(
|
||||
scheduled[0]
|
||||
.replication_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.version_purge_status_internal.as_deref()),
|
||||
Some("arn:aws:s3:::target-bucket=PENDING;")
|
||||
);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, b"latest replica version");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn force_delete_replication_config_failure_leaves_prefix_intact() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-force-delete-repl-fail-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let prefix = "prefix/";
|
||||
let object = "prefix/object.txt";
|
||||
let payload = b"force delete must validate replication metadata before deleting descendants";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, payload).await;
|
||||
install_malformed_replication_config(&bucket).await;
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(prefix.to_string())
|
||||
.build()
|
||||
.expect("delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
insert_header(&mut req.headers, SUFFIX_FORCE_DELETE, "true");
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_delete_object(req)
|
||||
.await
|
||||
.expect_err("replication config failure must reject force delete");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, payload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn replica_delete_replication_config_failure_leaves_object_intact() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-replica-delete-repl-fail-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
let payload = b"incoming replica delete must use the admitted request snapshot";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, payload).await;
|
||||
install_malformed_replication_config(&bucket).await;
|
||||
|
||||
let input = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.expect("delete input should build");
|
||||
let mut req = build_request(input, Method::DELETE);
|
||||
req.headers
|
||||
.insert(AMZ_BUCKET_REPLICATION_STATUS, HeaderValue::from_static("REPLICA"));
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_delete_object(req)
|
||||
.await
|
||||
.expect_err("replication config failure must reject incoming replica delete");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, payload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn delete_objects_replication_config_failure_does_not_call_storage_delete() {
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket = format!("test-delete-repls-fail-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "object.txt";
|
||||
let payload = b"batch delete must fail closed on replication metadata errors";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, object, payload).await;
|
||||
install_malformed_replication_config(&bucket).await;
|
||||
|
||||
let input = DeleteObjectsInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.delete(Delete {
|
||||
objects: vec![ObjectIdentifier {
|
||||
key: object.to_string(),
|
||||
version_id: None,
|
||||
..Default::default()
|
||||
}],
|
||||
quiet: None,
|
||||
})
|
||||
.build()
|
||||
.expect("delete objects input should build");
|
||||
let mut req = build_request(input, Method::POST);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_delete_objects(req)
|
||||
.await
|
||||
.expect_err("replication config failure must reject DeleteObjects");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
assert_eq!(read_object_bytes(&ecstore, &bucket, object).await, payload);
|
||||
}
|
||||
|
||||
/// Regression pin for the nonexistent-bucket fast path: GET/HEAD/DeleteObject
|
||||
/// must decide NoSuchBucket from the bucket-existence check, before any bucket
|
||||
/// versioning lookup runs — and cheap request-shape validation still wins over
|
||||
/// bucket existence for GET.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial"]
|
||||
async fn nonexistent_bucket_fails_before_versioning_lookup_on_get_head_delete() {
|
||||
use super::super::storage_api::test::VERSIONING_CONFIG_LOOKUPS;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, _ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
// Never created.
|
||||
let bucket = format!("test-missing-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
|
||||
// Request-shape validation keeps precedence over bucket existence: GET with
|
||||
// both range and partNumber is InvalidArgument even on a missing bucket.
|
||||
let get_invalid = GetObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("k.txt".to_string())
|
||||
.range(Some(Range::Int { first: 0, last: Some(1) }))
|
||||
.part_number(Some(1))
|
||||
.build()
|
||||
.expect("get input should build");
|
||||
let err = Box::pin(test_seams::get_object(&usecase, build_request(get_invalid, Method::GET)))
|
||||
.await
|
||||
.expect_err("range+partNumber must be rejected");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
|
||||
let lookups_before = VERSIONING_CONFIG_LOOKUPS.load(Ordering::SeqCst);
|
||||
|
||||
let get = GetObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("k.txt".to_string())
|
||||
.build()
|
||||
.expect("get input should build");
|
||||
let err = Box::pin(test_seams::get_object(&usecase, build_request(get, Method::GET)))
|
||||
.await
|
||||
.expect_err("GET on a missing bucket must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
|
||||
|
||||
// Bucket existence beats versionId-format validation (which lives in the
|
||||
// opts builders) for HEAD and DeleteObject.
|
||||
let head = HeadObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("k.txt".to_string())
|
||||
.version_id(Some("not-a-uuid".to_string()))
|
||||
.build()
|
||||
.expect("head input should build");
|
||||
let err = Box::pin(test_seams::head_object(&usecase, build_request(head, Method::HEAD)))
|
||||
.await
|
||||
.expect_err("HEAD on a missing bucket must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
|
||||
|
||||
let del = DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("k.txt".to_string())
|
||||
.version_id(Some("not-a-uuid".to_string()))
|
||||
.build()
|
||||
.expect("delete input should build");
|
||||
let err = Box::pin(usecase.execute_delete_object(build_request(del, Method::DELETE)))
|
||||
.await
|
||||
.expect_err("DeleteObject on a missing bucket must fail");
|
||||
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
|
||||
|
||||
let lookups = VERSIONING_CONFIG_LOOKUPS.load(Ordering::SeqCst) - lookups_before;
|
||||
assert_eq!(
|
||||
lookups, 0,
|
||||
"requests naming a nonexistent bucket must fail before any bucket versioning lookup; got {lookups}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! PutBucketLifecycleConfiguration tests (batch A3 owns the lifecycle operations).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
fn expiration_lifecycle_configuration(prefix: &str) -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
expiration: Some(LifecycleExpiration {
|
||||
date: Some(Timestamp::from(
|
||||
time::OffsetDateTime::now_utc()
|
||||
.replace_time(time::Time::MIDNIGHT)
|
||||
.saturating_sub(time::Duration::days(1)),
|
||||
)),
|
||||
days: None,
|
||||
expired_object_delete_marker: None,
|
||||
..Default::default()
|
||||
}),
|
||||
filter: Some(LifecycleRuleFilter {
|
||||
and: None,
|
||||
object_size_greater_than: None,
|
||||
object_size_less_than: None,
|
||||
prefix: Some(prefix.to_string()),
|
||||
tag: None,
|
||||
..Default::default()
|
||||
}),
|
||||
id: Some("expire-existing".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn expired_object_delete_marker_lifecycle_configuration() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
expiration: Some(LifecycleExpiration {
|
||||
expired_object_delete_marker: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
filter: Some(LifecycleRuleFilter {
|
||||
prefix: Some("test/".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
id: Some("expired-delete-marker".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn del_marker_expiration_lifecycle_configuration(days: i32) -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: Some(DelMarkerExpiration { days: Some(days) }),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(30),
|
||||
..Default::default()
|
||||
}),
|
||||
filter: Some(LifecycleRuleFilter {
|
||||
prefix: Some("test/".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
id: Some("del-marker-expiration".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn compensation_driven_delete_marker_still_honors_lifecycle_cleanup() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let bucket_usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-comp-del-marker-cleanup-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"delete marker lifecycle should still clean up after compensation-driven transition";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set transition lifecycle configuration");
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should eventually transition after compensation backfill");
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
);
|
||||
|
||||
Box::pin(usecase.execute_delete_object(req))
|
||||
.await
|
||||
.expect("Failed to issue versioned delete after compensation-driven transition");
|
||||
|
||||
assert!(
|
||||
wait_for_delete_marker(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"versioned delete should create a delete marker before lifecycle cleanup"
|
||||
);
|
||||
assert!(
|
||||
backend.contains(&remote_object).await,
|
||||
"delete marker creation should keep the transitioned remote object version"
|
||||
);
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(expiration_lifecycle_configuration("test/")))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
bucket_usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect("Failed to update lifecycle configuration for delete marker cleanup");
|
||||
|
||||
assert!(
|
||||
wait_for_delete_marker(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"delete marker should remain visible after lifecycle update until cleanup completes"
|
||||
);
|
||||
assert!(
|
||||
backend.contains(&remote_object).await,
|
||||
"delete marker lifecycle cleanup should not remove the transitioned remote object version"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_bucket_lifecycle_configuration_expires_existing_objects() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-api-expire-existing-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/existing.txt";
|
||||
let payload = b"expire existing object after lifecycle update";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(expiration_lifecycle_configuration("test/")))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect("Failed to update lifecycle configuration");
|
||||
|
||||
assert!(
|
||||
wait_for_delete_marker(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"existing object should be lifecycle-deleted after lifecycle update"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_bucket_lifecycle_configuration_allows_expired_delete_marker_on_object_lock_bucket() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-lock-expired-del-marker-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_object_lock_enabled(bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enable object lock for bucket");
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(expired_object_delete_marker_lifecycle_configuration()))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect("ExpiredObjectDeleteMarker should be accepted on object-lock bucket");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_bucket_lifecycle_configuration_rejects_del_marker_expiration_on_object_lock_bucket() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-lock-del-marker-exp-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_object_lock_enabled(bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enable object lock for bucket");
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(del_marker_expiration_lifecycle_configuration(1)))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
let err = usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect_err("DelMarkerExpiration must be rejected on object-lock bucket");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
let message = err.message().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains(
|
||||
"ExpiredObjectAllVersions element and DelMarkerExpiration action cannot be used on an object locked bucket"
|
||||
),
|
||||
"unexpected error message: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_bucket_lifecycle_configuration_rejects_zero_day_del_marker_expiration_on_object_lock_bucket() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-lock-del-marker-exp0-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_object_lock_enabled(bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enable object lock for bucket");
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(del_marker_expiration_lifecycle_configuration(0)))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
let err = usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect_err("DelMarkerExpiration with zero days must be rejected on object-lock bucket");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
let message = err.message().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("Days must be a positive integer with DelMarkerExpiration"),
|
||||
"unexpected error message: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_bucket_lifecycle_configuration_rejects_zero_day_expiration() {
|
||||
// S3 compatibility (ceph s3-tests `test_lifecycle_expiration_days0`): a rule with
|
||||
// Expiration{Days:0} must be rejected with InvalidArgument through the real
|
||||
// PutBucketLifecycleConfiguration handler, not merely at the crates/lifecycle
|
||||
// io::Error layer. No object lock required.
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultBucketUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-zero-day-exp-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let lifecycle = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(0),
|
||||
..Default::default()
|
||||
}),
|
||||
filter: Some(LifecycleRuleFilter {
|
||||
prefix: Some("days0/".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
id: Some("expire-zero-days".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let req = build_request(
|
||||
PutBucketLifecycleConfigurationInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.lifecycle_configuration(Some(lifecycle))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::PUT,
|
||||
);
|
||||
|
||||
let prepared = req
|
||||
.input
|
||||
.lifecycle_configuration
|
||||
.clone()
|
||||
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
|
||||
.transpose();
|
||||
let err = usecase
|
||||
.execute_put_bucket_lifecycle_configuration(req, prepared)
|
||||
.await
|
||||
.expect_err("zero-day expiration must be rejected with InvalidArgument");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
let message = err.message().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("'Days' for Expiration action must be a positive integer"),
|
||||
"unexpected error message: {message}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! CompleteMultipartUpload tests (batch E owns CompleteMultipartUpload).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn complete_multipart_upload_transitions_immediately_via_usecase() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultMultipartUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-mpu-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/multipart.txt";
|
||||
let payload = b"multipart immediate transition through complete API";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
let upload = ecstore
|
||||
.new_multipart_upload(bucket.as_str(), object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to create multipart upload");
|
||||
|
||||
let mut reader = PutObjReader::from_vec(payload.to_vec());
|
||||
let uploaded_part = ecstore
|
||||
.put_object_part(bucket.as_str(), object, &upload.upload_id, 1, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to upload multipart part");
|
||||
|
||||
let complete_input = CompleteMultipartUploadInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.upload_id(upload.upload_id.clone())
|
||||
.multipart_upload(Some(CompletedMultipartUpload {
|
||||
parts: Some(vec![CompletedPart {
|
||||
part_number: Some(1),
|
||||
e_tag: uploaded_part.etag.clone().map(|etag| to_s3s_etag(&etag)),
|
||||
..Default::default()
|
||||
}]),
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let complete_output = Box::pin(usecase.execute_complete_multipart_upload(build_request(complete_input, Method::POST)))
|
||||
.await
|
||||
.expect("Failed to complete multipart upload through usecase");
|
||||
assert!(
|
||||
complete_output.output.version_id.is_some(),
|
||||
"versioned CompleteMultipartUpload should return the created version ID"
|
||||
);
|
||||
|
||||
let info = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("multipart object should transition immediately after complete usecase");
|
||||
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
}
|
||||
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
#[test]
|
||||
fn compensation_driven_complete_multipart_upload_still_transitions() {
|
||||
std::thread::Builder::new()
|
||||
.name("lifecycle-compensation-complete-multipart".to_string())
|
||||
.stack_size(32 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("compensation multipart test runtime should build");
|
||||
|
||||
runtime.block_on(compensation_driven_complete_multipart_upload_still_transitions_inner());
|
||||
})
|
||||
.expect("compensation multipart test thread should spawn")
|
||||
.join()
|
||||
.expect("compensation multipart test thread should finish");
|
||||
}
|
||||
|
||||
async fn compensation_driven_complete_multipart_upload_still_transitions_inner() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultMultipartUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-comp-mpu-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/multipart.txt";
|
||||
let payload = b"multipart should still transition after compensation";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
let upload = ecstore
|
||||
.new_multipart_upload(bucket.as_str(), object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to create multipart upload");
|
||||
|
||||
let mut reader = PutObjReader::from_vec(payload.to_vec());
|
||||
let uploaded_part = ecstore
|
||||
.put_object_part(bucket.as_str(), object, &upload.upload_id, 1, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to upload multipart part");
|
||||
|
||||
let complete_input = CompleteMultipartUploadInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.upload_id(upload.upload_id.clone())
|
||||
.multipart_upload(Some(CompletedMultipartUpload {
|
||||
parts: Some(vec![CompletedPart {
|
||||
part_number: Some(1),
|
||||
e_tag: uploaded_part.etag.clone().map(|etag| to_s3s_etag(&etag)),
|
||||
..Default::default()
|
||||
}]),
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
Box::pin(usecase.execute_complete_multipart_upload(build_request(complete_input, Method::POST)))
|
||||
.await
|
||||
.expect("Failed to complete multipart upload through usecase");
|
||||
})
|
||||
.await;
|
||||
|
||||
let info = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("multipart object should eventually transition after compensation backfill");
|
||||
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! PutObjectRetention and PutObjectLegalHold tests (batch B1 owns both).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
/// The object-lock handlers do not go through the object PUT path, so they must schedule
|
||||
/// replication themselves. Without it a retention or legal hold applied after upload stays
|
||||
/// local and the replica keeps its previous, unprotected state (a WORM object that is still
|
||||
/// deletable on the peer). This observes the shared decision counter: each handler must
|
||||
/// compute a replication decision exactly once when replication is configured, which is
|
||||
/// the step that drives both the persisted pending marker and the schedule.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state usecase integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn object_lock_handlers_schedule_replication() {
|
||||
use super::super::storage_api::object_usecase::bucket::replication::MUST_REPLICATE_OBJECT_CALLS;
|
||||
use crate::app::storage_api::s3::S3;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
|
||||
let bucket = format!("test-lock-repl-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "locked/object.txt";
|
||||
|
||||
(*ecstore)
|
||||
.make_bucket(
|
||||
bucket.as_str(),
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create object-lock bucket");
|
||||
upload_test_object(&ecstore, bucket.as_str(), object, b"worm payload").await;
|
||||
|
||||
// PutObjectRetention must compute a replication decision.
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
let retention_input = PutObjectRetentionInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.retention(Some(ObjectLockRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
||||
retain_until_date: Some(time::macros::datetime!(2030-01-01 00:00:00 UTC).into()),
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
fs.put_object_retention(build_request(retention_input, Method::PUT))
|
||||
.await
|
||||
.expect("put_object_retention should succeed");
|
||||
assert_eq!(
|
||||
MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst),
|
||||
1,
|
||||
"PutObjectRetention must compute the replication decision exactly once; 0 means the \
|
||||
retention change never replicates and the peer copy stays unprotected"
|
||||
);
|
||||
|
||||
// A bucket without a replication configuration can skip the decision.
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
let no_replication_input = PutObjectLegalHoldInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.legal_hold(Some(ObjectLockLegalHold {
|
||||
status: Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)),
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
fs.put_object_legal_hold(build_request(no_replication_input, Method::PUT))
|
||||
.await
|
||||
.expect("legal hold without replication should succeed");
|
||||
assert_eq!(
|
||||
MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst),
|
||||
0,
|
||||
"a bucket without replication must skip the decision"
|
||||
);
|
||||
|
||||
// A configured bucket must still evaluate the metadata replication decision.
|
||||
install_delete_replication_config(&bucket, true, false, None).await;
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
let legal_hold_input = PutObjectLegalHoldInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.legal_hold(Some(ObjectLockLegalHold {
|
||||
status: Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::ON)),
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
fs.put_object_legal_hold(build_request(legal_hold_input, Method::PUT))
|
||||
.await
|
||||
.expect("put_object_legal_hold should succeed");
|
||||
assert_eq!(
|
||||
MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst),
|
||||
1,
|
||||
"PutObjectLegalHold must compute the replication decision exactly once; 0 means the \
|
||||
legal hold never replicates and the peer copy stays deletable"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! PutObject tests (batch D owns PutObject).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_object_if_none_match_existing_object_returns_precondition_failed() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-put-if-none-match-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let initial_payload = b"initial conditional put payload";
|
||||
let replacement_payload = b"replacement conditional put payload";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let initial_input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(initial_payload)))
|
||||
.content_length(Some(initial_payload.len() as i64))
|
||||
.build()
|
||||
.unwrap();
|
||||
Box::pin(usecase.execute_put_object(&fs, build_request(initial_input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to upload initial object through usecase");
|
||||
|
||||
let existing_info = ecstore
|
||||
.get_object_info(bucket.as_str(), object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to fetch existing object info");
|
||||
let existing_etag = existing_info.etag.expect("existing object should have an ETag");
|
||||
let initial_versions = live_object_version_count(&ecstore, bucket.as_str(), object).await;
|
||||
|
||||
for (condition, expected_code) in [
|
||||
(ETagCondition::Any, S3ErrorCode::PreconditionFailed),
|
||||
(ETagCondition::ETag(ETag::Strong(existing_etag)), S3ErrorCode::NotImplemented),
|
||||
(
|
||||
ETagCondition::ETag(ETag::Strong("different-etag".to_string())),
|
||||
S3ErrorCode::NotImplemented,
|
||||
),
|
||||
] {
|
||||
let header = condition.to_http_header().expect("conditional PUT header should encode");
|
||||
// Cover header-only callers and the DTO-plus-header shape of parsed HTTP requests.
|
||||
for if_none_match in [None, Some(condition)] {
|
||||
let has_dto = if_none_match.is_some();
|
||||
let replacement_input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(replacement_payload)))
|
||||
.content_length(Some(replacement_payload.len() as i64))
|
||||
.if_none_match(if_none_match)
|
||||
.build()
|
||||
.expect("conditional replacement PUT input should build");
|
||||
let mut req = build_request(replacement_input, Method::PUT);
|
||||
req.headers.insert(IF_NONE_MATCH, header.clone());
|
||||
|
||||
let err = Box::pin(usecase.execute_put_object(&fs, req))
|
||||
.await
|
||||
.expect_err("conditional PUT must reject the replacement");
|
||||
assert_eq!(err.code(), &expected_code, "header={header:?}, has_dto={has_dto}");
|
||||
assert_eq!(
|
||||
read_object_bytes(&ecstore, bucket.as_str(), object).await,
|
||||
initial_payload,
|
||||
"failed conditional PutObject must not overwrite the current object"
|
||||
);
|
||||
assert_eq!(
|
||||
live_object_version_count(&ecstore, bucket.as_str(), object).await,
|
||||
initial_versions,
|
||||
"failed conditional PutObject must not create a version"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// rustfs/backlog#1320: a single PUT must compute the replication decision
|
||||
/// exactly once. Historically the PUT path computed `must_replicate_object`
|
||||
/// twice with the same inputs — once before commit to persist the pending
|
||||
/// replication metadata, and again after commit to drive the schedule. Besides
|
||||
/// repeating the versioning/config/target traversal on the hot path, a
|
||||
/// replication-config hot update between the two computations could split the
|
||||
/// two phases (pending-without-schedule or schedule-without-pending). The fix
|
||||
/// reuses one immutable decision for both phases; this test observes the
|
||||
/// computation counter and fails if a second computation is reintroduced.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state usecase integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_object_computes_replication_decision_exactly_once() {
|
||||
use super::super::storage_api::object_usecase::bucket::replication::MUST_REPLICATE_OBJECT_CALLS;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let fs = FS::new();
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-repl-decision-once-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"replication decision single-compute payload";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
// Reset immediately before the single PUT so the counter reflects only this
|
||||
// request. `#[serial]` guarantees no other usecase PUT runs concurrently, so
|
||||
// no unrelated `must_replicate_object` call can perturb the count.
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
|
||||
let input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.body(Some(streaming_blob_from_bytes(payload)))
|
||||
.content_length(Some(payload.len() as i64))
|
||||
.build()
|
||||
.unwrap();
|
||||
Box::pin(usecase.execute_put_object(&fs, build_request(input, Method::PUT)))
|
||||
.await
|
||||
.expect("Failed to upload object through usecase");
|
||||
|
||||
let calls = MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
calls, 1,
|
||||
"a single PUT must compute the replication decision exactly once; reverting to a \
|
||||
pre-commit + post-commit recompute makes this observe 2 (rustfs/backlog#1320)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Snowball auto-extract writes each archive member as an independent object,
|
||||
/// so each member must join bucket replication like a regular PUT (MinIO
|
||||
/// PutObjectExtract parity). The archive holds two file entries; a count other
|
||||
/// than 2 means extracted objects bypass the replication decision entirely and
|
||||
/// stay local forever.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state usecase integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn put_object_extract_computes_replication_decision_per_entry() {
|
||||
use super::super::storage_api::object_usecase::bucket::replication::MUST_REPLICATE_OBJECT_CALLS;
|
||||
use super::super::storage_api::test::ReqInfo;
|
||||
use super::super::storage_api::test::bucket::metadata::BUCKET_POLICY_CONFIG;
|
||||
use std::io::Cursor;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let bucket = format!("test-extract-repl-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
// The extract path re-authorizes every extracted entry internally. The test
|
||||
// harness has no IAM system, so allow anonymous PutObject via bucket policy.
|
||||
let policy_json = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{bucket}/*")]
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
metadata_sys::update(bucket.as_str(), BUCKET_POLICY_CONFIG, policy_json.into_bytes())
|
||||
.await
|
||||
.expect("Failed to install anonymous put bucket policy");
|
||||
|
||||
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
||||
for (path, data) in [
|
||||
("member-one.txt", b"first member payload".as_slice()),
|
||||
("nested/member-two.txt", b"second member payload".as_slice()),
|
||||
] {
|
||||
let mut header = tokio_tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, path, Cursor::new(data))
|
||||
.await
|
||||
.expect("Failed to append archive member");
|
||||
}
|
||||
let archive = builder.into_inner().await.expect("Failed to finish archive").into_inner();
|
||||
|
||||
MUST_REPLICATE_OBJECT_CALLS.store(0, Ordering::SeqCst);
|
||||
|
||||
let input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("archive.tar".to_string())
|
||||
.body(Some(streaming_blob_from_bytes(&archive)))
|
||||
.content_length(Some(archive.len() as i64))
|
||||
.build()
|
||||
.unwrap();
|
||||
let mut req = build_request(input, Method::PUT);
|
||||
req.extensions.insert(ReqInfo::default());
|
||||
Box::pin(usecase.execute_put_object_extract(req))
|
||||
.await
|
||||
.expect("Failed to extract archive through usecase");
|
||||
|
||||
assert_eq!(
|
||||
MUST_REPLICATE_OBJECT_CALLS.load(Ordering::SeqCst),
|
||||
2,
|
||||
"snowball auto-extract must compute one replication decision per extracted member \
|
||||
object; 0 means extracted objects bypass bucket replication (P0-6 companion, MinIO \
|
||||
PutObjectExtract parity)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! RestoreObject tests (batch E owns RestoreObject).
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
const RESTORE_SUSPENDED_WAIT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
async fn suspend_test_bucket(bucket: &str) {
|
||||
DefaultBucketUsecase::from_global()
|
||||
.execute_put_bucket_versioning(
|
||||
build_request(
|
||||
PutBucketVersioningInput::builder()
|
||||
.bucket(bucket.to_string())
|
||||
.versioning_configuration(VersioningConfiguration {
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED)),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.expect("suspended versioning request should build"),
|
||||
Method::PUT,
|
||||
),
|
||||
Ok(rustfs_gateway_types::persistence::PersistedVersioningConfiguration {
|
||||
status: Some("Suspended".to_owned()),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("bucket versioning should be suspended");
|
||||
}
|
||||
|
||||
async fn transition_uploaded_object_directly(
|
||||
ecstore: &Arc<ECStore>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
tier_name: &str,
|
||||
uploaded: &ObjectInfo,
|
||||
) -> ObjectInfo {
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name.to_string(),
|
||||
etag: uploaded.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: uploaded.version_id.map(|version| version.to_string()),
|
||||
versioned: uploaded.version_id.is_some(),
|
||||
mod_time: uploaded.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
ecstore
|
||||
.transition_object(bucket, object, &transition_opts)
|
||||
.await
|
||||
.expect("Failed to transition object directly");
|
||||
wait_for_transition(ecstore, bucket, object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should transition before restore assertions")
|
||||
}
|
||||
|
||||
async fn wait_for_restore_completion(
|
||||
ecstore: &Arc<ECStore>,
|
||||
backend: &MockWarmBackend,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<ObjectInfo, String> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
let mut last_state = None;
|
||||
|
||||
loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
let tier_gets = backend.get_count().await;
|
||||
let op_log = backend.op_log().await;
|
||||
return Err(format!(
|
||||
"restore copy-back should complete within {timeout:?}; tier_gets={tier_gets}, op_log={op_log:?}; last observed state: {}",
|
||||
last_state.unwrap_or_else(|| "no object info observed".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
match (**ecstore).get_object_info(bucket, object, &ObjectOptions::default()).await {
|
||||
Ok(info) => {
|
||||
if !info.restore_ongoing && info.restore_expires.is_some() {
|
||||
return Ok(info);
|
||||
}
|
||||
last_state = Some(format!(
|
||||
"restore_ongoing={}, restore_expires={:?}, transitioned_status={}",
|
||||
info.restore_ongoing, info.restore_expires, info.transitioned_object.status
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
last_state = Some(format!("get_object_info failed: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// backlog#1148 ilm-8: the RestoreObject API surface on a transitioned object.
|
||||
///
|
||||
/// POST restore(days=1) is accepted and flips the object to
|
||||
/// `x-amz-restore: ongoing-request="true"` while the mock tier GET barrier
|
||||
/// proves the background copy-back has reached the remote read; a second POST
|
||||
/// during that window is rejected with 409 `RestoreAlreadyInProgress`.
|
||||
/// Synchronous SetDisks transition tests cover copy-back completion, restore
|
||||
/// metadata, and local byte-identical reads.
|
||||
///
|
||||
/// Re-enabled in the serial lane by backlog#1304: the accept path now flips
|
||||
/// the ongoing flag under a short compare-and-set guard and the copy-back
|
||||
/// runs without the #4877 whole-copy-back lock, so the mid-restore
|
||||
/// `ongoing-request="true"` read and the fast 409 rejection asserted here are
|
||||
/// the implemented contract.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-8)"]
|
||||
async fn restore_object_usecase_reports_ongoing_conflict() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-restore-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
// Keep the object under the shared ILM test prefix even though this setup
|
||||
// transitions it directly; it keeps diagnostics aligned with sibling tests.
|
||||
let object = "test/restore/api-object.bin";
|
||||
let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect();
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
assert!(uploaded.version_id.is_none(), "fixture must create the null version");
|
||||
let _ = transition_uploaded_object_directly(&ecstore, bucket.as_str(), object, &tier_name, &uploaded).await;
|
||||
backend.clear_op_log().await;
|
||||
let get_barrier = backend.arm_get_barrier().await;
|
||||
|
||||
let restore_request = || RestoreRequest {
|
||||
days: Some(1),
|
||||
description: None,
|
||||
glacier_job_parameters: None,
|
||||
output_location: None,
|
||||
select_parameters: None,
|
||||
tier: None,
|
||||
type_: None,
|
||||
};
|
||||
let restore_input = || {
|
||||
RestoreObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.restore_request(Some(restore_request()))
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
Box::pin(usecase.execute_restore_object(build_request(restore_input(), Method::POST)))
|
||||
.await
|
||||
.expect("restore request should be accepted");
|
||||
|
||||
get_barrier.wait_until_paused().await;
|
||||
|
||||
// The barrier proves the detached copy-back reached the tier GET and is
|
||||
// still paused, so the ongoing state and conflict rejection are not timing
|
||||
// assumptions about task scheduling.
|
||||
let ongoing = ecstore
|
||||
.get_object_info(bucket.as_str(), object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to load object info during restore");
|
||||
assert!(
|
||||
ongoing.restore_ongoing,
|
||||
"x-amz-restore must report ongoing-request=true while the copy-back tier GET is paused"
|
||||
);
|
||||
|
||||
// A second restore while one is in flight is rejected.
|
||||
let err = Box::pin(usecase.execute_restore_object(build_request(restore_input(), Method::POST)))
|
||||
.await
|
||||
.expect_err("a second restore while one is ongoing must be rejected");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
&S3ErrorCode::RestoreAlreadyInProgress,
|
||||
"unexpected rejection for a repeated restore: {err:?}"
|
||||
);
|
||||
|
||||
get_barrier.release();
|
||||
}
|
||||
|
||||
/// rustfs/backlog#1337: cancellation after the ongoing metadata commit but
|
||||
/// before detached worker creation must not strand the object forever. The
|
||||
/// replacement POST proves the abandoned v1 generation has no live worker,
|
||||
/// atomically supersedes it, and performs exactly one remote copy-back.
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1337"]
|
||||
#[test]
|
||||
fn restore_object_usecase_recovers_cancelled_post_commit_generation() {
|
||||
std::thread::Builder::new()
|
||||
.name("lifecycle-restore-orphan-recovery".to_string())
|
||||
.stack_size(32 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("orphan restore recovery test runtime should build");
|
||||
runtime.block_on(restore_object_usecase_recovers_cancelled_post_commit_generation_inner());
|
||||
})
|
||||
.expect("orphan restore recovery test thread should spawn")
|
||||
.join()
|
||||
.expect("orphan restore recovery test thread should finish");
|
||||
}
|
||||
|
||||
async fn restore_object_usecase_recovers_cancelled_post_commit_generation_inner() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
let bucket = format!("test-api-restore-orphan-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/restore/orphaned-generation.bin";
|
||||
let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect();
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
let _ = transition_uploaded_object_directly(&ecstore, bucket.as_str(), object, &tier_name, &uploaded).await;
|
||||
backend.clear_op_log().await;
|
||||
let tier_gets_before_restore = backend.get_count().await;
|
||||
|
||||
let restore_input = || {
|
||||
RestoreObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.restore_request(Some(RestoreRequest {
|
||||
days: Some(1),
|
||||
description: None,
|
||||
glacier_job_parameters: None,
|
||||
output_location: None,
|
||||
select_parameters: None,
|
||||
tier: None,
|
||||
type_: None,
|
||||
}))
|
||||
.build()
|
||||
.expect("restore request should build")
|
||||
};
|
||||
|
||||
let commit_barrier = crate::app::object::RestoreStatusCommitBarrier::install(bucket.as_str(), object);
|
||||
let first_input = restore_input();
|
||||
let first = tokio::spawn(async move {
|
||||
DefaultObjectUsecase::from_global()
|
||||
.execute_restore_object(build_request(first_input, Method::POST))
|
||||
.await
|
||||
});
|
||||
commit_barrier.wait_until_paused().await;
|
||||
|
||||
first.abort();
|
||||
assert!(first.await.expect_err("the first request must be cancelled").is_cancelled());
|
||||
drop(commit_barrier);
|
||||
|
||||
let orphaned = ecstore
|
||||
.get_object_info(bucket.as_str(), object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the committed orphan generation should remain readable");
|
||||
assert!(orphaned.restore_ongoing, "the interrupted request must have committed ongoing=true");
|
||||
assert_eq!(
|
||||
rustfs_utils::http::get_consistent_str(orphaned.user_defined.as_ref(), rustfs_utils::http::SUFFIX_RESTORE_WORKER_LOCK,),
|
||||
Some(rustfs_utils::http::RESTORE_WORKER_LOCK_PROTOCOL_V1),
|
||||
"recoverable generations must advertise the worker-lock protocol"
|
||||
);
|
||||
let orphaned_operation_id =
|
||||
rustfs_utils::http::get_consistent_str(orphaned.user_defined.as_ref(), rustfs_utils::http::SUFFIX_RESTORE_OPERATION_ID)
|
||||
.and_then(|value| Uuid::parse_str(value).ok())
|
||||
.expect("recoverable generations must persist a non-nil operation id");
|
||||
assert!(!orphaned_operation_id.is_nil());
|
||||
|
||||
DefaultObjectUsecase::from_global()
|
||||
.execute_restore_object(build_request(restore_input(), Method::POST))
|
||||
.await
|
||||
.expect("a new POST must supersede the committed generation whose worker lock was released");
|
||||
|
||||
let completed = wait_for_restore_completion(&ecstore, &backend, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{err}"));
|
||||
assert!(!completed.restore_ongoing);
|
||||
assert!(completed.restore_expires.is_some());
|
||||
assert!(
|
||||
rustfs_utils::http::get_str(completed.user_defined.as_ref(), rustfs_utils::http::SUFFIX_RESTORE_OPERATION_ID,).is_none(),
|
||||
"completed replacement restore must consume its operation id"
|
||||
);
|
||||
assert!(
|
||||
rustfs_utils::http::get_str(completed.user_defined.as_ref(), rustfs_utils::http::SUFFIX_RESTORE_WORKER_LOCK,).is_none(),
|
||||
"completed replacement restore must consume its liveness marker"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.get_count().await - tier_gets_before_restore,
|
||||
1,
|
||||
"the cancelled pre-spawn generation must not issue a tier GET"
|
||||
);
|
||||
}
|
||||
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#4879"]
|
||||
#[test]
|
||||
fn restore_object_usecase_completes_suspended_null_version_in_place() {
|
||||
std::thread::Builder::new()
|
||||
.name("lifecycle-restore-suspended-null".to_string())
|
||||
.stack_size(32 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("suspended null-version restore test runtime should build");
|
||||
|
||||
runtime.block_on(restore_object_usecase_completes_suspended_null_version_in_place_inner());
|
||||
})
|
||||
.expect("suspended null-version restore test thread should spawn")
|
||||
.join()
|
||||
.expect("suspended null-version restore test thread should finish");
|
||||
}
|
||||
|
||||
async fn restore_object_usecase_completes_suspended_null_version_in_place_inner() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
let bucket = format!("test-api-restore-suspended-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/restore/suspended-null.bin";
|
||||
let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect();
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
assert!(uploaded.version_id.is_none(), "fixture must create the null version");
|
||||
let _ = transition_uploaded_object_directly(&ecstore, bucket.as_str(), object, &tier_name, &uploaded).await;
|
||||
suspend_test_bucket(bucket.as_str()).await;
|
||||
backend.clear_op_log().await;
|
||||
let tier_gets_before_restore = backend.get_count().await;
|
||||
let get_barrier = backend.arm_get_barrier().await;
|
||||
|
||||
Box::pin(
|
||||
usecase.execute_restore_object(build_request(
|
||||
RestoreObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.restore_request(Some(RestoreRequest {
|
||||
days: Some(1),
|
||||
description: None,
|
||||
glacier_job_parameters: None,
|
||||
output_location: None,
|
||||
select_parameters: None,
|
||||
tier: None,
|
||||
type_: None,
|
||||
}))
|
||||
.build()
|
||||
.expect("restore request should build"),
|
||||
Method::POST,
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.expect("suspended null-version restore should be accepted");
|
||||
|
||||
get_barrier.wait_until_paused().await;
|
||||
get_barrier.release();
|
||||
let completed = wait_for_restore_completion(&ecstore, &backend, bucket.as_str(), object, RESTORE_SUSPENDED_WAIT_TIMEOUT)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{err}"));
|
||||
|
||||
assert!(!completed.restore_ongoing, "the original null version must complete in place");
|
||||
assert!(completed.restore_expires.is_some(), "completed restore must carry an expiry");
|
||||
assert!(
|
||||
completed.version_id.is_none() || completed.version_id.is_some_and(|version_id| version_id.is_nil()),
|
||||
"suspended restore must remain on the null version"
|
||||
);
|
||||
assert_eq!(
|
||||
live_object_version_count(&ecstore, bucket.as_str(), object).await,
|
||||
1,
|
||||
"suspended restore must not create a UUID version"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.get_count().await - tier_gets_before_restore,
|
||||
1,
|
||||
"suspended restore copy-back must fetch the tier exactly once"
|
||||
);
|
||||
}
|
||||
|
||||
/// backlog#1304: the restore-accept compare-and-set itself, under real
|
||||
/// concurrency. Two POST ?restore for the same transitioned object race each
|
||||
/// other from separate tasks; the accept guard must let exactly one through —
|
||||
/// the winner flips `ongoing-request="true"`, the loser must read that flag
|
||||
/// and be rejected with 409 `RestoreAlreadyInProgress` — and the tier must
|
||||
/// see exactly one copy-back GET, never two. Without the accept guard both
|
||||
/// requests can observe ongoing=false and double-start the copy-back (the
|
||||
/// #4859 race), which this test catches via the tier GET count.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-8)"]
|
||||
async fn restore_object_usecase_accepts_exactly_one_of_two_concurrent_restores() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-restore-cas-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
// Keep the object under the shared ILM test prefix for diagnostics parity.
|
||||
let object = "test/restore/cas-object.bin";
|
||||
let payload: Vec<u8> = (0..128 * 1024).map(|i| (i % 251) as u8).collect();
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
let _ = transition_uploaded_object_directly(&ecstore, bucket.as_str(), object, &tier_name, &uploaded).await;
|
||||
backend.clear_op_log().await;
|
||||
|
||||
// Hold the accepted copy-back at the tier GET until both accept attempts
|
||||
// return, so the loser cannot observe an already-restored object.
|
||||
let get_barrier = backend.arm_get_barrier().await;
|
||||
|
||||
let tier_gets_before_restore = backend.get_count().await;
|
||||
|
||||
let restore_input = || {
|
||||
RestoreObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.restore_request(Some(RestoreRequest {
|
||||
days: Some(1),
|
||||
description: None,
|
||||
glacier_job_parameters: None,
|
||||
output_location: None,
|
||||
select_parameters: None,
|
||||
tier: None,
|
||||
type_: None,
|
||||
}))
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let spawn_restore = |input: RestoreObjectInput| {
|
||||
tokio::spawn(async move {
|
||||
let usecase = DefaultObjectUsecase::from_global();
|
||||
Box::pin(usecase.execute_restore_object(build_request(input, Method::POST))).await
|
||||
})
|
||||
};
|
||||
|
||||
let (first, second) = tokio::join!(spawn_restore(restore_input()), spawn_restore(restore_input()));
|
||||
let results = [
|
||||
first.expect("first concurrent restore task panicked"),
|
||||
second.expect("second concurrent restore task panicked"),
|
||||
];
|
||||
|
||||
let accepted = results.iter().filter(|result| result.is_ok()).count();
|
||||
assert_eq!(accepted, 1, "exactly one of two concurrent restores must be accepted, got {accepted}");
|
||||
let rejection = results
|
||||
.iter()
|
||||
.find_map(|result| result.as_ref().err())
|
||||
.expect("the losing concurrent restore must surface an error");
|
||||
assert_eq!(
|
||||
rejection.code(),
|
||||
&S3ErrorCode::RestoreAlreadyInProgress,
|
||||
"the losing concurrent restore must be rejected as already in progress: {rejection:?}"
|
||||
);
|
||||
|
||||
get_barrier.wait_until_paused().await;
|
||||
get_barrier.release();
|
||||
|
||||
// This test is scoped to the accept CAS: completion, expiry metadata, and
|
||||
// local restored GET service are covered by the single-request restore test
|
||||
// above. Here it is enough to prove exactly one copy-back was admitted.
|
||||
let expected_tier_gets = tier_gets_before_restore + 1;
|
||||
let deadline = tokio::time::Instant::now() + TRANSITION_WAIT_TIMEOUT;
|
||||
loop {
|
||||
let actual_tier_gets = backend.get_count().await;
|
||||
if actual_tier_gets >= expected_tier_gets {
|
||||
assert_eq!(
|
||||
actual_tier_gets - tier_gets_before_restore,
|
||||
1,
|
||||
"two concurrent restore requests must trigger exactly one tier copy-back GET"
|
||||
);
|
||||
break;
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
let op_log = backend.op_log().await;
|
||||
panic!(
|
||||
"mock tier should record exactly one restore GET within {TRANSITION_WAIT_TIMEOUT:?}; \
|
||||
tier_gets_before_restore={tier_gets_before_restore}, actual_tier_gets={actual_tier_gets}, op_log={op_log:?}"
|
||||
);
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! ILM transition-engine tests that call no S3 use-case; owned by no operation batch.
|
||||
|
||||
use super::common::*;
|
||||
use super::*;
|
||||
|
||||
const ENV_GET_CODEC_STREAMING_ENABLE: &str = "RUSTFS_GET_CODEC_STREAMING_ENABLE";
|
||||
|
||||
const ENV_GET_CODEC_STREAMING_ROLLOUT: &str = "RUSTFS_GET_CODEC_STREAMING_ROLLOUT";
|
||||
|
||||
const ENV_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED: &str = "RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED";
|
||||
|
||||
const ENV_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED: &str = "RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED";
|
||||
|
||||
const ENV_GET_CODEC_STREAMING_MIN_SIZE: &str = "RUSTFS_GET_CODEC_STREAMING_MIN_SIZE";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn transition_wait_timeout_reports_metadata_state_and_read_failure() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("test-transition-wait-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
upload_test_object(&ecstore, &bucket, "plain.txt", b"not transitioned").await;
|
||||
|
||||
let pending = wait_for_transition(&ecstore, &bucket, "plain.txt", Duration::ZERO)
|
||||
.await
|
||||
.expect_err("an untransitioned object must still fail the observation deadline");
|
||||
assert!(pending.contains("transitioned_status=\"\""), "{pending}");
|
||||
assert!(pending.contains("compensation_pending="), "{pending}");
|
||||
assert!(pending.contains("compensation_running="), "{pending}");
|
||||
|
||||
let missing = wait_for_transition(&ecstore, &bucket, "missing.txt", Duration::ZERO)
|
||||
.await
|
||||
.expect_err("metadata read failures must not become a completed transition");
|
||||
assert!(missing.contains("metadata_error=ObjectNotFound"), "{missing}");
|
||||
}
|
||||
|
||||
async fn with_get_codec_streaming_remote_probe_env<F, Fut>(test_fn: F)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = ()>,
|
||||
{
|
||||
let metrics_was_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
|
||||
let result = std::panic::AssertUnwindSafe(temp_env::async_with_vars(
|
||||
[
|
||||
(ENV_GET_CODEC_STREAMING_ENABLE, Some("true")),
|
||||
(ENV_GET_CODEC_STREAMING_ROLLOUT, Some("benchmark")),
|
||||
(ENV_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED, Some("true")),
|
||||
(ENV_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED, Some("true")),
|
||||
(ENV_GET_CODEC_STREAMING_MIN_SIZE, Some("1")),
|
||||
],
|
||||
test_fn(),
|
||||
))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(metrics_was_enabled);
|
||||
if let Err(err) = result {
|
||||
std::panic::resume_unwind(err);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
fn get_transitioned_object_uses_remote_codec_fallback_path() {
|
||||
// CI's default test stack overflows this deep async path; run it on a dedicated large-stack thread.
|
||||
std::thread::Builder::new()
|
||||
.name("lifecycle-transition-remote-codec-fallback".to_string())
|
||||
.stack_size(16 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("remote codec fallback test runtime should build");
|
||||
|
||||
runtime.block_on(async {
|
||||
with_get_codec_streaming_remote_probe_env(|| async {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-get-remote-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/remote-codec-fallback.txt";
|
||||
let payload: Vec<u8> = (0..(1024 * 1024))
|
||||
.map(|index| u8::try_from(index % 251).expect("payload byte fits in u8"))
|
||||
.collect();
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name.clone(),
|
||||
etag: uploaded.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: uploaded.version_id.map(|version| version.to_string()),
|
||||
versioned: true,
|
||||
mod_time: uploaded.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
ecstore
|
||||
.transition_object(bucket.as_str(), object, &transition_opts)
|
||||
.await
|
||||
.expect("Failed to transition object directly");
|
||||
|
||||
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should transition before remote fallback GET");
|
||||
|
||||
assert_eq!(transitioned.transitioned_object.status, "complete");
|
||||
assert_eq!(transitioned.transitioned_object.tier, tier_name);
|
||||
assert!(!transitioned.transitioned_object.name.is_empty());
|
||||
assert!(backend.contains(&transitioned.transitioned_object.name).await);
|
||||
|
||||
let actual = read_object_bytes(&ecstore, bucket.as_str(), object).await;
|
||||
assert_eq!(actual, payload);
|
||||
})
|
||||
.await;
|
||||
});
|
||||
})
|
||||
.expect("remote codec fallback test thread should spawn")
|
||||
.join()
|
||||
.expect("remote codec fallback test thread should not panic");
|
||||
}
|
||||
|
||||
/// Regression test for rustfs/rustfs#4827: a duplicate transition task that runs
|
||||
/// after the winning transition completed must return early without a second
|
||||
/// tier upload and without clobbering the winner's remote object / metadata.
|
||||
///
|
||||
/// The duplicate models the residual race left after the in-flight enqueue dedup
|
||||
/// (#4839): a stale-snapshot task admitted after the winner released its claim.
|
||||
/// On an unversioned bucket the duplicate's metadata re-read is eligible for the
|
||||
/// GET metadata cache; before the fix the cache still held the pre-transition
|
||||
/// entry (transition_object never invalidated it after persisting), so the
|
||||
/// TRANSITION_COMPLETE early-return never fired and the duplicate streamed the
|
||||
/// already-deleted local data to the tier again (NotFound reader errors +
|
||||
/// rejected duplicate tier PUT).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn duplicate_transition_task_skips_already_transitioned_version() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-api-dup-transition-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/duplicate-transition.txt";
|
||||
let payload = b"duplicate transition tasks must not re-upload";
|
||||
|
||||
// Unversioned bucket: the duplicate's re-read goes through the GET metadata
|
||||
// cache (versioned reads bypass it), which is the stale-read window under test.
|
||||
(*ecstore)
|
||||
.make_bucket(bucket.as_str(), &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("Failed to create unversioned test bucket");
|
||||
|
||||
let uploaded = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name.clone(),
|
||||
etag: uploaded.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: uploaded.version_id.map(|version| version.to_string()),
|
||||
mod_time: uploaded.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Winner: transitions the object, persists transition_status=complete, and
|
||||
// deletes the local data.
|
||||
ecstore
|
||||
.transition_object(bucket.as_str(), object, &transition_opts)
|
||||
.await
|
||||
.expect("winning transition should succeed");
|
||||
|
||||
let puts_after_winner: Vec<String> = backend
|
||||
.op_log()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter_map(|op| match op {
|
||||
MockWarmOp::Put { object, .. } => Some(object),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(puts_after_winner.len(), 1, "winner must upload exactly one tier object");
|
||||
let winner_remote_object = puts_after_winner[0].clone();
|
||||
|
||||
// Duplicate task with the same stale snapshot (same mod_time / etag), run
|
||||
// immediately so the pre-fix stale metadata-cache entry is still live. It
|
||||
// must observe transition_status=complete and return Ok without uploading.
|
||||
ecstore
|
||||
.transition_object(bucket.as_str(), object, &transition_opts)
|
||||
.await
|
||||
.expect("duplicate transition must return early without error");
|
||||
|
||||
let puts_after_duplicate = backend
|
||||
.op_log()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|op| matches!(op, MockWarmOp::Put { .. }))
|
||||
.count();
|
||||
assert_eq!(puts_after_duplicate, 1, "duplicate transition must not attempt a second tier upload");
|
||||
|
||||
// The winner's remote object and local metadata must be intact.
|
||||
assert!(backend.contains(&winner_remote_object).await);
|
||||
let info = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object must remain transitioned after duplicate task");
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert_eq!(
|
||||
info.transitioned_object.name, winner_remote_object,
|
||||
"duplicate transition must not repoint metadata at a new remote object"
|
||||
);
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, payload);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn transition_rejects_stale_etag_before_remote_upload() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
let bucket = format!("test-api-transition-stale-etag-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/transition-stale-etag.txt";
|
||||
(*ecstore)
|
||||
.make_bucket(bucket.as_str(), &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("unversioned test bucket should be created");
|
||||
let original = upload_test_object(&ecstore, bucket.as_str(), object, b"original transition candidate").await;
|
||||
let replacement_payload = b"replacement committed before the stale transition starts";
|
||||
let replacement = upload_test_object(&ecstore, bucket.as_str(), object, replacement_payload).await;
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name,
|
||||
etag: original.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: replacement.version_id.map(|version| version.to_string()),
|
||||
mod_time: replacement.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
ecstore
|
||||
.transition_object(bucket.as_str(), object, &transition_opts)
|
||||
.await
|
||||
.expect_err("stale ETag must reject the transition before remote upload");
|
||||
assert_eq!(backend.put_count().await, 0, "rejected stale work must not upload a remote candidate");
|
||||
assert_eq!(
|
||||
read_object_bytes(&ecstore, bucket.as_str(), object).await,
|
||||
replacement_payload,
|
||||
"the replacement object must remain local and readable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn transition_commit_rejects_replaced_source() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
let bucket = format!("test-api-transition-replace-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/transition-replaced-source.txt";
|
||||
let original_payload = b"transition source that must not replace a concurrent overwrite";
|
||||
let replacement_payload = b"concurrent replacement must remain the authoritative local object";
|
||||
|
||||
(*ecstore)
|
||||
.make_bucket(bucket.as_str(), &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("unversioned test bucket should be created");
|
||||
let original = upload_test_object(&ecstore, bucket.as_str(), object, original_payload).await;
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name.clone(),
|
||||
etag: original.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: original.version_id.map(|version| version.to_string()),
|
||||
mod_time: original.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let put_barrier = backend.arm_put_barrier().await;
|
||||
let transition_store = Arc::clone(&ecstore);
|
||||
let transition_bucket = bucket.clone();
|
||||
let transition = tokio::spawn(async move {
|
||||
transition_store
|
||||
.transition_object(&transition_bucket, object, &transition_opts)
|
||||
.await
|
||||
});
|
||||
put_barrier.wait_until_paused().await;
|
||||
|
||||
upload_test_object(&ecstore, bucket.as_str(), object, replacement_payload).await;
|
||||
put_barrier.release();
|
||||
|
||||
transition
|
||||
.await
|
||||
.expect("transition task should not panic")
|
||||
.expect_err("transition must reject a source replaced during remote upload");
|
||||
assert_eq!(
|
||||
backend.put_count().await,
|
||||
1,
|
||||
"the stale transition should upload exactly one remote candidate"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.remove_count().await,
|
||||
1,
|
||||
"the uncommitted remote candidate must be removed after identity revalidation fails"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.remove_versions().await,
|
||||
backend.put_versions().await,
|
||||
"cleanup must delete the exact object version returned by the tier PUT"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.object_count().await,
|
||||
0,
|
||||
"the rejected transition must not leave a remote candidate"
|
||||
);
|
||||
assert_eq!(
|
||||
read_object_bytes(&ecstore, bucket.as_str(), object).await,
|
||||
replacement_payload,
|
||||
"the replacement object must remain readable and unmodified"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn cancelled_transition_waiting_for_prepared_reader_cleans_remote() {
|
||||
temp_env::async_with_vars([(ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE, Some("false"))], async {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
let bucket = format!("test-api-transition-reader-lock-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/transition-reader-lock.txt";
|
||||
let payload = b"prepared reader must keep transition commit behind the namespace lock".repeat(1024);
|
||||
|
||||
(*ecstore)
|
||||
.make_bucket(bucket.as_str(), &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("unversioned test bucket should be created");
|
||||
let original = upload_test_object(&ecstore, bucket.as_str(), object, &payload).await;
|
||||
let transition_opts = ObjectOptions {
|
||||
transition: lifecycle::lifecycle_contract::TransitionOptions {
|
||||
status: lifecycle::lifecycle_contract::TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name,
|
||||
etag: original.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: original.version_id.map(|version| version.to_string()),
|
||||
mod_time: original.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let put_barrier = backend.arm_put_barrier().await;
|
||||
let transition_store = Arc::clone(&ecstore);
|
||||
let transition_bucket = bucket.clone();
|
||||
let mut transition = tokio::spawn(async move {
|
||||
transition_store
|
||||
.transition_object(&transition_bucket, object, &transition_opts)
|
||||
.await
|
||||
});
|
||||
tokio::select! {
|
||||
result = &mut transition => panic!("transition ended before reaching the tier PUT barrier: {result:?}"),
|
||||
() = put_barrier.wait_until_paused() => {}
|
||||
}
|
||||
|
||||
let prepared_reader = ecstore
|
||||
.prepare_get_object_reader(bucket.as_str(), object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("prepared reader should resolve while the local source still exists");
|
||||
put_barrier.release();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(200), &mut transition)
|
||||
.await
|
||||
.is_err(),
|
||||
"transition commit must wait while a prepared reader owns the namespace read lock"
|
||||
);
|
||||
|
||||
transition.abort();
|
||||
assert!(
|
||||
transition
|
||||
.await
|
||||
.expect_err("transition task should be cancelled")
|
||||
.is_cancelled(),
|
||||
"transition cancellation should not be reported as a task panic"
|
||||
);
|
||||
drop(prepared_reader);
|
||||
assert_eq!(backend.put_count().await, 1, "the transition should upload exactly one remote candidate");
|
||||
assert!(
|
||||
backend.wait_for_object_count(0, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"cancelling while the commit waits for the namespace lock must clean the exact uploaded candidate"
|
||||
);
|
||||
assert_eq!(backend.remove_versions().await, backend.put_versions().await);
|
||||
assert_eq!(read_object_bytes(&ecstore, bucket.as_str(), object).await, payload);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn lifecycle_transition_marks_dirty_disks_for_capacity_manager() {
|
||||
let (disk_paths, ecstore) = setup_test_env().await;
|
||||
let manager = create_isolated_manager(HybridStrategyConfig::default());
|
||||
let _ = manager.get_dirty_disks().await;
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let _backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-capacity-transition-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"transition should mark dirty scope";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
|
||||
lifecycle::bucket_lifecycle_ops::enqueue_transition_for_existing_objects(ecstore.clone(), bucket.as_str())
|
||||
.await
|
||||
.expect("Failed to enqueue transitioned object");
|
||||
|
||||
let _ = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should transition before dirty scope assertion");
|
||||
|
||||
let dirty_disks = manager.get_dirty_disks().await;
|
||||
assert_eq!(dirty_disks.len(), disk_paths.len());
|
||||
|
||||
let actual_paths: std::collections::HashSet<_> = dirty_disks
|
||||
.into_iter()
|
||||
.map(|disk| stdfs::canonicalize(&disk.drive_path).unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let expected_paths: std::collections::HashSet<_> = disk_paths
|
||||
.iter()
|
||||
.map(|path| stdfs::canonicalize(path).unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(actual_paths, expected_paths);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
|
||||
async fn immediate_transition_timeout_eventually_completes_via_compensation() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
|
||||
let bucket = format!("test-compensation-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object = "test/object.txt";
|
||||
let payload = b"transition compensation should eventually complete";
|
||||
|
||||
create_test_bucket(&ecstore, bucket.as_str()).await;
|
||||
set_bucket_lifecycle_transition_with_tier(bucket.as_str(), &tier_name)
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
|
||||
with_forced_immediate_enqueue_timeout(&backend, || async {
|
||||
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let info = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await
|
||||
.expect("object should eventually transition after compensation backfill");
|
||||
|
||||
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
}
|
||||
@@ -49,3 +49,5 @@ pub(crate) mod gating_test_env;
|
||||
mod lifecycle_transition_api_test;
|
||||
#[cfg(test)]
|
||||
mod put_prelookup_gating_test;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_seams;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
use super::*;
|
||||
use crate::app::storage_api::object_usecase::DeleteObjectCondition;
|
||||
use s3s::dto::ObjectIdentifier;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
|
||||
async fn authorize_recursive_delete<T>(
|
||||
@@ -81,6 +82,19 @@ async fn authorize_recursive_delete<T>(
|
||||
result
|
||||
}
|
||||
|
||||
// XML body keys bypass s3s's URL-path normalization. Match its private slash
|
||||
// normalizer without URL-decoding or resolving dot segments in client keys.
|
||||
fn normalize_delete_object_key(key: &str) -> Cow<'_, str> {
|
||||
if !key.starts_with('/') {
|
||||
return Cow::Borrowed(key);
|
||||
}
|
||||
let mut normalized = key.split('/').filter(|part| !part.is_empty()).collect::<Vec<_>>().join("/");
|
||||
if key.ends_with('/') {
|
||||
normalized.push('/');
|
||||
}
|
||||
Cow::Owned(normalized)
|
||||
}
|
||||
|
||||
fn successful_delete_audit_objects(
|
||||
delete: &s3s::dto::Delete,
|
||||
successful_results: impl IntoIterator<Item = bool>,
|
||||
@@ -90,7 +104,9 @@ fn successful_delete_audit_objects(
|
||||
.iter()
|
||||
.zip(successful_results)
|
||||
.filter(|(_, successful)| *successful)
|
||||
.map(|(requested, _)| AuditObjectVersion::new(requested.key.clone(), requested.version_id.clone()))
|
||||
.map(|(requested, _)| {
|
||||
AuditObjectVersion::new(normalize_delete_object_key(&requested.key).into_owned(), requested.version_id.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -590,7 +606,10 @@ impl DefaultObjectUsecase {
|
||||
// Per-key denials are audited once per denied action, in aggregate, after the loop.
|
||||
let mut bulk_denials = crate::storage::access::BulkDenialAudit::start(&mut req)?;
|
||||
for (idx, obj_id) in delete.objects.iter().enumerate() {
|
||||
if let Err(err) = validate_object_key(&obj_id.key, "DeleteObjects") {
|
||||
// Authorization and every storage side effect must address the same
|
||||
// normalized object; retain the request key only for response entries.
|
||||
let key = normalize_delete_object_key(&obj_id.key);
|
||||
if let Err(err) = validate_object_key(&key, "DeleteObjects") {
|
||||
delete_results[idx].error = Some(s3s::dto::Error {
|
||||
code: Some(err.code().as_str().to_string()),
|
||||
key: Some(obj_id.key.clone()),
|
||||
@@ -623,7 +642,7 @@ impl DefaultObjectUsecase {
|
||||
{
|
||||
let req_info = req_info_mut(&mut req)?;
|
||||
req_info.bucket = Some(bucket.clone());
|
||||
req_info.object = Some(obj_id.key.clone());
|
||||
req_info.object = Some(key.to_string());
|
||||
req_info.version_id = version_id.clone();
|
||||
}
|
||||
|
||||
@@ -670,7 +689,7 @@ impl DefaultObjectUsecase {
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = validate_table_catalog_object_mutation(&bucket, &obj_id.key).await {
|
||||
if let Err(err) = validate_table_catalog_object_mutation(&bucket, &key).await {
|
||||
delete_results[idx].error = Some(s3s::dto::Error {
|
||||
code: Some("InvalidRequest".to_string()),
|
||||
key: Some(obj_id.key.clone()),
|
||||
@@ -680,9 +699,9 @@ impl DefaultObjectUsecase {
|
||||
continue;
|
||||
}
|
||||
|
||||
let synthetic_version_id = delete_condition.is_none() && version_id.is_none() && is_dir_object(&obj_id.key);
|
||||
let synthetic_version_id = delete_condition.is_none() && version_id.is_none() && is_dir_object(&key);
|
||||
let object = ObjectToDelete {
|
||||
object_name: obj_id.key.clone(),
|
||||
object_name: key.into_owned(),
|
||||
version_id: version_uuid,
|
||||
delete_condition,
|
||||
synthetic_version_id,
|
||||
@@ -910,15 +929,16 @@ impl DefaultObjectUsecase {
|
||||
let deleted = (!delete.quiet.unwrap_or_default()).then(|| {
|
||||
delete_results
|
||||
.iter()
|
||||
.filter_map(|result| result.delete_object.as_ref().map(|object| (result, object)))
|
||||
.map(|(result, object)| DeletedObject {
|
||||
.enumerate()
|
||||
.filter_map(|(idx, result)| result.delete_object.as_ref().map(|object| (idx, result, object)))
|
||||
.map(|(idx, result, object)| DeletedObject {
|
||||
delete_marker: { if object.delete_marker { Some(true) } else { None } },
|
||||
delete_marker_version_id: delete_marker_response_version_id(
|
||||
object.delete_marker_version_id,
|
||||
object.delete_marker && result.version_suspended && result.requested_version_id.is_none(),
|
||||
result.synthetic_version_id,
|
||||
),
|
||||
key: Some(object.object_name.clone()),
|
||||
key: Some(delete.objects[idx].key.clone()),
|
||||
version_id: delete_entry_response_version_id(
|
||||
object.version_id,
|
||||
object.delete_marker,
|
||||
@@ -936,7 +956,12 @@ impl DefaultObjectUsecase {
|
||||
|
||||
let errors = delete_results
|
||||
.iter()
|
||||
.filter_map(|v| v.error.clone())
|
||||
.enumerate()
|
||||
.filter_map(|(idx, result)| {
|
||||
let mut error = result.error.clone()?;
|
||||
error.key = Some(delete.objects[idx].key.clone());
|
||||
Some(error)
|
||||
})
|
||||
.collect::<Vec<s3s::dto::Error>>();
|
||||
let output = DeleteObjectsOutput {
|
||||
deleted,
|
||||
@@ -1986,12 +2011,10 @@ mod tests {
|
||||
.version_id(requested_version)
|
||||
.build()
|
||||
.expect("HEAD request");
|
||||
let get_error = usecase
|
||||
.execute_get_object(build_request(get, Method::GET))
|
||||
let get_error = crate::app::test_seams::get_object(&usecase, build_request(get, Method::GET))
|
||||
.await
|
||||
.expect_err("marker GET");
|
||||
let head_error = usecase
|
||||
.execute_head_object(build_request(head, Method::HEAD))
|
||||
let head_error = crate::app::test_seams::head_object(&usecase, build_request(head, Method::HEAD))
|
||||
.await
|
||||
.expect_err("marker HEAD");
|
||||
for (method, error) in [("GET", get_error), ("HEAD", head_error)] {
|
||||
@@ -2038,8 +2061,7 @@ mod tests {
|
||||
.version_id(Some(original_id.clone()))
|
||||
.build()
|
||||
.expect("historical GET request");
|
||||
let response = usecase
|
||||
.execute_get_object(build_request(get, Method::GET))
|
||||
let response = crate::app::test_seams::get_object(&usecase, build_request(get, Method::GET))
|
||||
.await
|
||||
.expect("historical GET");
|
||||
assert!(!response.headers.contains_key("x-amz-delete-marker"));
|
||||
@@ -2055,8 +2077,7 @@ mod tests {
|
||||
.version_id(Some(original_id.clone()))
|
||||
.build()
|
||||
.expect("historical HEAD request");
|
||||
let response = usecase
|
||||
.execute_head_object(build_request(head, Method::HEAD))
|
||||
let response = crate::app::test_seams::head_object(&usecase, build_request(head, Method::HEAD))
|
||||
.await
|
||||
.expect("historical HEAD");
|
||||
assert_eq!(response.output.content_length, Some(i64::try_from(payload.len()).unwrap()));
|
||||
@@ -2076,12 +2097,10 @@ mod tests {
|
||||
.version_id(version)
|
||||
.build()
|
||||
.expect("missing HEAD request");
|
||||
let get_error = usecase
|
||||
.execute_get_object(build_request(get, Method::GET))
|
||||
let get_error = crate::app::test_seams::get_object(&usecase, build_request(get, Method::GET))
|
||||
.await
|
||||
.expect_err("missing GET");
|
||||
let head_error = usecase
|
||||
.execute_head_object(build_request(head, Method::HEAD))
|
||||
let head_error = crate::app::test_seams::head_object(&usecase, build_request(head, Method::HEAD))
|
||||
.await
|
||||
.expect_err("missing HEAD");
|
||||
for error in [get_error, head_error] {
|
||||
@@ -2176,7 +2195,7 @@ mod tests {
|
||||
.version_id(Some(marker_id.clone()))
|
||||
.build()
|
||||
.unwrap();
|
||||
let get_err = Box::pin(usecase.execute_get_object(build_request(get, Method::GET)))
|
||||
let get_err = Box::pin(crate::app::test_seams::get_object(&usecase, build_request(get, Method::GET)))
|
||||
.await
|
||||
.expect_err("GET of a marker remains forbidden");
|
||||
assert_eq!(get_err.code(), &S3ErrorCode::MethodNotAllowed);
|
||||
@@ -2186,7 +2205,7 @@ mod tests {
|
||||
.version_id(Some(marker_id.clone()))
|
||||
.build()
|
||||
.unwrap();
|
||||
let head_err = Box::pin(usecase.execute_head_object(build_request(head, Method::HEAD)))
|
||||
let head_err = Box::pin(crate::app::test_seams::head_object(&usecase, build_request(head, Method::HEAD)))
|
||||
.await
|
||||
.expect_err("HEAD of a marker remains forbidden");
|
||||
assert_eq!(head_err.code(), &S3ErrorCode::MethodNotAllowed);
|
||||
@@ -2262,7 +2281,7 @@ mod tests {
|
||||
.version_id(Some(marker_id))
|
||||
.build()
|
||||
.unwrap();
|
||||
let missing = Box::pin(usecase.execute_get_object(build_request(removed, Method::GET)))
|
||||
let missing = Box::pin(crate::app::test_seams::get_object(&usecase, build_request(removed, Method::GET)))
|
||||
.await
|
||||
.expect_err("the marker version must actually be gone");
|
||||
assert_eq!(
|
||||
@@ -2275,7 +2294,7 @@ mod tests {
|
||||
.key(key.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
let response = Box::pin(usecase.execute_get_object(build_request(get, Method::GET)))
|
||||
let response = Box::pin(crate::app::test_seams::get_object(&usecase, build_request(get, Method::GET)))
|
||||
.await
|
||||
.expect("GET restores the historical payload");
|
||||
let mut body = response.output.body.expect("GET body");
|
||||
@@ -2819,11 +2838,57 @@ mod tests {
|
||||
assert_eq!(err.message(), Some("Not init"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_objects_key_normalization_matches_s3s_path_keys() {
|
||||
use s3s::path::{
|
||||
parse_path_style_with_validation_and_normalization, parse_virtual_hosted_style_with_validation_and_normalization,
|
||||
};
|
||||
use s3s::validation::AwsNameValidation;
|
||||
|
||||
for key in [
|
||||
"plain",
|
||||
"/a/b",
|
||||
"//a/b",
|
||||
"/dir///sub//file",
|
||||
"/dir///",
|
||||
"/",
|
||||
"///",
|
||||
"dir//file",
|
||||
"/literal%2F+名",
|
||||
"/dir/../file",
|
||||
"/dir/./file",
|
||||
"/dir\\file",
|
||||
"/a\0b",
|
||||
] {
|
||||
let normalized = normalize_delete_object_key(key);
|
||||
let path = parse_path_style_with_validation_and_normalization(
|
||||
&format!("/test-bucket/{key}"),
|
||||
&AwsNameValidation::new(),
|
||||
true,
|
||||
)
|
||||
.expect("path-style key should parse before storage validation");
|
||||
let virtual_host = parse_virtual_hosted_style_with_validation_and_normalization(
|
||||
Some("test-bucket"),
|
||||
&format!("/{key}"),
|
||||
&AwsNameValidation::new(),
|
||||
true,
|
||||
)
|
||||
.expect("virtual-hosted key should parse before storage validation");
|
||||
assert_eq!(Some(normalized.as_ref()), path.get_object_key(), "path-style mismatch for {key:?}");
|
||||
assert_eq!(
|
||||
Some(normalized.as_ref()),
|
||||
virtual_host.get_object_key(),
|
||||
"virtual-hosted mismatch for {key:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(normalize_delete_object_key(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_objects_audit_details_include_only_successful_request_entries() {
|
||||
let requested = vec![
|
||||
ObjectIdentifier {
|
||||
key: "first-key".to_string(),
|
||||
key: "/first-key".to_string(),
|
||||
version_id: None,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -2833,7 +2898,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
ObjectIdentifier {
|
||||
key: "versioned-key".to_string(),
|
||||
key: "//versioned-key".to_string(),
|
||||
version_id: Some("requested-version".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -2674,9 +2674,7 @@ mod tests {
|
||||
.expect("metadata GET input must build");
|
||||
let metadata_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
let metadata_get = tokio::spawn(async move {
|
||||
metadata_usecase
|
||||
.execute_get_object(build_request(metadata_input, Method::GET))
|
||||
.await
|
||||
crate::app::test_seams::get_object(&metadata_usecase, build_request(metadata_input, Method::GET)).await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(2), metadata_entered.wait())
|
||||
.await
|
||||
@@ -2705,7 +2703,10 @@ mod tests {
|
||||
.build()
|
||||
.expect("GET input must build");
|
||||
let get_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
let get = tokio::spawn(async move { get_usecase.execute_get_object(build_request(get_input, Method::GET)).await });
|
||||
let get =
|
||||
tokio::spawn(
|
||||
async move { crate::app::test_seams::get_object(&get_usecase, build_request(get_input, Method::GET)).await },
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !object_traffic_health.read_storage_stalled_for_test() {
|
||||
tokio::task::yield_now().await;
|
||||
@@ -4225,7 +4226,7 @@ mod tests {
|
||||
.expect("SSE-C HEAD input must build");
|
||||
let mut head_request = build_request(head_input, Method::HEAD);
|
||||
head_request.headers = read_headers.clone();
|
||||
let head = Box::pin(usecase.execute_head_object(head_request))
|
||||
let head = Box::pin(crate::app::test_seams::head_object(&usecase, head_request))
|
||||
.await
|
||||
.expect("matching SSE-C key must authorize HEAD");
|
||||
assert_eq!(head.output.server_side_encryption, None);
|
||||
@@ -4242,7 +4243,7 @@ mod tests {
|
||||
.expect("SSE-C GET input must build");
|
||||
let mut get_request = build_request(get_input, Method::GET);
|
||||
get_request.headers = read_headers;
|
||||
let get = Box::pin(usecase.execute_get_object(get_request))
|
||||
let get = Box::pin(crate::app::test_seams::get_object(&usecase, get_request))
|
||||
.await
|
||||
.expect("matching SSE-C key must authorize GET");
|
||||
assert_eq!(get.output.server_side_encryption, None);
|
||||
|
||||
@@ -59,6 +59,8 @@ pub(crate) mod s3 {
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::dto::{GetObjectInput, HeadObjectInput, ListObjectsV2Input, ListObjectsV2Output};
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::dto::{GetObjectOutput, HeadObjectOutput, PutObjectInput, PutObjectOutput};
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::dto::{ListObjectsInput, StreamingBlob, UploadPartInput, UploadPartOutput};
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::service::{S3Service, S3ServiceBuilder};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::app::object_usecase::DefaultObjectUsecase;
|
||||
use crate::app::storage_api::s3::{GetObjectInput, GetObjectOutput, S3Request, S3Response, S3Result};
|
||||
use std::future::Future;
|
||||
|
||||
/// Calls `DefaultObjectUsecase::execute_get_object` with today's arguments.
|
||||
///
|
||||
/// Owned by the GetObject batch (C). Tests outside that batch's files call this seam instead of the use-case method;
|
||||
/// when batch C changes the use-case signature it changes only this body.
|
||||
pub(crate) fn get_object(
|
||||
usecase: &DefaultObjectUsecase,
|
||||
req: S3Request<GetObjectInput>,
|
||||
) -> impl Future<Output = S3Result<S3Response<GetObjectOutput>>> + '_ {
|
||||
usecase.execute_get_object(req)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::app::object_usecase::DefaultObjectUsecase;
|
||||
use crate::app::storage_api::s3::{HeadObjectInput, HeadObjectOutput, S3Request, S3Response, S3Result};
|
||||
use std::future::Future;
|
||||
|
||||
/// Calls `DefaultObjectUsecase::execute_head_object` with today's arguments.
|
||||
///
|
||||
/// Owned by the HeadObject batch (C). Tests outside that batch's files call this seam instead of the use-case method;
|
||||
/// when batch C changes the use-case signature it changes only this body.
|
||||
pub(crate) fn head_object(
|
||||
usecase: &DefaultObjectUsecase,
|
||||
req: S3Request<HeadObjectInput>,
|
||||
) -> impl Future<Output = S3Result<S3Response<HeadObjectOutput>>> + '_ {
|
||||
usecase.execute_head_object(req)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::app::bucket_usecase::DefaultBucketUsecase;
|
||||
use crate::app::storage_api::s3::{ListObjectsV2Input, ListObjectsV2Output, S3Request, S3Response, S3Result};
|
||||
use std::future::Future;
|
||||
|
||||
/// Calls `DefaultBucketUsecase::execute_list_objects_v2` with today's arguments.
|
||||
///
|
||||
/// Owned by the ListObjectsV2 batch (B2). Tests outside that batch's files call this seam instead of the use-case
|
||||
/// method; when batch B2 changes the use-case signature it changes only this body.
|
||||
pub(crate) fn list_objects_v2(
|
||||
usecase: &DefaultBucketUsecase,
|
||||
req: S3Request<ListObjectsV2Input>,
|
||||
) -> impl Future<Output = S3Result<S3Response<ListObjectsV2Output>>> + '_ {
|
||||
usecase.execute_list_objects_v2(req)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Test-only seams over the use-case entry points that tests of other operation families call as an oracle.
|
||||
//!
|
||||
//! Each seam is owned by the batch that owns its operation. A test outside that family calls the seam, never the
|
||||
//! use-case method, so a signature change touches one seam body instead of another family's tests.
|
||||
//! `scripts/check_test_seams.py` enforces this.
|
||||
|
||||
mod get_object;
|
||||
mod head_object;
|
||||
mod list_objects_v2;
|
||||
mod put_object;
|
||||
|
||||
pub(crate) use get_object::get_object;
|
||||
pub(crate) use head_object::head_object;
|
||||
pub(crate) use list_objects_v2::list_objects_v2;
|
||||
pub(crate) use put_object::put_object;
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::app::object_usecase::DefaultObjectUsecase;
|
||||
use crate::app::storage_api::s3::{PutObjectInput, PutObjectOutput, S3Request, S3Response, S3Result};
|
||||
use crate::app::storage_api::test::ecfs::FS;
|
||||
use std::future::Future;
|
||||
|
||||
/// Calls `DefaultObjectUsecase::execute_put_object` with today's arguments.
|
||||
///
|
||||
/// Owned by the PutObject batch (D). Tests outside that batch's files call this seam instead of the use-case method;
|
||||
/// when batch D changes the use-case signature it changes only this body.
|
||||
pub(crate) fn put_object<'a>(
|
||||
usecase: &'a DefaultObjectUsecase,
|
||||
fs: &'a FS,
|
||||
req: S3Request<PutObjectInput>,
|
||||
) -> impl Future<Output = S3Result<S3Response<PutObjectOutput>>> + 'a {
|
||||
usecase.execute_put_object(fs, req)
|
||||
}
|
||||
@@ -6900,23 +6900,27 @@ mod tests {
|
||||
));
|
||||
let usecase = crate::app::object_usecase::DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
assert_eq!(
|
||||
usecase.execute_get_object(get).await.expect_err("bad GET part number").code(),
|
||||
crate::app::test_seams::get_object(&usecase, get)
|
||||
.await
|
||||
.expect_err("bad GET part number")
|
||||
.code(),
|
||||
&S3ErrorCode::InvalidArgument
|
||||
);
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_head_object(head)
|
||||
crate::app::test_seams::head_object(&usecase, head)
|
||||
.await
|
||||
.expect_err("range and part number conflict")
|
||||
.code(),
|
||||
&S3ErrorCode::InvalidArgument
|
||||
);
|
||||
assert_eq!(
|
||||
crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
|
||||
.execute_list_objects_v2(list)
|
||||
.await
|
||||
.expect_err("negative max keys")
|
||||
.code(),
|
||||
crate::app::test_seams::list_objects_v2(
|
||||
&crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context))),
|
||||
list
|
||||
)
|
||||
.await
|
||||
.expect_err("negative max keys")
|
||||
.code(),
|
||||
&S3ErrorCode::InvalidArgument
|
||||
);
|
||||
}
|
||||
@@ -7004,78 +7008,77 @@ mod tests {
|
||||
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("created identity");
|
||||
sys.apply_for_incarnation(&bucket, incarnation, Some(&config)).await;
|
||||
let usecase = crate::app::object_usecase::DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
usecase
|
||||
.execute_get_object(get.clone())
|
||||
crate::app::test_seams::get_object(&usecase, get.clone())
|
||||
.await
|
||||
.expect("a local GET hit ignores source capture failure");
|
||||
usecase
|
||||
.execute_head_object(head.clone())
|
||||
crate::app::test_seams::head_object(&usecase, head.clone())
|
||||
.await
|
||||
.expect("a local HEAD hit ignores source capture failure");
|
||||
get.input.key = "missing".into();
|
||||
head.input.key = "missing".into();
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_get_object(get.clone())
|
||||
crate::app::test_seams::get_object(&usecase, get.clone())
|
||||
.await
|
||||
.expect_err("failed capture cannot rebind on GET miss")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchBucket
|
||||
);
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_head_object(head.clone())
|
||||
crate::app::test_seams::head_object(&usecase, head.clone())
|
||||
.await
|
||||
.expect_err("failed capture cannot rebind on HEAD miss")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchBucket
|
||||
);
|
||||
assert_eq!(
|
||||
crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
|
||||
.execute_list_objects_v2(list.clone())
|
||||
.await
|
||||
.expect_err("failed capture cannot rebind on LIST")
|
||||
.code(),
|
||||
crate::app::test_seams::list_objects_v2(
|
||||
&crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context))),
|
||||
list.clone()
|
||||
)
|
||||
.await
|
||||
.expect_err("failed capture cannot rebind on LIST")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchBucket
|
||||
);
|
||||
config.filter.prefix = Some("remote/".into());
|
||||
sys.apply_for_incarnation(&bucket, incarnation, Some(&config)).await;
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_get_object(get.clone())
|
||||
crate::app::test_seams::get_object(&usecase, get.clone())
|
||||
.await
|
||||
.expect_err("filtered GET remains local")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchKey
|
||||
);
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_head_object(head.clone())
|
||||
crate::app::test_seams::head_object(&usecase, head.clone())
|
||||
.await
|
||||
.expect_err("filtered HEAD remains local")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchKey
|
||||
);
|
||||
list.input.prefix = Some("local/".into());
|
||||
let listing = crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
|
||||
.execute_list_objects_v2(list.clone())
|
||||
.await
|
||||
.expect("disjoint prefix needs no source identity");
|
||||
let listing = crate::app::test_seams::list_objects_v2(
|
||||
&crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context))),
|
||||
list.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("disjoint prefix needs no source identity");
|
||||
assert_eq!(listing.output.key_count, Some(0));
|
||||
list.input.prefix = Some("remote/".into());
|
||||
list.input.max_keys = Some(0);
|
||||
let listing = crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context)))
|
||||
.execute_list_objects_v2(list)
|
||||
.await
|
||||
.expect("an empty page needs no source identity");
|
||||
let listing = crate::app::test_seams::list_objects_v2(
|
||||
&crate::app::bucket_usecase::DefaultBucketUsecase::with_context(Some(Arc::clone(&context))),
|
||||
list,
|
||||
)
|
||||
.await
|
||||
.expect("an empty page needs no source identity");
|
||||
assert_eq!(listing.output.key_count, Some(0));
|
||||
|
||||
config.filter.prefix = None;
|
||||
config.policy.head = crate::on_demand_migration::HeadPolicy::LocalOnly;
|
||||
sys.apply_for_incarnation(&bucket, incarnation, Some(&config)).await;
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_head_object(head.clone())
|
||||
crate::app::test_seams::head_object(&usecase, head.clone())
|
||||
.await
|
||||
.expect_err("local-only HEAD needs no source identity")
|
||||
.code(),
|
||||
@@ -7095,16 +7098,14 @@ mod tests {
|
||||
.await
|
||||
.expect("create local delete marker");
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_get_object(get)
|
||||
crate::app::test_seams::get_object(&usecase, get)
|
||||
.await
|
||||
.expect_err("GET respects the delete marker before capture failure")
|
||||
.code(),
|
||||
&S3ErrorCode::NoSuchKey
|
||||
);
|
||||
assert_eq!(
|
||||
usecase
|
||||
.execute_head_object(head)
|
||||
crate::app::test_seams::head_object(&usecase, head)
|
||||
.await
|
||||
.expect_err("HEAD respects the delete marker before capture failure")
|
||||
.code(),
|
||||
|
||||
@@ -14460,8 +14460,8 @@ fn object_mutation_entrypoints_call_reserved_prefix_guard() {
|
||||
assert_ordered(
|
||||
method(delete, "fn execute_delete_objects_owned("),
|
||||
&[
|
||||
"validate_object_key(&obj_id.key, \"DeleteObjects\")",
|
||||
"if let Err(err) = validate_table_catalog_object_mutation(&bucket, &obj_id.key).await {",
|
||||
"validate_object_key(&key, \"DeleteObjects\")",
|
||||
"if let Err(err) = validate_table_catalog_object_mutation(&bucket, &key).await {",
|
||||
"continue;\n }",
|
||||
"authorized_deletes.push(AuthorizedDelete { idx, object });",
|
||||
],
|
||||
|
||||
+8
-3
@@ -37,11 +37,11 @@ their issue closes.
|
||||
| `check_no_planning_docs.sh` | ci-gate | Blocks committed planning-type documents | ci.yml Quick Checks; `make pre-commit` |
|
||||
| `check_no_tokio_io_uring.sh` | ci-gate | Keeps tokio's io-uring backend disabled | ci.yml Quick Checks |
|
||||
| `check_s3s_footprint.sh` | ci-gate | Exact, lower-only baseline on the direct s3s surface ahead of the gateway migration (reads `.config/s3s-footprint-baseline.txt`; `s3_error_lines` leaves out lines whose file imports the macro from `rustfs-s3-types`, rule in the script header); `--mode allowlist` fails on any Rust file referencing s3s outside `.config/s3s-edge-allowlist.txt`, `--dry-run` lists them | ci.yml Quick Checks; `make pre-commit`; `make script-tests` (`--self-test`) |
|
||||
| `check_test_count_ratchet.sh` | ci-gate | Exact per-crate test-count and `#[ignore]` ratchet against `.config/test-count-baseline.txt`; no crate may lose tests or gain ignores | ci.yml Quick Checks; `make pre-commit` |
|
||||
| `check_test_count_ratchet.sh` | ci-gate | Per-crate test-count floor and `#[ignore]` ceiling against `.config/test-count-baseline.txt`; no crate may fall below its tests row or rise above its ignored row (extra tests and new crates pass with a note) | ci.yml Quick Checks; `make pre-commit` |
|
||||
| `check_unsafe_code_allowances.sh` | ci-gate | Unsafe-code allowance ledger guard | ci.yml Quick Checks |
|
||||
| `layer-dependency-baseline.txt` | ci-gate (data) | Committed baseline consumed by `check_layer_dependencies.sh` | [Architecture guard troubleshooting](../docs/operations/architecture-guard-troubleshooting.md) |
|
||||
| `static.sh` | ci-gate | Static-build helper executed inside image builds | `Dockerfile.source`, `Dockerfile.decommission-local` |
|
||||
| `helm_chart_version.sh` | ci-gate | Keeps the Helm chart version in sync with the release | helm-package.yml |
|
||||
| `helm_chart_version.sh` | dev-tool | Normalizes chart/app versions for installation references | rustfs-release-version-bump skill |
|
||||
| `test_helm_templates.sh` | ci-gate | Helm template rendering test | helm-package.yml |
|
||||
|
||||
## Test & e2e runners
|
||||
@@ -55,6 +55,11 @@ their issue closes.
|
||||
| `test_prepare_replacement_migration.py` | dev-tool | Verifies maintenance approval scope, publication, and stopped-writer assertion | Python unittest; same runbook |
|
||||
| `test_diagnose_scanner_enumeration_restart.py` | dev-tool | Driver report validation and positive convergence oracle tests | Python unittest; same guide |
|
||||
| `test_object_generation_protocol_model.py` | dev-tool | Bounded single-slot, four-voter promise/accept design model and recovery/retirement examples; no production runtime evidence | `python3 scripts/test_object_generation_protocol_model.py`; [Generation contract](../docs/architecture/unified-object-generation.md#executable-design-model-and-acceptance-mapping) |
|
||||
| `junit_diff.py` | dev-tool | Case-by-case pass/fail/skip diff of two JUnit XML reports (reference vs candidate stack); exit 1 on any difference or stale `--allow` entry, 2 on unreadable input | `python3 scripts/junit_diff.py a.xml b.xml`; backlog T3.1 |
|
||||
| `test_junit_diff.py` | ci-gate | Contract tests and fixtures (`scripts/tests/junit_diff/`) for `junit_diff.py` | `make script-tests` |
|
||||
| `s3-tests/reference_parity.py` | ci-gate | Plans the reference parity targets, lists a run's failures for the single rerun, and compares every candidate's s3-tests JUnit against the pinned reference per node id with a tolerated-difference file | `.github/workflows/reference-parity.yml`; backlog T3.2 |
|
||||
| `s3-tests/test_reference_parity.py` | ci-gate | Contract tests for `reference_parity.py` | `make script-tests` |
|
||||
| `test_reference_binary.py` | ci-gate | Contract tests for `.github/actions/reference-binary/reference_binary.py` (pin resolution and verified unpacking) | `make script-tests` |
|
||||
| `e2e-run.sh` | ci-gate | Boots a rustfs server and runs the `s3s-e2e` black-box conformance tool against it | ci.yml `e2e-tests` jobs; `docs/testing/README.md` |
|
||||
| `run_ecstore_validation_suite.sh` | dev-tool | ecstore black-box validation suite (`quick`/`full`/`destructive`/`fuzz` profiles) | `docs/testing/README.md`, `docs/testing/ecstore-validation-suite-design.md` |
|
||||
| `run_e2e_tests.sh` | dev-tool | Local `e2e_test` crate runner (starts a server, applies filters, cleans up) | `crates/e2e_test/README.md` |
|
||||
@@ -164,7 +169,7 @@ their issue closes.
|
||||
|---|---|---|---|
|
||||
| `codemods/` | dev-tool | Scripted, idempotent source migrations for the gateway migration (rustfs/backlog#2734); `s3_error_to_s3_types.py` moves s3s error-type paths onto `rustfs-s3-types` per crate closure and refuses the s3s edge allowlist | `make script-tests` (`--self-test`); usage in each script's docstring |
|
||||
| `fuzz/` | ci-gate | Unified cargo-fuzz runner and helpers for the `fuzz/` sub-workspace | fuzz.yml; `fuzz/README.md` |
|
||||
| `release/` | ci-gate | Release creation and DEB/RPM version-normalization helpers | build.yml; package.yml |
|
||||
| `release/` | ci-gate | Release creation and DEB/RPM version-normalization helpers | build.yml; package.yml; manual-main-packages.yml |
|
||||
| `s3-tests/` | ci-gate | ceph/s3-tests compatibility harness (allow-lists, patches, report tooling) | ci.yml; e2e-s3tests.yml; `scripts/s3-tests/README.md` |
|
||||
| `security/` | ci-gate | Workflow-pin enforcement and release supply-chain asset generation | audit.yml; build.yml |
|
||||
| `table-catalog/` | dev-tool | S3-Tables / pyiceberg validation suite | `docs/architecture/s3-tables-support-matrix.md` |
|
||||
|
||||
@@ -2261,10 +2261,10 @@ fi
|
||||
rustfs/src/app/select_object.rs rustfs/src/app/*_usecase.rs rustfs/src/app/object || true
|
||||
rg -n --with-filename \
|
||||
'use super::(?:\{[^}]*\b(?:DynReader|HashReader|WriteEncryption|WritePlan|DecryptReader|EncryptReader|HardLimitReader|boxed_reader|wrap_reader|compression_metadata_value|is_disk_compressible|MIN_DISK_COMPRESSIBLE_SIZE|get_lock_acquire_timeout|is_valid_storage_class|StorageError|DiskError|is_all_buckets_not_found|is_err_bucket_not_found|is_err_object_not_found|is_err_version_not_found)\b|(?:object_api_utils::to_s3s_etag|storageclass|StorageError|DiskError|DynReader|HashReader|WriteEncryption|WritePlan|DecryptReader|EncryptReader|HardLimitReader|boxed_reader|wrap_reader|compression_metadata_value|is_disk_compressible|MIN_DISK_COMPRESSIBLE_SIZE|get_lock_acquire_timeout|is_valid_storage_class|is_all_buckets_not_found|is_err_bucket_not_found|is_err_object_not_found|is_err_version_not_found)\b)' \
|
||||
rustfs/src/app/bucket_usecase.rs rustfs/src/app/object_usecase.rs rustfs/src/app/object rustfs/src/app/multipart_usecase.rs rustfs/src/app/lifecycle_transition_api_test.rs || true
|
||||
rustfs/src/app/bucket_usecase.rs rustfs/src/app/object_usecase.rs rustfs/src/app/object rustfs/src/app/multipart_usecase.rs rustfs/src/app/lifecycle_transition_api_test.rs rustfs/src/app/lifecycle_transition_api_test || true
|
||||
rg -n --with-filename \
|
||||
'use super::(?:\{[^}]*\b(?:AppObjectLockConfigExt|AppReplicationConfigExt|AppVersioningConfigExt|predict_lifecycle_expiration|validate_restore_request|bucket_target_sys|lifecycle|metadata|metadata_sys|object_lock|policy_sys|quota|replication|tagging|target|utils|versioning_sys|transition_api|ObjectInfo|ObjectOptions)\b|(?:AppObjectLockConfigExt|AppReplicationConfigExt|AppVersioningConfigExt|predict_lifecycle_expiration|validate_restore_request|bucket_target_sys|lifecycle|metadata|metadata_sys|object_lock|policy_sys|quota|replication|tagging|target|utils|versioning_sys|transition_api|ObjectInfo|ObjectOptions)\b)|super::(?:lifecycle|metadata_sys|object_lock|quota|replication|tagging|target|utils|versioning_sys|transition_api)::|super::super::(?:metadata_sys|lifecycle|target)::' \
|
||||
rustfs/src/app/bucket_usecase.rs rustfs/src/app/object_usecase.rs rustfs/src/app/object rustfs/src/app/multipart_usecase.rs rustfs/src/app/lifecycle_transition_api_test.rs rustfs/src/app/capacity_dirty_scope_test.rs rustfs/src/app/context.rs rustfs/src/app/context/handles.rs rustfs/src/app/context/interfaces.rs rustfs/src/app/context/runtime_sources.rs || true
|
||||
rustfs/src/app/bucket_usecase.rs rustfs/src/app/object_usecase.rs rustfs/src/app/object rustfs/src/app/multipart_usecase.rs rustfs/src/app/lifecycle_transition_api_test.rs rustfs/src/app/lifecycle_transition_api_test rustfs/src/app/capacity_dirty_scope_test.rs rustfs/src/app/context.rs rustfs/src/app/context/handles.rs rustfs/src/app/context/interfaces.rs rustfs/src/app/context/runtime_sources.rs || true
|
||||
}
|
||||
) >"$RUSTFS_APP_USECASE_STORAGE_API_BYPASS_HITS_FILE"
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user